const std = @import("std"); const util = @import("util"); const pkg = @import("../lib.zig"); const services = @import("../services.zig"); const invites = @import("./invites.zig"); const Allocator = std.mem.Allocator; const Uuid = util.Uuid; const DateTime = util.DateTime; const ApiContext = pkg.ApiContext; const Token = pkg.tokens.Token; const RegistrationOptions = pkg.auth.RegistrationOptions; const Invite = services.invites.Invite; pub fn register( alloc: std.mem.Allocator, ctx: ApiContext, svcs: anytype, opt: RegistrationOptions, ) !Uuid { const tx = try svcs.beginTx(); errdefer tx.rollbackTx(); const maybe_invite = if (opt.invite_code) |code| tx.getInviteByCode(alloc, code, ctx.community.id) catch |err| switch (err) { error.NotFound => return error.InvalidInvite, else => |e| return e, } else null; defer if (maybe_invite) |inv| util.deepFree(alloc, inv); if (maybe_invite) |invite| { if (!Uuid.eql(invite.community_id, ctx.community.id)) return error.WrongCommunity; if (!invites.isValid(invite)) return error.InvalidInvite; } const invite_kind = if (maybe_invite) |inv| inv.kind else .user; if (ctx.community.kind == .admin) @panic("Unimplmented"); const account_id = try createLocalAccount( alloc, tx, .{ .username = opt.username, .password = opt.password, .community_id = ctx.community.id, .invite_id = if (maybe_invite) |inv| @as(?Uuid, inv.id) else null, .email = opt.email, }, ); switch (invite_kind) { .user => {}, .system => @panic("System user invites unimplemented"), .community_owner => { try tx.transferCommunityOwnership(ctx.community.id, account_id); }, } try tx.commitTx(); return account_id; } pub const AccountCreateArgs = struct { username: []const u8, password: []const u8, community_id: Uuid, invite_id: ?Uuid = null, email: ?[]const u8 = null, role: services.accounts.Role = .user, }; pub fn createLocalAccount( alloc: std.mem.Allocator, svcs: anytype, args: AccountCreateArgs, ) !Uuid { const tx = try svcs.beginTx(); errdefer tx.rollbackTx(); const hash = try hashPassword(args.password, alloc); defer alloc.free(hash); const id = try tx.createActor(alloc, args.username, args.community_id, false); try tx.createAccount(alloc, .{ .for_actor = id, .password_hash = hash, .invite_id = args.invite_id, .email = args.email, .role = args.role, }); try tx.commitTx(); return id; } pub fn verifyToken(alloc: std.mem.Allocator, ctx: ApiContext, svcs: anytype, token: []const u8) !Token.Info { const hash = try hashToken(token, alloc); defer alloc.free(hash); const info = try svcs.getTokenByHash(alloc, hash, ctx.community.id); defer util.deepFree(alloc, info); return .{ .account_id = info.account_id, .issued_at = info.issued_at }; } pub fn login( alloc: std.mem.Allocator, ctx: ApiContext, svcs: anytype, username: []const u8, password: []const u8, ) !Token { const community_id = ctx.community.id; const credentials = try svcs.getCredentialsByUsername( alloc, username, community_id, ); defer util.deepFree(alloc, credentials); try verifyPassword(credentials.password_hash, password, alloc); const token = try generateToken(alloc); errdefer util.deepFree(alloc, token); const token_hash = hashToken(token, alloc) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => unreachable, }; defer util.deepFree(alloc, token_hash); const tx = try svcs.beginTx(); errdefer tx.rollbackTx(); // ensure that the password has not changed in the meantime { const updated_info = try tx.getCredentialsByUsername( alloc, username, community_id, ); defer util.deepFree(alloc, updated_info); if (!std.mem.eql(u8, credentials.password_hash, updated_info.password_hash)) return error.InvalidLogin; } try tx.createToken(alloc, credentials.account_id, token_hash); try tx.commitTx(); const info = try svcs.getTokenByHash(alloc, token_hash, community_id); defer util.deepFree(alloc, info); return .{ .value = token, .info = .{ .account_id = info.account_id, .issued_at = info.issued_at, }, }; } // We use scrypt, a password hashing algorithm that attempts to slow down // GPU-based cracking approaches by using large amounts of memory, for // password hashing. // Attempting to calculate/verify a hash will use about 50mb of work space. const scrypt = std.crypto.pwhash.scrypt; const max_password_hash_len = 128; fn verifyPassword( hash: []const u8, password: []const u8, alloc: std.mem.Allocator, ) !void { scrypt.strVerify( hash, password, .{ .allocator = alloc }, ) catch |err| return switch (err) { error.PasswordVerificationFailed => return error.InvalidLogin, error.OutOfMemory => return error.OutOfMemory, else => |e| return e, }; } const scrypt_params = if (!@import("builtin").is_test) scrypt.Params.interactive else scrypt.Params{ .ln = 8, .r = 8, .p = 1, }; fn hashPassword(password: []const u8, alloc: std.mem.Allocator) ![]const u8 { var buf: [max_password_hash_len]u8 = undefined; const hash = try scrypt.strHash( password, .{ .allocator = alloc, .params = scrypt_params, .encoding = .phc, }, &buf, ); return util.deepClone(alloc, hash); } /// A raw token is a sequence of N random bytes, base64 encoded. /// When the token is generated: /// - The hash of the token is calculated by: /// 1. Decoding the base64 text /// 2. Calculating the SHA256 hash of this text /// 3. Encoding the hash back as base64 /// - The b64 encoded hash is stored in the database /// - The original token is returned to the user /// * The user will treat it as opaque text /// When the token is verified: /// - The hash of the token is taken as shown above /// - The database is scanned for a token matching this hash /// - If none can be found, the token is invalid const Sha256 = std.crypto.hash.sha2.Sha256; const Base64Encoder = std.base64.standard.Encoder; const Base64Decoder = std.base64.standard.Decoder; const token_len = 12; fn generateToken(alloc: std.mem.Allocator) ![]const u8 { var token = std.mem.zeroes([token_len]u8); std.crypto.random.bytes(&token); const token_b64_len = Base64Encoder.calcSize(token.len); const token_b64 = try alloc.alloc(u8, token_b64_len); return Base64Encoder.encode(token_b64, &token); } fn hashToken(token_b64: []const u8, alloc: std.mem.Allocator) ![]const u8 { const decoded_token_len = Base64Decoder.calcSizeForSlice(token_b64) catch return error.InvalidToken; if (decoded_token_len != token_len) return error.InvalidToken; var token = std.mem.zeroes([token_len]u8); Base64Decoder.decode(&token, token_b64) catch return error.InvalidToken; var hash = std.mem.zeroes([Sha256.digest_length]u8); Sha256.hash(&token, &hash, .{}); const hash_b64_len = Base64Encoder.calcSize(hash.len); const hash_b64 = try alloc.alloc(u8, hash_b64_len); return Base64Encoder.encode(hash_b64, &hash); } test "register" { const testCase = struct { const test_invite_code = "xyz"; const test_invite_id = Uuid.parse("d24e7f2a-7e6e-4e2a-8e9d-987538a04a40") catch unreachable; const test_acc_id = Uuid.parse("e8e21e1d-7b80-4e48-876d-9929326af511") catch unreachable; const test_community_id = Uuid.parse("8bf88bd7-fb07-492d-a89a-6350c036183f") catch unreachable; const Args = struct { username: []const u8 = "username", password: []const u8 = "password1234", use_invite: bool = false, invite_community_id: Uuid = test_community_id, invite_kind: services.invites.Kind = .user, invite_max_uses: ?usize = null, invite_current_uses: usize = 0, invite_expires_at: ?DateTime = null, get_invite_error: ?anyerror = null, create_account_error: ?anyerror = null, create_actor_error: ?anyerror = null, transfer_error: ?anyerror = null, expect_error: ?anyerror = null, expect_transferred: bool = false, }; fn runCaseOnce(allocator: std.mem.Allocator, test_args: Args) anyerror!void { const Svc = struct { test_args: Args, tx_level: usize = 0, rolled_back: bool = false, committed: bool = false, account_created: bool = false, actor_created: bool = false, community_transferred: bool = false, fn beginTx(self: *@This()) !*@This() { self.tx_level += 1; return self; } fn rollbackTx(self: *@This()) void { self.tx_level -= 1; self.rolled_back = true; } fn commitTx(self: *@This()) !void { self.tx_level -= 1; self.committed = true; } fn getInviteByCode(self: *@This(), alloc: Allocator, code: []const u8, community_id: Uuid) anyerror!services.invites.Invite { try std.testing.expect(self.tx_level > 0); try std.testing.expectEqualStrings(test_invite_code, code); try std.testing.expectEqual(test_community_id, community_id); if (self.test_args.get_invite_error) |err| return err; return try util.deepClone(alloc, std.mem.zeroInit(services.invites.Invite, .{ .id = test_invite_id, .community_id = self.test_args.invite_community_id, .code = code, .kind = self.test_args.invite_kind, .times_used = self.test_args.invite_current_uses, .max_uses = self.test_args.invite_max_uses, .expires_at = self.test_args.invite_expires_at, })); } fn createActor(self: *@This(), _: Allocator, username: []const u8, community_id: Uuid, _: bool) anyerror!Uuid { try std.testing.expect(self.tx_level > 0); if (self.test_args.create_actor_error) |err| return err; try std.testing.expectEqualStrings(self.test_args.username, username); try std.testing.expectEqual(test_community_id, community_id); self.actor_created = true; return test_acc_id; } fn createAccount(self: *@This(), alloc: Allocator, args: services.accounts.CreateArgs) anyerror!void { try std.testing.expect(self.tx_level > 0); if (self.test_args.create_account_error) |err| return err; try verifyPassword(args.password_hash, self.test_args.password, alloc); if (self.test_args.use_invite) try std.testing.expectEqual(@as(?Uuid, test_invite_id), args.invite_id) else try std.testing.expect(args.invite_id == null); try std.testing.expectEqual(services.accounts.Role.user, args.role); self.account_created = true; } fn transferCommunityOwnership(self: *@This(), community_id: Uuid, account_id: Uuid) !void { try std.testing.expect(self.tx_level > 0); if (self.test_args.transfer_error) |err| return err; self.community_transferred = true; try std.testing.expectEqual(test_community_id, community_id); try std.testing.expectEqual(test_acc_id, account_id); } }; var svc = Svc{ .test_args = test_args }; const community = std.mem.zeroInit(pkg.Community, .{ .kind = .local, .id = test_community_id }); const result = register( allocator, .{ .community = community }, &svc, .{ .username = test_args.username, .password = test_args.password, .invite_code = if (test_args.use_invite) test_invite_code else null, }, // shortcut out of memory errors to test allocation ) catch |err| if (err == error.OutOfMemory) return err else err; if (test_args.expect_error) |err| { try std.testing.expectError(err, result); try std.testing.expect(!svc.committed); if (svc.account_created or svc.actor_created or svc.community_transferred) { try std.testing.expect(svc.rolled_back); } } else { try std.testing.expectEqual(test_acc_id, try result); try std.testing.expect(svc.committed); try std.testing.expect(!svc.rolled_back); try std.testing.expect(svc.account_created); try std.testing.expect(svc.actor_created); try std.testing.expectEqual(test_args.expect_transferred, svc.community_transferred); } } fn case(args: Args) !void { try std.testing.checkAllAllocationFailures(std.testing.allocator, runCaseOnce, .{args}); } }.case; // regular registration try testCase(.{}); // registration with invite try testCase(.{ .use_invite = true }); // registration with invite for a different community try testCase(.{ .invite_community_id = Uuid.parse("11111111-1111-1111-1111-111111111111") catch unreachable, .use_invite = true, .expect_error = error.WrongCommunity, }); // registration as a new community owner try testCase(.{ .use_invite = true, .invite_kind = .community_owner, .expect_transferred = true, }); // invite with expiration info try testCase(.{ .use_invite = true, .invite_max_uses = 100, .invite_current_uses = 10, .invite_expires_at = DateTime{ .seconds_since_epoch = DateTime.test_now_timestamp + 3600 }, }); // missing invite try testCase(.{ .use_invite = true, .get_invite_error = error.NotFound, .expect_error = error.InvalidInvite, }); // expired invite try testCase(.{ .use_invite = true, .invite_expires_at = DateTime{ .seconds_since_epoch = DateTime.test_now_timestamp - 3600 }, .expect_error = error.InvalidInvite, }); // used invite try testCase(.{ .use_invite = true, .invite_max_uses = 100, .invite_current_uses = 110, .expect_error = error.InvalidInvite, }); }