This commit is contained in:
LordMZTE 2024-03-02 19:54:51 +01:00
commit 3560650ac1
Signed by: LordMZTE
GPG key ID: B64802DC33A64FF6
22 changed files with 1325 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/zig-*

1
assets.zig Normal file
View file

@ -0,0 +1 @@
pub const steve_skin = @embedFile("assets/steve_skin.png");

BIN
assets/steve_skin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

34
build.zig Normal file
View file

@ -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);
}

7
build.zig.zon Normal file
View file

@ -0,0 +1,7 @@
.{
.name = "anvilauth",
.version = "0.0.0",
.dependencies = .{},
.paths = .{""},
}

14
conf.json Normal file
View file

@ -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"
}

9
src/Config.zig Normal file
View file

@ -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,

140
src/Db.zig Normal file
View file

@ -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.* = &param.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 "<not present>"),
});
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)),
};
}
};

30
src/Id.zig Normal file
View file

@ -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 };
}

93
src/JsonUserProfile.zig Normal file
View file

@ -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);
}
}

63
src/State.zig Normal file
View file

@ -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;
}

92
src/conutil.zig Normal file
View file

@ -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;
}

32
src/ffi.zig Normal file
View file

@ -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});
}
}

207
src/main.zig Normal file
View file

@ -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;
}
}

View file

@ -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",
}},
});
}

View file

@ -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", .{});
}
}

View file

@ -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",
}}
});
}

View file

@ -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,
});
}
}

View file

@ -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!", .{});
}
}

View file

@ -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 });
}
}

View file

@ -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",
}},
});
}

19
src/routes/root.zig Normal file
View file

@ -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,
}},
});
}