const std = @import("std"); const util = @import("util"); const actors = @import("./actors.zig"); const Uuid = util.Uuid; const DateTime = util.DateTime; pub const RegistrationError = error{ PasswordTooShort, DatabaseFailure, HashFailure, OutOfMemory, } || actors.CreateError; pub const min_password_chars = 12; pub const Kind = enum { user, admin, }; pub const RegistrationOptions = struct { invite_id: ?Uuid = null, email: ?[]const u8 = null, kind: Kind = .user, }; /// Creates a local account with the given information and returns the /// account id pub fn register( db: anytype, username: []const u8, password: []const u8, community_id: Uuid, options: RegistrationOptions, alloc: std.mem.Allocator, ) RegistrationError!Uuid { if (password.len < min_password_chars) return error.PasswordTooShort; // perform pre-validation to avoid having to hash the password if it fails try actors.validateUsername(username, false); const hash = try hashPassword(password, alloc); defer alloc.free(hash); const tx = db.beginOrSavepoint() catch return error.DatabaseFailure; errdefer tx.rollback(); const id = try actors.create(tx, username, community_id, false, alloc); tx.insert("account", .{ .id = id, .invite_id = options.invite_id, .email = options.email, .kind = options.kind, }, alloc) catch return error.DatabaseFailure; tx.insert("password", .{ .account_id = id, .hash = hash, .changed_at = DateTime.now(), }, alloc) catch return error.DatabaseFailure; tx.insert("drive_entry", .{ .id = id, .owner_id = id, }, alloc) catch return error.DatabaseFailure; tx.commitOrRelease() catch return error.DatabaseFailure; return id; } pub const LoginError = error{ InvalidLogin, HashFailure, DatabaseFailure, OutOfMemory, }; pub const LoginResult = struct { token: []const u8, user_id: Uuid, }; /// Attempts to login to the account `@username@community` and creates /// a login token/cookie for the user pub fn login( db: anytype, username: []const u8, community_id: Uuid, password: []const u8, alloc: std.mem.Allocator, ) LoginError!LoginResult { std.log.debug("user: {s}, community_id: {}", .{ username, community_id }); const info = db.queryRow( struct { account_id: Uuid, hash: []const u8 }, \\SELECT account.id as account_id, password.hash \\FROM password \\ JOIN account \\ JOIN actor \\ ON password.account_id = account.id AND account.id = actor.id \\WHERE actor.username = $1 \\ AND actor.community_id = $2 \\LIMIT 1 , .{ username, community_id }, alloc, ) catch |err| return switch (err) { error.NoRows => error.InvalidLogin, else => error.DatabaseFailure, }; defer alloc.free(info.hash); try verifyPassword(info.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 = db.begin() catch return error.DatabaseFailure; errdefer tx.rollback(); // ensure that the password has not changed in the meantime { const updated_info = tx.queryRow( struct { hash: []const u8 }, \\SELECT hash \\FROM password \\WHERE account_id = $1 \\LIMIT 1 , .{info.account_id}, alloc, ) catch return error.DatabaseFailure; defer util.deepFree(alloc, updated_info); if (!std.mem.eql(u8, info.hash, updated_info.hash)) return error.InvalidLogin; } tx.insert("token", .{ .account_id = info.account_id, .hash = token_hash, .issued_at = DateTime.now(), }, alloc) catch return error.DatabaseFailure; tx.commit() catch return error.DatabaseFailure; return LoginResult{ .token = token, .user_id = info.account_id, }; } pub const VerifyTokenError = error{ InvalidToken, DatabaseFailure, OutOfMemory }; pub const TokenInfo = struct { user_id: Uuid, issued_at: DateTime, }; pub fn verifyToken( db: anytype, token: []const u8, community_id: Uuid, alloc: std.mem.Allocator, ) VerifyTokenError!TokenInfo { const hash = try hashToken(token, alloc); defer alloc.free(hash); return db.queryRow( TokenInfo, \\SELECT token.account_id as user_id, token.issued_at \\FROM token \\ JOIN account \\ JOIN actor \\ ON token.account_id = account.id AND account.id = actor.id \\WHERE token.hash = $1 AND actor.community_id = $2 \\LIMIT 1 , .{ hash, community_id }, alloc, ) catch |err| switch (err) { error.NoRows => error.InvalidToken, else => error.DatabaseFailure, }; } // 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, ) LoginError!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); }