const std = @import("std"); const util = @import("util"); const types = @import("../types.zig"); const Uuid = util.Uuid; const DateTime = util.DateTime; const Invite = @import("../lib.zig").Invite; pub const Token = types.Token; pub const RegistrationOptions = struct { invite_code: ?[]const u8 = null, email: ?[]const u8 = null, }; pub const AccountCreateOptions = @import("../services/accounts.zig").CreateOptions; pub fn methods(comptime models: type) type { return struct { fn isInviteValid(invite: Invite) bool { if (invite.max_uses != null and invite.times_used >= invite.max_uses.?) return false; if (invite.expires_at != null and DateTime.now().isAfter(invite.expires_at.?)) return false; return true; } pub fn register(self: anytype, username: []const u8, password: []const u8, opt: RegistrationOptions) !types.Actor { const tx = try self.db.beginOrSavepoint(); const maybe_invite = if (opt.invite_code) |code| try models.invites.getByCode(tx, code, self.context.community.id, self.allocator) else null; defer if (maybe_invite) |inv| util.deepFree(self.allocator, inv); if (maybe_invite) |invite| { if (!Uuid.eql(invite.community_id, self.context.community.id)) return error.WrongCommunity; if (!isInviteValid(invite)) return error.InvalidInvite; } const invite_kind = if (maybe_invite) |inv| inv.kind else .user; if (self.context.community.kind == .admin) @panic("Unimplmented"); const user_id = try createLocalAccount( tx, username, password, self.context.community.id, .{ .invite_id = if (maybe_invite) |inv| @as(?Uuid, inv.id) else null, .email = opt.email, }, self.allocator, ); switch (invite_kind) { .user => {}, .system => @panic("System user invites unimplemented"), .community_owner => { try models.communities.transferOwnership(tx, self.context.community.id, user_id); }, } const user = models.actors.get(tx, user_id, self.allocator) catch |err| switch (err) { error.NotFound => return error.Unexpected, else => |e| return e, }; errdefer util.deepFree(self.allocator, user); try tx.commitOrRelease(); return user; } // Only for internal use pub fn createLocalAccount( db: anytype, username: []const u8, password: []const u8, community_id: Uuid, opt: AccountCreateOptions, alloc: std.mem.Allocator, ) !Uuid { const tx = try db.beginOrSavepoint(); errdefer tx.rollback(); const hash = try hashPassword(password, alloc); defer alloc.free(hash); const id = try models.actors.create(tx, username, community_id, false, alloc); try models.accounts.create(tx, id, hash, opt, alloc); try tx.commitOrRelease(); return id; } pub fn verifyToken(self: anytype, token: []const u8) !Token.Info { const hash = try hashToken(token, self.allocator); defer self.allocator.free(hash); const info = try models.tokens.getByHash(self.db, hash, self.context.community.id, self.allocator); defer util.deepFree(self.allocator, info); return .{ .user_id = info.account_id, .issued_at = info.issued_at }; } }; } const ApiContext = @import("../lib.zig").ApiContext; 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 tx.getTokenByHash(alloc, token_hash, community_id); defer util.deepFree(alloc, info); return .{ .value = token, .info = .{ .user_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 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 => error.InvalidLogin, else => error.HashFailure, }; } fn hashPassword(password: []const u8, alloc: std.mem.Allocator) ![]const u8 { const buf = try alloc.alloc(u8, password_hash_len); errdefer alloc.free(buf); return scrypt.strHash( password, .{ .allocator = alloc, .params = scrypt.Params.interactive, .encoding = .phc, }, buf, ) catch error.HashFailure; } /// 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); } const TestDb = struct { tx_level: usize = 0, rolled_back: bool = false, committed: bool = false, fn beginOrSavepoint(self: *TestDb) !*TestDb { self.tx_level += 1; return self; } fn rollback(self: *TestDb) void { self.rolled_back = true; self.tx_level -= 1; } fn commitOrRelease(self: *TestDb) !void { self.committed = true; self.tx_level -= 1; } }; test "register" { comptime var exp_code = "code"; comptime var exp_community = Uuid.parse("a210c035-c9e1-4361-82a2-aaeac8e40dc6") catch unreachable; comptime var uid = Uuid.parse("6d951fcc-1c9f-497b-9c96-31dfb9873708") catch unreachable; const MockSvc = struct { const invites = struct { fn getByCode(db: *TestDb, code: []const u8, community_id: Uuid, alloc: std.mem.Allocator) !Invite { try std.testing.expectEqual(db.tx_level, 1); try std.testing.expectEqualStrings(exp_code, code); try std.testing.expectEqual(exp_community, community_id); return try util.deepClone(alloc, Invite{ .id = Uuid.parse("eac18f43-4dcc-489f-9fb5-4c1633e7b4e0") catch unreachable, .created_by = Uuid.parse("6d951fcc-1c9f-497b-9c96-31dfb9873708") catch unreachable, .community_id = exp_community, .name = "test invite", .code = exp_code, .kind = .user, .created_at = DateTime.parse("2022-12-21T09:05:50Z") catch unreachable, .times_used = 0, .expires_at = null, .max_uses = null, }); } }; const auth = struct { fn register( db: *TestDb, username: []const u8, password: []const u8, community_id: Uuid, _: AccountCreateOptions, _: std.mem.Allocator, ) anyerror!Uuid { try std.testing.expectEqual(db.tx_level, 1); try std.testing.expectEqualStrings("root", username); try std.testing.expectEqualStrings("password", password); try std.testing.expectEqual(exp_community, community_id); return uid; } }; const actors = struct { fn get(_: *TestDb, id: Uuid, alloc: std.mem.Allocator) anyerror!types.Actor { try std.testing.expectEqual(uid, id); return try util.deepClone(alloc, std.mem.zeroInit(types.Actor, .{ .id = id, .username = "root", .host = "example.com", .community_id = exp_community, })); } }; const communities = struct { fn transferOwnership(_: *TestDb, _: Uuid, _: Uuid) anyerror!void {} }; }; var db = TestDb{}; util.deepFree(std.testing.allocator, try methods(MockSvc).register(.{ .db = &db, .allocator = std.testing.allocator, .community = .{ .id = exp_community, .kind = .local, }, }, "root", "password", .{})); try std.testing.expectEqual(false, db.rolled_back); try std.testing.expectEqual(true, db.committed); try std.testing.expectEqual(@as(usize, 0), db.tx_level); }