From 3560650ac1f6621f01168653295d6394746295a1 Mon Sep 17 00:00:00 2001 From: LordMZTE Date: Sat, 2 Mar 2024 19:54:51 +0100 Subject: [PATCH] init --- .gitignore | 1 + assets.zig | 1 + assets/steve_skin.png | Bin 0 -> 1143 bytes build.zig | 34 +++ build.zig.zon | 7 + conf.json | 14 ++ src/Config.zig | 9 + src/Db.zig | 140 ++++++++++++ src/Id.zig | 30 +++ src/JsonUserProfile.zig | 93 ++++++++ src/State.zig | 63 ++++++ src/conutil.zig | 92 ++++++++ src/ffi.zig | 32 +++ src/main.zig | 207 ++++++++++++++++++ src/routes/aliapi/api/profiles/minecraft.zig | 93 ++++++++ src/routes/aliapi/authserver/authenticate.zig | 185 ++++++++++++++++ src/routes/aliapi/index.zig | 45 ++++ .../session/minecraft/has_joined.zig | 74 +++++++ .../sessionserver/session/minecraft/join.zig | 68 ++++++ .../session/minecraft/profile.zig | 99 +++++++++ src/routes/default_skin.zig | 19 ++ src/routes/root.zig | 19 ++ 22 files changed, 1325 insertions(+) create mode 100644 .gitignore create mode 100644 assets.zig create mode 100644 assets/steve_skin.png create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 conf.json create mode 100644 src/Config.zig create mode 100644 src/Db.zig create mode 100644 src/Id.zig create mode 100644 src/JsonUserProfile.zig create mode 100644 src/State.zig create mode 100644 src/conutil.zig create mode 100644 src/ffi.zig create mode 100644 src/main.zig create mode 100644 src/routes/aliapi/api/profiles/minecraft.zig create mode 100644 src/routes/aliapi/authserver/authenticate.zig create mode 100644 src/routes/aliapi/index.zig create mode 100644 src/routes/aliapi/sessionserver/session/minecraft/has_joined.zig create mode 100644 src/routes/aliapi/sessionserver/session/minecraft/join.zig create mode 100644 src/routes/aliapi/sessionserver/session/minecraft/profile.zig create mode 100644 src/routes/default_skin.zig create mode 100644 src/routes/root.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe95f8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/zig-* diff --git a/assets.zig b/assets.zig new file mode 100644 index 0000000..db3969a --- /dev/null +++ b/assets.zig @@ -0,0 +1 @@ +pub const steve_skin = @embedFile("assets/steve_skin.png"); diff --git a/assets/steve_skin.png b/assets/steve_skin.png new file mode 100644 index 0000000000000000000000000000000000000000..c1bc5650b3ac7c9e1bcba302a470381dcedcba03 GIT binary patch literal 1143 zcmV--1c>{IP)ZNSPw~PCD|5KPeNh_K_F4| z(65KE$DaJ3zUIum-|gFB?q1!bUDpeTnVmT^JLk-NbH5vjggnv8O$6-Oxq)OI*uABg z?w*QeL}aZ$EI77%vxYPPfXJcNL-eG#$uEI|GJwT5lotgWpr~m;TR|AWyKja8w-vkr zQY=mYAj4r@7+@10W?+n5A-HIR0nEb~jKv)2=Xt~Oh`cfYI8arpg+SnuJ!QIN z;P;8>R*FZrYaR??4#uF3=aB|v$|G`afL`=Ych_9uN>~MUy2v-;duoIhUnJ@G%p@&7 zPpbMDeY|&tn(DV|4!9Vz-!o5}pytHfVR=M;oPct5lq*Z!U9E6K0rgmI8GU$gi@r{c z>hja%D9t6W($7?q=5BXufCDbZU@YpG3v=RkSRRo#A>f8V3Dxb`NYj^^X|BJ6nw@Q0 zJf5DG6nz^X(Be_Y15>~O7h^CMbI3Wv@`(H~K<$`&Y|Ch>r;%o^wb9cnN0qov`Z9il zemqM0ueV=MN9f(qDVkI{KRDoG48~#(-hFuq2IUcXaR*?bIJP(1WAtX|9L@Hh^gn1` z^|dR3t<+XiLeIKeG&*Wa>1E$>df0nZbHK$IjKv%}1iWwv%Oi4b0Drx)=(&N@dPkpK zK1O4wV|1tE0FAaswE-t8i?n#)@r7e_|LhUX0r%}-mmWKN^Nd@1^Rba;9>K=tU0!gp zYl$Qr$4Nuz1WQZ7Ns;P{T)&~_Y z36q9#2-wDWN8M(?%$el(*RqDtcn4d`ZHca<{Q8?z;Wq&QoQO9NyGaJg&)f&_;3%Md|n8t7+?!T>Yz@-I{l z69vA6gA8DiwiA*w18gTK%>*SZG__V7*=Fu=+6Aa!yCYU&z#rYM<6XmvsF(l%002ov JPDHLkV1me<5KRC8 literal 0 HcmV?d00001 diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..c83f5e9 --- /dev/null +++ b/build.zig @@ -0,0 +1,34 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const assets_mod = b.addModule("assets", .{ + .root_source_file = .{ .path = "assets.zig" }, + }); + + const exe = b.addExecutable(.{ + .name = "anvilauth", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + .link_libc = true, + }); + + exe.root_module.addImport("assets", assets_mod); + + exe.linkSystemLibrary("libpq"); + exe.linkSystemLibrary("openssl"); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..0e4e521 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,7 @@ +.{ + .name = "anvilauth", + .version = "0.0.0", + + .dependencies = .{}, + .paths = .{""}, +} diff --git a/conf.json b/conf.json new file mode 100644 index 0000000..de81847 --- /dev/null +++ b/conf.json @@ -0,0 +1,14 @@ +{ + "bind": { + "ip": "127.0.0.1", + "port": 8080 + }, + "base_url": "http://localhost:8081/", + "postgres_url": "postgres://anvilauth:alec@localhost:5432/anvilauth", + "forgejo_url": "http://localhost:8082/", + "skin_domains": [ + "localhost", + ".localhost" + ], + "server_name": "AnvilAuth test server" +} diff --git a/src/Config.zig b/src/Config.zig new file mode 100644 index 0000000..6e08753 --- /dev/null +++ b/src/Config.zig @@ -0,0 +1,9 @@ +bind: struct { + ip: []const u8, + port: u16, +}, +base_url: []const u8, +postgres_url: [:0]const u8, +forgejo_url: []const u8, +skin_domains: []const []const u8, +server_name: []const u8, diff --git a/src/Db.zig b/src/Db.zig new file mode 100644 index 0000000..43bf097 --- /dev/null +++ b/src/Db.zig @@ -0,0 +1,140 @@ +const std = @import("std"); + +const ffi = @import("ffi.zig"); +const c = ffi.c; + +const Id = @import("Id.zig"); + +con: *c.PGconn, + +const Db = @This(); + +pub fn initDb(self: Db) !void { + const query = + \\CREATE TABLE IF NOT EXISTS users ( + \\ id UUID NOT NULL PRIMARY KEY, + \\ name VARCHAR NOT NULL UNIQUE + \\); + \\ + \\CREATE TABLE IF NOT EXISTS tokens ( + \\ id UUID NOT NULL PRIMARY KEY, + \\ userid UUID NOT NULL REFERENCES users (id), + \\ expiry BIGINT NOT NULL, + \\ client_token VARCHAR NOT NULL + \\); + \\ + \\CREATE TABLE IF NOT EXISTS joins ( + \\ userid UUID NOT NULL PRIMARY KEY REFERENCES users (id), + \\ serverid VARCHAR NOT NULL + \\); + ; + + const status = self.exec(query); + defer status.deinit(); + try status.expectCommand(); +} + +pub fn exec(self: Db, query: [:0]const u8) Result { + return .{ .res = c.PQexec(self.con, query.ptr) }; +} + +pub fn execParams(self: Db, query: [:0]const u8, params: anytype) Result { + var args_buf: [1024 * 2]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&args_buf); + const alloc = fba.allocator(); + + const nparams = @typeInfo(@TypeOf(params)).Struct.fields.len; + + const vals = alloc.alloc([*]const u8, nparams) catch return Result.oom; + const lengths = alloc.alloc(c_int, nparams) catch return Result.oom; + const formats = alloc.alloc(c_int, nparams) catch return Result.oom; + + inline for (params, vals, lengths, formats) |param, *val, *len, *format| { + switch (@TypeOf(param)) { + []const u8, [:0]const u8 => { + val.* = param.ptr; + len.* = @intCast(param.len); + format.* = 1; + }, + [*:0]const u8 => { + val.* = param; + len.* = -1; + format.* = 0; + }, + i64 => { + const bytes = std.mem.asBytes(&std.mem.nativeToBig(i64, param)); + val.* = bytes.ptr; + len.* = @intCast(bytes.len); + format.* = 1; + }, + Id => { + val.* = ¶m.bytes; + len.* = param.bytes.len; + format.* = 1; + }, + else => @compileError("unsupported parameter type: " ++ @typeName(@TypeOf(param))), + } + } + + const ret = c.PQexecParams( + self.con, + query, + nparams, + null, + vals.ptr, + lengths.ptr, + formats.ptr, + 1, + ); + return .{ .res = ret }; +} + +pub const Result = struct { + pub const oom = Result{ .res = null }; + + res: ?*c.PGresult, + + pub inline fn deinit(self: Result) void { + if (self.res) |res| c.PQclear(res); + } + + pub fn expect(self: Result, expected: c_uint) !void { + const actual = c.PQresultStatus(self.res); // safe to call with null + if (actual != expected) { + std.log.err("expected result `{s}`, got `{s}` (msg: `{s}`)", .{ + c.PQresStatus(expected), + c.PQresStatus(actual), + @as([*:0]const u8, c.PQresultErrorField(self.res, c.PG_DIAG_MESSAGE_PRIMARY) orelse ""), + }); + return error.UnexpectedSqlStatus; + } + } + + pub inline fn expectTuples(self: Result) !void { + try self.expect(c.PGRES_TUPLES_OK); + } + + pub inline fn expectCommand(self: Result) !void { + try self.expect(c.PGRES_COMMAND_OK); + } + + pub inline fn rows(self: Result) c_int { + return c.PQntuples(self.res); + } + + pub inline fn cols(self: Result) c_int { + return c.PQnfields(self.res); + } + + pub fn getOptional(self: Result, comptime T: type, row: c_int, col: c_int) ?T { + return if (c.PQgetisnull(self.res, row, col) == 1) null else self.get(T, row, col); + } + + pub inline fn get(self: Result, comptime T: type, row: c_int, col: c_int) T { + return switch (T) { + []const u8 => c.PQgetvalue(self.res, row, col)[0..@intCast(c.PQgetlength(self.res, row, col))], + Id => .{ .bytes = c.PQgetvalue(self.res, row, col)[0..16].* }, + else => @compileError("unsuppored type: " ++ @typeName(T)), + }; + } +}; diff --git a/src/Id.zig b/src/Id.zig new file mode 100644 index 0000000..7692fb7 --- /dev/null +++ b/src/Id.zig @@ -0,0 +1,30 @@ +/// This file implements something similar to UUIDs without the bullshit. +/// It implements a simple even U-er UID, which is simply 16 random bytes. +const std = @import("std"); + +bytes: [16]u8, + +const Id = @This(); + +/// Returns a hex encoded version of the ID. +pub fn toString(self: Id) [32]u8 { + var ret: [32]u8 = undefined; + _ = std.fmt.bufPrint(&ret, "{}", .{std.fmt.fmtSliceHexLower(&self.bytes)}) catch unreachable; + return ret; +} + +pub fn genRandom(rand: std.rand.Random) Id { + var bytes: [16]u8 = undefined; + rand.bytes(&bytes); + return .{ .bytes = bytes }; +} + +// Parses the given string as an ID, or returns null if it is invalid. +pub fn parse(str: []const u8) ?Id { + if (str.len != 32) return null; + var bytes: [16]u8 = undefined; + for (&bytes, 0..) |*out, ihalf| { + out.* = std.fmt.parseInt(u8, str[ihalf * 2 ..][0..2], 16) catch return null; + } + return .{ .bytes = bytes }; +} diff --git a/src/JsonUserProfile.zig b/src/JsonUserProfile.zig new file mode 100644 index 0000000..68f5d60 --- /dev/null +++ b/src/JsonUserProfile.zig @@ -0,0 +1,93 @@ +// TODO: implement JSON serializer instead of this jank +const std = @import("std"); +const c = ffi.c; + +const ffi = @import("ffi.zig"); + +const Id = @import("Id.zig"); + +pub const Property = struct { + name: []const u8, + value: []const u8, + signature: ?[]const u8 = null, +}; + +id: []const u8, +name: []const u8, +properties: [1]Property, + +const JsonUserProfile = @This(); + +/// id and skin_url are copied, name is only copied for properties! +pub fn init( + alloc: std.mem.Allocator, + id: Id, + name: []const u8, + skin_url: []const u8, + rsa: ?*c.RSA, +) !JsonUserProfile { + const id_s = try alloc.dupe(u8, &id.toString()); + errdefer alloc.free(id_s); + + const textures_value = .{ + .timestamp = std.time.timestamp(), + .profileId = id_s, + .profileName = name, + .textures = .{ + .SKIN = .{ + .url = skin_url, + }, + }, + }; + const textures_json = try std.json.stringifyAlloc(alloc, textures_value, .{}); + defer alloc.free(textures_json); + + const textures_b64 = try alloc.alloc(u8, std.base64.standard.Encoder.calcSize(textures_json.len)); + errdefer alloc.free(textures_b64); + std.debug.assert(std.base64.standard.Encoder.encode(textures_b64, textures_json).len == textures_b64.len); + + var textures_prop = Property{ + .name = "textures", + .value = textures_b64, + }; + + if (rsa) |r| { + var hasher = std.crypto.hash.Sha1.init(.{}); + hasher.update(textures_b64); + + const retbuf = try alloc.alloc(u8, @intCast(c.RSA_size(r))); + defer alloc.free(retbuf); + + var retlen: c_uint = 0; + if (c.RSA_sign( + c.NID_sha1, + &hasher.finalResult(), + std.crypto.hash.Sha1.digest_length, + retbuf.ptr, + &retlen, + r, + ) != 1) return error.OpenSSLBorked; + + const signature = retbuf[0..retlen]; + + const sig_b64 = try alloc.alloc(u8, std.base64.standard.Encoder.calcSize(signature.len)); + errdefer alloc.free(sig_b64); + std.debug.assert(std.base64.standard.Encoder.encode(sig_b64, signature).len == sig_b64.len); + + textures_prop.signature = sig_b64; + } + + return .{ + .id = id_s, + .name = name, + .properties = .{textures_prop}, + }; +} + +pub fn deinit(self: JsonUserProfile, alloc: std.mem.Allocator) void { + alloc.free(self.id); + for (self.properties) |prop| { + alloc.free(prop.value); + if (prop.signature) |sig| alloc.free(sig); + } +} diff --git a/src/State.zig b/src/State.zig new file mode 100644 index 0000000..e56da9c --- /dev/null +++ b/src/State.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const c = @import("ffi.zig").c; + +const Db = @import("Db.zig"); + +pub const SkinCache = std.StringHashMapUnmanaged(struct { has_skin: bool, expiration: i64 }); + +allocator: std.mem.Allocator, +base_url: []const u8, +forgejo_url: []const u8, +skin_domains: []const []const u8, +server_name: []const u8, +http: std.http.Client, +db: Db, +rand: std.rand.Random, +rsa: *c.RSA, +x509: *c.X509, +default_skin_url: []const u8, +skin_cache: SkinCache, +skin_cache_mtx: std.Thread.Mutex = .{}, + +const State = @This(); + +/// Gets the skin URL for a given user, if the user has a skin URL set. May do network IO for checking. +pub fn getSkinUrl(self: *State, username: []const u8) !?[]const u8 { + self.skin_cache_mtx.lock(); + defer self.skin_cache_mtx.unlock(); + + const url = try std.fmt.allocPrint( + self.allocator, + "{s}/{s}/.anvilauth/raw/branch/master/skin.png", + .{ self.forgejo_url, username }, + ); + errdefer self.allocator.free(url); + + if (self.skin_cache.get(username)) |entry| { + if (std.time.milliTimestamp() < entry.expiration) { + if (entry.has_skin) + return url; + + self.allocator.free(url); + return null; + } + } + + const res = try self.http.fetch(.{ + .method = .HEAD, + .location = .{ .url = url }, + }); + + const username_d = try self.allocator.dupe(u8, username); + errdefer self.allocator.free(username_d); + try self.skin_cache.put(self.allocator, username_d, .{ + .has_skin = res.status == .ok, + .expiration = std.time.milliTimestamp() + std.time.ms_per_day, + }); + + if (res.status == .ok) + return url; + + self.allocator.free(url); + return null; +} diff --git a/src/conutil.zig b/src/conutil.zig new file mode 100644 index 0000000..c702662 --- /dev/null +++ b/src/conutil.zig @@ -0,0 +1,92 @@ +const std = @import("std"); + +pub fn sendJsonError( + req: *std.http.Server.Request, + code: std.http.Status, + comptime fmt: []const u8, + args: anytype, +) !void { + const JsonError = struct { + @"error": []const u8, + errorMessage: []const u8, + }; + + var fmt_buf: [1024 * 4]u8 = undefined; + const json_error = JsonError{ + .@"error" = code.phrase() orelse "Unknown Error", + .errorMessage = try std.fmt.bufPrint(&fmt_buf, "[AnvilAuth] " ++ fmt, args), + }; + + var ser_buf: [1024 * 4]u8 = undefined; + var ser_fbs = std.io.fixedBufferStream(&ser_buf); + try std.json.stringify(json_error, .{}, ser_fbs.writer()); + + try req.respond(ser_fbs.getWritten(), .{ + .status = code, + .extra_headers = &.{.{ + .name = "Content-Type", + .value = "application/json", + }}, + }); +} + +pub const QueryParameterError = error{ MissingParameter, InvalidParameters }; + +pub fn parseQueryParametersFromUri(uri: std.Uri, comptime T: type) QueryParameterError!T { + return if (uri.query) |q| try parseQueryParameters(q, T) else error.MissingParameter; +} + +pub fn parseQueryParameters(params: []const u8, comptime T: type) QueryParameterError!T { + const DefaultedT = comptime blk: { + const info = @typeInfo(T); + var opt_fields: [info.Struct.fields.len]std.builtin.Type.StructField = undefined; + + for (&opt_fields, info.Struct.fields) |*ofield, field| { + ofield.* = .{ + .name = field.name, + .type = ?field.type, + .default_value = @as(*const ?field.type, &null), + .is_comptime = false, + .alignment = 0, + }; + } + + break :blk @Type(.{ .Struct = .{ + .layout = .Auto, + .fields = &opt_fields, + .decls = &.{}, + .is_tuple = false, + } }); + }; + + const def_out = try parseQueryParametersOrDefaults(params, DefaultedT); + + var out: T = undefined; + inline for (comptime std.meta.fieldNames(T)) |fname| { + @field(out, fname) = @field(def_out, fname) orelse return error.MissingParameter; + } + + return out; +} + +pub fn parseQueryParametersOrDefaults(params: []const u8, comptime T: type) QueryParameterError!T { + var out: T = .{}; + + var iter = std.mem.splitScalar(u8, params, '&'); + while (iter.next()) |param| { + var psplit = std.mem.splitScalar(u8, param, '='); + const key = psplit.next() orelse return error.InvalidParameters; + const value = psplit.next() orelse return error.InvalidParameters; + if (psplit.next()) |_| return error.InvalidParameters; + + inline for (comptime std.meta.fieldNames(T)) |fname| { + if (std.mem.eql(u8, key, fname)) { + @field(out, fname) = value; + break; + } + } + } + + return out; +} + diff --git a/src/ffi.zig b/src/ffi.zig new file mode 100644 index 0000000..c177167 --- /dev/null +++ b/src/ffi.zig @@ -0,0 +1,32 @@ +const std = @import("std"); + +pub const c = @cImport({ + @cInclude("libpq-fe.h"); + @cInclude("openssl/rsa.h"); + @cInclude("openssl/bio.h"); + @cInclude("openssl/pem.h"); + @cInclude("openssl/x509.h"); +}); + +const libpq_log = std.log.scoped(.libpq); + +pub fn libpqNoticeReceiverCb(_: ?*anyopaque, res: ?*const c.PGresult) callconv(.C) void { + const severity_str: [*:0]const u8 = c.PQresultErrorField(res, c.PG_DIAG_SEVERITY_NONLOCALIZED) orelse + c.PQresultErrorField(res, c.PG_DIAG_SEVERITY) orelse + "LOG"; + + const msg = c.PQresultErrorField(res, c.PG_DIAG_MESSAGE_PRIMARY); + const sev = std.mem.span(severity_str); + if (std.mem.eql(u8, sev, "ERROR") or + std.mem.eql(u8, sev, "FATAL") or + std.mem.eql(u8, sev, "PANIC")) + { + libpq_log.err("{s}", .{msg}); + } else if (std.mem.eql(u8, sev, "WARNING")) { + libpq_log.warn("{s}", .{msg}); + } else if (std.mem.eql(u8, sev, "DEBUG")) { + libpq_log.debug("{s}", .{msg}); + } else { + libpq_log.info("{s}", .{msg}); + } +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..0ac745c --- /dev/null +++ b/src/main.zig @@ -0,0 +1,207 @@ +const std = @import("std"); +const c = ffi.c; + +const ffi = @import("ffi.zig"); + +const Config = @import("Config.zig"); +const Db = @import("Db.zig"); +const State = @import("State.zig"); + +pub const std_options = std.Options{ + .log_level = if (@import("builtin").mode == .Debug) .debug else .info, +}; + +pub fn main() !u8 { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + const confpath = conf: { + var argiter = try std.process.argsWithAllocator(alloc); + defer argiter.deinit(); + _ = argiter.next() orelse unreachable; + + const confpath = argiter.next() orelse { + std.log.err("Need a config argument!", .{}); + return error.InvalidArgs; + }; + const confpath_d = try alloc.dupe(u8, confpath); + errdefer alloc.free(confpath_d); + + if (argiter.next()) |_| { + std.log.err("Too many arguments!", .{}); + return error.InvalidArgs; + } + + break :conf confpath_d; + }; + defer alloc.free(confpath); + + var conffile = try std.fs.cwd().openFile(confpath, .{}); + defer conffile.close(); + + var json_reader = std.json.reader(alloc, conffile.reader()); + defer json_reader.deinit(); + + const config_parsed = try std.json.parseFromTokenSource( + Config, + alloc, + &json_reader, + .{ .ignore_unknown_fields = true }, + ); + defer config_parsed.deinit(); + + const postgres_con = c.PQconnectdb(config_parsed.value.postgres_url) orelse unreachable; + defer c.PQfinish(postgres_con); + + if (c.PQstatus(postgres_con) != c.CONNECTION_OK) { + std.log.err("connecting to DB: {s}", .{c.PQerrorMessage(postgres_con)}); + return error.DatabaseConnect; + } + + _ = c.PQsetNoticeReceiver(postgres_con, ffi.libpqNoticeReceiverCb, null); + + const db = Db{ .con = postgres_con }; + + std.log.info("initializing database", .{}); + try db.initDb(); + + var rand = rand: { + var seed: [32]u8 = undefined; + std.crypto.random.bytes(&seed); + break :rand std.rand.DefaultCsprng.init(seed); + }; + + const rsa = c.RSA_new() orelse return error.OutOfMemory; + { + errdefer c.RSA_free(rsa); + + const bn = c.BN_new() orelse return error.OutOfMemory; + defer c.BN_free(bn); + + if (c.BN_set_word(bn, c.RSA_F4) != 1) return error.OpenSSLBorked; + if (c.RSA_generate_key_ex(rsa, 1024 * 4, bn, null) != 1) return error.OpenSSLBorked; + } + + const pkey = c.EVP_PKEY_new() orelse return error.OutOfMemory; + defer c.EVP_PKEY_free(pkey); + + if (c.EVP_PKEY_assign_RSA(pkey, rsa) != 1) return error.OpenSSLBorked; + + const x509 = c.X509_new() orelse return error.OutOfMemory; + defer c.X509_free(x509); + + { + if (c.ASN1_INTEGER_set(c.X509_get_serialNumber(x509), 1) != 1) return error.OpenSSLBorked; + _ = c.X509_gmtime_adj(c.X509_get_notBefore(x509), 0); + _ = c.X509_gmtime_adj(c.X509_get_notAfter(x509), std.time.s_per_day * 365); + if (c.X509_set_pubkey(x509, pkey) != 1) return error.OpenSSLBorked; + + const subjname = c.X509_get_subject_name(x509); + if (c.X509_set_issuer_name(x509, subjname) != 1) return error.OpenSSLBorked; + if (c.X509_sign(x509, pkey, c.EVP_sha1()) == 0) return error.OpenSSLBorked; + } + + const base_url = std.mem.trimRight(u8, config_parsed.value.base_url, "/"); + const default_skin_url = try std.fmt.allocPrint(alloc, "{s}/default_skin", .{base_url}); + defer alloc.free(default_skin_url); + + var state = State{ + .allocator = alloc, + .base_url = base_url, + .forgejo_url = std.mem.trimRight(u8, config_parsed.value.forgejo_url, "/"), + .skin_domains = config_parsed.value.skin_domains, + .server_name = config_parsed.value.server_name, + .http = .{ .allocator = alloc }, + .db = .{ .con = postgres_con }, + .rand = rand.random(), + .rsa = rsa, + .x509 = x509, + .default_skin_url = default_skin_url, + .skin_cache = State.SkinCache{}, + }; + defer state.http.deinit(); + defer { + var kiter = state.skin_cache.keyIterator(); + while (kiter.next()) |key| { + alloc.free(key.*); + } + state.skin_cache.deinit(alloc); + } + + const addr = try std.net.Address.parseIp(config_parsed.value.bind.ip, config_parsed.value.bind.port); + var server = try addr.listen(.{}); + std.log.info("listening on {}", .{addr}); + + while (true) { + var con = try server.accept(); + errdefer con.stream.close(); + + const thread = try std.Thread.spawn(.{}, handleConnection, .{ con, &state }); + thread.detach(); + } + + return 0; +} + +fn handleConnection(con: std.net.Server.Connection, state: *State) void { + var read_buf: [1024 * 4]u8 = undefined; + const http = std.http.Server.init(con, &read_buf); + tryHandleConnection(http, state) catch |e| { + std.log.warn("error in connection handler: {}", .{e}); + }; +} + +fn tryHandleConnection(srv_: std.http.Server, state: *State) !void { + var srv = srv_; + defer srv.connection.stream.close(); + + while (true) { + var req = srv.receiveHead() catch |e| switch (e) { + error.HttpConnectionClosing => return, + else => return e, + }; + + const path = req.head.target; + + std.log.info("{s} {s} from {}", .{ + @tagName(req.head.method), + path, + srv.connection.address, + }); + + inline for (.{ + @import("routes/root.zig"), + @import("routes/aliapi/index.zig"), + @import("routes/aliapi/api/profiles/minecraft.zig"), + @import("routes/aliapi/authserver/authenticate.zig"), + @import("routes/aliapi/sessionserver/session/minecraft/has_joined.zig"), + @import("routes/aliapi/sessionserver/session/minecraft/join.zig"), + @import("routes/aliapi/sessionserver/session/minecraft/profile.zig"), + @import("routes/default_skin.zig"), + }) |route| { + if (route.matches(path)) { + route.call(&req, state) catch |e| { + //if (res.state == .waited) { + // try @import("conutil.zig").sendJsonError( + // &res, + // .internal_server_error, + // "alec", + // .{}, + // ); + //} + return e; + }; + break; + } + } else { + try req.respond("", .{ .status = .not_found }); + //res.status = .not_found; + //res.transfer_encoding = .{ .content_length = 0 }; + //try res.send(); + //try res.finish(); + } + + if (srv.state == .closing) break; + } +} diff --git a/src/routes/aliapi/api/profiles/minecraft.zig b/src/routes/aliapi/api/profiles/minecraft.zig new file mode 100644 index 0000000..bc3b9aa --- /dev/null +++ b/src/routes/aliapi/api/profiles/minecraft.zig @@ -0,0 +1,93 @@ +const std = @import("std"); + +const conutil = @import("../../../../conutil.zig"); + +const Id = @import("../../../../Id.zig"); +const State = @import("../../../../State.zig"); + +pub fn matches(path: []const u8) bool { + return std.mem.eql(u8, path, "/aliapi/api/profiles/minecraft"); +} + +pub fn call(req: *std.http.Server.Request, state: *State) !void { + if (req.head.method != .POST) { + try conutil.sendJsonError( + req, + .method_not_allowed, + "only POST requests are allowed to this endpoint!", + .{}, + ); + return; + } + + var json_reader = std.json.reader(state.allocator, try req.reader()); + defer json_reader.deinit(); + + const usernames_req = try std.json.parseFromTokenSource( + [][]u8, + state.allocator, + &json_reader, + .{}, + ); + defer usernames_req.deinit(); + + if (usernames_req.value.len > 10) { + try conutil.sendJsonError( + req, + .bad_request, + "Only up to 10 usernames may be requested at a time, got {}", + .{usernames_req.value.len}, + ); + return; + } + + for (usernames_req.value) |username| { + for (username) |*char| + char.* = std.ascii.toLower(char.*); + } + + var param_str = std.ArrayList(u8).init(state.allocator); + defer param_str.deinit(); + try param_str.append('{'); + for (usernames_req.value, 0..) |username, i| { + if (i != 0) try param_str.append(','); + try param_str.appendSlice(username); + } + try param_str.append('}'); + try param_str.append(0); + + const db_res = state.db.execParams( + \\SELECT id, name FROM users + \\WHERE lower(name)=any($1::varchar[]); + , .{@as([*:0]const u8, @ptrCast(param_str.items.ptr))}); + defer db_res.deinit(); + try db_res.expectTuples(); + + if (db_res.cols() != 2) return error.InvalidResultFromPostgresServer; + + var response_json = std.ArrayList(u8).init(state.allocator); + defer response_json.deinit(); + + var json_writer = std.json.writeStream(response_json.writer(), .{}); + + try json_writer.beginArray(); + for (0..@intCast(db_res.rows())) |rowidx| { + const id = db_res.get(Id, @intCast(rowidx), 0); + const name = db_res.get([]const u8, @intCast(rowidx), 1); + + try json_writer.beginObject(); + try json_writer.objectField("id"); + try json_writer.write(&id.toString()); + try json_writer.objectField("name"); + try json_writer.write(name); + try json_writer.endObject(); + } + try json_writer.endArray(); + + try req.respond(response_json.items, .{ + .extra_headers = &.{.{ + .name = "Content-Type", + .value = "application/json", + }}, + }); +} diff --git a/src/routes/aliapi/authserver/authenticate.zig b/src/routes/aliapi/authserver/authenticate.zig new file mode 100644 index 0000000..4fc4c04 --- /dev/null +++ b/src/routes/aliapi/authserver/authenticate.zig @@ -0,0 +1,185 @@ +const std = @import("std"); +const c = ffi.c; + +const ffi = @import("../../../ffi.zig"); +const conutil = @import("../../../conutil.zig"); + +const Id = @import("../../../Id.zig"); +const State = @import("../../../State.zig"); + +pub fn matches(path: []const u8) bool { + return std.mem.eql(u8, path, "/aliapi/authserver/authenticate"); +} + +pub fn call(req: *std.http.Server.Request, state: *State) !void { + if (req.head.method != .POST) { + try conutil.sendJsonError( + req, + .method_not_allowed, + "only POST requests are allowed to this endpoint!", + .{}, + ); + return; + } + + const Request = struct { + username: [:0]const u8, + password: []const u8, + clientToken: ?[:0]const u8 = null, + requestUser: bool = false, + }; + + var json_reader = std.json.reader(state.allocator, try req.reader()); + defer json_reader.deinit(); + const req_payload = std.json.parseFromTokenSource(Request, state.allocator, &json_reader, .{ + .ignore_unknown_fields = true, + }) catch |e| { + try conutil.sendJsonError(req, .bad_request, "unable to parse JSON payload: {}", .{e}); + return; + }; + defer req_payload.deinit(); + + std.log.info("authentification attempt from user {s}", .{req_payload.value.username}); + + const valid = valid: { + const forgejo_url = try std.fmt.allocPrint(state.allocator, "{s}/api/v1/user", .{state.forgejo_url}); + defer state.allocator.free(forgejo_url); + + const unenc_auth = try std.fmt.allocPrint( + state.allocator, + "{s}:{s}", + .{ req_payload.value.username, req_payload.value.password }, + ); + defer state.allocator.free(unenc_auth); + + const auth_prefix = "Basic "; + const auth_str = try state.allocator.alloc( + u8, + auth_prefix.len + std.base64.standard.Encoder.calcSize(unenc_auth.len), + ); + defer state.allocator.free(auth_str); + + @memcpy(auth_str[0..auth_prefix.len], auth_prefix); + _ = std.base64.standard.Encoder.encode(auth_str[auth_prefix.len..], unenc_auth); + + var fres = try state.http.fetch(.{ + .location = .{ .url = forgejo_url }, + .extra_headers = &.{.{ + .name = "Authorization", + .value = auth_str, + }}, + }); + + break :valid fres.status.class() == .success; + }; + + if (valid) { + std.log.info("issuing new token", .{}); + // Ensure user record exists + const insert_result = state.db.execParams( + "INSERT INTO users (id, name) VALUES (gen_random_uuid(), $1) ON CONFLICT DO NOTHING;", + .{req_payload.value.username}, + ); + defer insert_result.deinit(); + try insert_result.expectCommand(); + + // Get user UUID + const sel_result = state.db.execParams( + "SELECT id FROM users WHERE name=$1::text;", + .{req_payload.value.username}, + ); + defer sel_result.deinit(); + try sel_result.expectTuples(); + + if (sel_result.rows() != 1 or sel_result.cols() != 1) + return error.InvalidResultFromPostgresServer; + + const userid = sel_result.get(Id, 0, 0); + + const Profile = struct { + name: []const u8, + id: []const u8, + }; + + const ResponsePayload = struct { + user: ?struct { + username: []const u8, + properties: []const struct { + name: []const u8, + value: []const u8, + }, + id: []const u8, + } = null, + clientToken: []const u8, + accessToken: []const u8, + availableProfiles: []const Profile, + selectedProfile: Profile, + }; + + var gen_token_buf: [32:0]u8 = undefined; + const client_token: [:0]const u8 = req_payload.value.clientToken orelse gentoken: { + // TODO: according to https://wiki.vg/Legacy_Mojang_Authentication, the normal server + // would invalidate all existing tokens here. This makes no sense, so we don't do it. + @memcpy(&gen_token_buf, &Id.genRandom(state.rand).toString()); + break :gentoken &gen_token_buf; + }; + + // remains valid for one week + const expiry = std.time.timestamp() + std.time.ms_per_week; + + const tokenid = Id.genRandom(state.rand); + + const add_tok_stat = state.db.execParams( + \\INSERT INTO tokens (id, userid, expiry, client_token) + \\ VALUES ($1::uuid, $2::uuid, $3::bigint, $4::text); + , + .{ tokenid, userid, expiry, client_token }, + ); + defer add_tok_stat.deinit(); + try add_tok_stat.expectCommand(); + + const uid_hex = userid.toString(); + + const profile = Profile{ + .name = req_payload.value.username, + .id = &uid_hex, + }; + + const res_payload = ResponsePayload{ + .user = if (req_payload.value.requestUser) .{ + .username = req_payload.value.username, + .id = &uid_hex, + .properties = &.{ + // There is no acceptable real-world use-case where this would be incorrect. + .{ + .name = "preferredLanguage", + .value = "en", + }, + }, + } else null, + .clientToken = client_token, + .accessToken = &tokenid.toString(), + .availableProfiles = &.{profile}, + .selectedProfile = profile, + }; + + const data = try std.json.stringifyAlloc( + state.allocator, + res_payload, + .{ .emit_null_optional_fields = false }, + ); + defer state.allocator.free(data); + + try req.respond(data, .{ + .extra_headers = &.{.{ + .name = "Content-Type", + .value = "application/json", + }}, + }); + } else { + std.log.warn("credentials invalid", .{}); + + // .forbidden makes no sense here, but that was mojank's idea + try conutil.sendJsonError(req, .forbidden, "invalid credentials", .{}); + } +} diff --git a/src/routes/aliapi/index.zig b/src/routes/aliapi/index.zig new file mode 100644 index 0000000..6c96342 --- /dev/null +++ b/src/routes/aliapi/index.zig @@ -0,0 +1,45 @@ +const std = @import("std"); +const c = ffi.c; + +const ffi = @import("../../ffi.zig"); + +const State = @import("../../State.zig"); + +pub fn matches(path: []const u8) bool { + return std.mem.eql(u8, path, "/aliapi"); +} + +pub fn call(req: *std.http.Server.Request, state: *State) !void { + const bio = c.BIO_new(c.BIO_s_mem()) orelse return error.OutOfMemory; + defer _ = c.BIO_free(bio); + + if (c.PEM_write_bio_X509_PUBKEY( + bio, + c.X509_get_X509_PUBKEY(state.x509), + ) != 1) return error.OpenSSLBorked; + + var dataptr: ?[*]const u8 = null; + const datalen: usize = @intCast(c.BIO_get_mem_data(bio, &dataptr)); + const keydata = dataptr.?[0..datalen]; + + const response_payload = .{ + .meta = .{ + .serverName = state.server_name, + .implementationName = "AnvilAuth", + .implementationVersion = "0.0.0", + .links = .{ .source = "https://git.tilera.org/Anvilcraft/AnvilAuth" }, + }, + .skinDomains = state.skin_domains, + .signaturePublickey = keydata, + }; + + const json = try std.json.stringifyAlloc(state.allocator, response_payload, .{}); + defer state.allocator.free(json); + + try req.respond(json, .{ + .extra_headers = &.{.{ + .name = "Content-Type", + .value = "application/json", + }} + }); +} diff --git a/src/routes/aliapi/sessionserver/session/minecraft/has_joined.zig b/src/routes/aliapi/sessionserver/session/minecraft/has_joined.zig new file mode 100644 index 0000000..d164072 --- /dev/null +++ b/src/routes/aliapi/sessionserver/session/minecraft/has_joined.zig @@ -0,0 +1,74 @@ +const std = @import("std"); +const c = ffi.c; + +const conutil = @import("../../../../../conutil.zig"); +const ffi = @import("../../../../../ffi.zig"); + +const Id = @import("../../../../../Id.zig"); +const JsonUserProfile = @import("../../../../../JsonUserProfile.zig"); +const State = @import("../../../../../State.zig"); + +pub fn matches(path: []const u8) bool { + return std.mem.startsWith(u8, path, "/aliapi/sessionserver/session/minecraft/hasJoined"); +} + +pub fn call(req: *std.http.Server.Request, state: *State) !void { + const req_url = try std.Uri.parseWithoutScheme(req.head.target); + const params = conutil.parseQueryParametersFromUri(req_url, struct { + serverId: []const u8, + username: []const u8, + }) catch |e| { + try conutil.sendJsonError(req, .bad_request, "invalid query parameters: {}", .{e}); + return; + }; + + const sel_dbret = state.db.execParams( + \\SELECT users.id + \\FROM users, joins + \\WHERE + \\ joins.userid = users.id AND + \\ users.name = $1::text AND + \\ joins.serverid = $2::text; + , .{ params.username, params.serverId }); + defer sel_dbret.deinit(); + try sel_dbret.expectTuples(); + + if (sel_dbret.cols() != 1) return error.InvalidResultFromPostgresServer; + + if (sel_dbret.rows() >= 1) { + const id = sel_dbret.get(Id, 0, 0); + + const skin_url = try state.getSkinUrl(params.username); + defer if (skin_url) |url| state.allocator.free(url); + + const profile = try JsonUserProfile.init( + state.allocator, + id, + params.username, + skin_url orelse state.default_skin_url, + state.rsa, + ); + defer profile.deinit(state.allocator); + + const profile_json = try std.json.stringifyAlloc( + state.allocator, + profile, + .{ .emit_null_optional_fields = false }, + ); + defer state.allocator.free(profile_json); + + try req.respond(profile_json, .{ .extra_headers = &.{.{ + .name = "Content-Type", + .value = "application/json", + }} }); + + const del_dbret = state.db.execParams("DELETE FROM joins WHERE userid = $1::uuid;", .{id}); + defer del_dbret.deinit(); + try del_dbret.expectCommand(); + } else { + // task failed successfully! (good api design, mojank!) + try req.respond("", .{ + .status = .no_content, + }); + } +} diff --git a/src/routes/aliapi/sessionserver/session/minecraft/join.zig b/src/routes/aliapi/sessionserver/session/minecraft/join.zig new file mode 100644 index 0000000..c329e55 --- /dev/null +++ b/src/routes/aliapi/sessionserver/session/minecraft/join.zig @@ -0,0 +1,68 @@ +const std = @import("std"); +const c = ffi.c; + +const conutil = @import("../../../../../conutil.zig"); +const ffi = @import("../../../../../ffi.zig"); + +const Id = @import("../../../../../Id.zig"); +const State = @import("../../../../../State.zig"); + +pub fn matches(path: []const u8) bool { + return std.mem.eql(u8, path, "/aliapi/sessionserver/session/minecraft/join"); +} + +pub fn call(req: *std.http.Server.Request, state: *State) !void { + const Request = struct { + accessToken: []const u8, + selectedProfile: []const u8, + serverId: [:0]const u8, + }; + + var json_reader = std.json.reader(state.allocator, try req.reader()); + defer json_reader.deinit(); + const req_payload = std.json.parseFromTokenSource(Request, state.allocator, &json_reader, .{ + .ignore_unknown_fields = true, + }) catch |e| { + try conutil.sendJsonError(req, .bad_request, "unable to parse JSON payload: {}", .{e}); + return; + }; + defer req_payload.deinit(); + + const access_token = Id.parse(req_payload.value.accessToken) orelse { + try conutil.sendJsonError(req, .bad_request, "accessToken is not a valid ID!", .{}); + return; + }; + + const sel_profile = Id.parse(req_payload.value.selectedProfile) orelse { + try conutil.sendJsonError(req, .bad_request, "selectedProfile is not a valid ID!", .{}); + return; + }; + + const dbret = state.db.execParams("SELECT userid FROM tokens WHERE id=$1::uuid;", .{access_token}); + defer dbret.deinit(); + try dbret.expectTuples(); + + if (dbret.cols() != 1) return error.InvalidResultFromPostgresServer; + + if (dbret.rows() >= 1) { + const token_user = dbret.get(Id, 0, 0); + if (std.mem.eql(u8, &sel_profile.bytes, &token_user.bytes)) { + const ins_dbret = state.db.execParams( + \\INSERT INTO joins (userid, serverid) + \\VALUES ($1::uuid, $2::text) + \\ON CONFLICT (userid) DO + \\UPDATE SET serverid = EXCLUDED.serverid; + , .{ token_user, req_payload.value.serverId }); + defer ins_dbret.deinit(); + try ins_dbret.expectCommand(); + + try req.respond("", .{ .status = .no_content }); + } else { + // acces token belongs to other user (hehe) + try conutil.sendJsonError(req, .forbidden, "invalid access token!", .{}); + } + } else { + // invalid access token + try conutil.sendJsonError(req, .forbidden, "invalid access token!", .{}); + } +} diff --git a/src/routes/aliapi/sessionserver/session/minecraft/profile.zig b/src/routes/aliapi/sessionserver/session/minecraft/profile.zig new file mode 100644 index 0000000..680133a --- /dev/null +++ b/src/routes/aliapi/sessionserver/session/minecraft/profile.zig @@ -0,0 +1,99 @@ +const std = @import("std"); +const c = ffi.c; + +const ffi = @import("../../../../../ffi.zig"); +const conutil = @import("../../../../../conutil.zig"); + +const Id = @import("../../../../../Id.zig"); +const JsonUserProfile = @import("../../../../../JsonUserProfile.zig"); +const State = @import("../../../../../State.zig"); + +const path_prefix = "/aliapi/sessionserver/session/minecraft/profile/"; + +pub fn matches(path: []const u8) bool { + return std.mem.startsWith(u8, path, path_prefix); +} + +pub fn call(req: *std.http.Server.Request, state: *State) !void { + const req_url = try std.Uri.parseWithoutScheme(req.head.target); + + // This is sound as we only go here if the path starts with path_prefix. + const profile_id = Id.parse(req_url.path[path_prefix.len..]) orelse { + try conutil.sendJsonError( + req, + .bad_request, + "not a valid UUID: {s} (NOTE: AnvilAuth technically doesn't use UUIDs, this endpoint expects 16 hex-encoded, undelimited bytes.)", + .{req_url.path[path_prefix.len..]}, + ); + return; + }; + + const unsigned = unsigned: { + if (req_url.query == null) break :unsigned true; + + const params = conutil.parseQueryParametersOrDefaults( + req_url.query.?, + struct { unsigned: []const u8 = "true" }, + ) catch |e| { + try conutil.sendJsonError(req, .bad_request, "invalid query parameters: {}", .{e}); + return; + }; + + if (std.mem.eql(u8, params.unsigned, "true")) { + break :unsigned true; + } else if (std.mem.eql(u8, params.unsigned, "false")) { + break :unsigned false; + } else { + try conutil.sendJsonError( + req, + .bad_request, + "`unsigned` parameter must be either `true` or `false`, got `{s}`", + .{params.unsigned}, + ); + return; + } + }; + + const status = state.db.execParams( + "SELECT name FROM users WHERE id=$1::uuid;", + .{profile_id}, + ); + defer status.deinit(); + try status.expectTuples(); + + if (status.cols() != 1) return error.InvalidResultFromPostgresServer; + + if (status.rows() >= 1) { + const username = status.get([]const u8, 0, 0); + + const skin_url = try state.getSkinUrl(username); + defer if (skin_url) |url| state.allocator.free(url); + + const uprofile = try JsonUserProfile.init( + state.allocator, + profile_id, + username, + skin_url orelse state.default_skin_url, + if (unsigned) null else state.rsa, + ); + defer uprofile.deinit(state.allocator); + + const response_data = try std.json.stringifyAlloc( + state.allocator, + uprofile, + .{ .emit_null_optional_fields = false }, + ); + defer state.allocator.free(response_data); + + try req.respond(response_data, .{ + .extra_headers = &.{.{ + .name = "Content-Type", + .value = "application/json", + }}, + }); + } else { + // -> User not found + // This retarded API design brought to you by Mojang! + try req.respond("", .{ .status = .no_content }); + } +} diff --git a/src/routes/default_skin.zig b/src/routes/default_skin.zig new file mode 100644 index 0000000..75f5452 --- /dev/null +++ b/src/routes/default_skin.zig @@ -0,0 +1,19 @@ +const std = @import("std"); +const assets = @import("assets"); + +const State = @import("../State.zig"); + +pub fn matches(path: []const u8) bool { + return std.mem.eql(u8, path, "/default_skin"); +} + +pub fn call(req: *std.http.Server.Request, state: *State) !void { + _ = state; + + try req.respond(assets.steve_skin, .{ + .extra_headers = &.{.{ + .name = "Content-Type", + .value = "image/png", + }}, + }); +} diff --git a/src/routes/root.zig b/src/routes/root.zig new file mode 100644 index 0000000..8178249 --- /dev/null +++ b/src/routes/root.zig @@ -0,0 +1,19 @@ +const std = @import("std"); + +const State = @import("../State.zig"); + +pub fn matches(path: []const u8) bool { + return std.mem.eql(u8, path, "/"); +} + +pub fn call(req: *std.http.Server.Request, state: *State) !void { + const base_url = try std.fmt.allocPrint(state.allocator, "{s}/aliapi", .{state.base_url}); + defer state.allocator.free(base_url); + + try req.respond("", .{ + .extra_headers = &.{.{ + .name = "x-authlib-injector-api-location", + .value = base_url, + }}, + }); +}