From 58bc1af969159c47489a2c6b6b090d56219bb0c7 Mon Sep 17 00:00:00 2001 From: jaina heartles Date: Sun, 1 Jan 2023 16:28:36 -0800 Subject: [PATCH] Move login behavior into methods --- src/api/lib.zig | 3 + src/api/methods/auth.zig | 108 +++++++++++++++++++++++++++++++++- src/api/services/accounts.zig | 66 +++++++++++++++++++++ src/api/services/tokens.zig | 37 ++++++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 src/api/services/accounts.zig create mode 100644 src/api/services/tokens.zig diff --git a/src/api/lib.zig b/src/api/lib.zig index e6c0006..3cbb798 100644 --- a/src/api/lib.zig +++ b/src/api/lib.zig @@ -16,6 +16,9 @@ const services = struct { pub const invites = @import("./services/invites.zig"); pub const notes = @import("./services/notes.zig"); pub const follows = @import("./services/follows.zig"); + + pub const accounts = @import("./services/accounts.zig"); + pub const tokens = @import("./services/tokens.zig"); }; test { diff --git a/src/api/methods/auth.zig b/src/api/methods/auth.zig index 217e990..757a3b0 100644 --- a/src/api/methods/auth.zig +++ b/src/api/methods/auth.zig @@ -64,17 +64,119 @@ pub fn methods(comptime models: type) type { } pub fn login(self: anytype, username: []const u8, password: []const u8) !Token { - return models.auth.login( + const community_id = self.context.community.id; + const credentials = try models.accounts.getCredentialsByUsername( self.db, username, - self.context.community.id, - password, + community_id, self.allocator, ); + defer util.deepFree(self.allocator, credentials); + + try verifyPassword(credentials.password_hash, password, self.allocator); + + const token = try generateToken(self.allocator); + errdefer util.deepFree(self.allocator, token); + const token_hash = hashToken(token, self.allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => unreachable, + }; + defer util.deepFree(self.allocator, token_hash); + + const tx = try self.db.begin(); + errdefer tx.rollback(); + + // ensure that the password has not changed in the meantime + { + const updated_info = try models.accounts.getCredentialsByUsername( + tx, + username, + community_id, + self.allocator, + ); + defer util.deepFree(self.allocator, updated_info); + + if (!std.mem.eql(u8, credentials.password_hash, updated_info.password_hash)) return error.InvalidLogin; + } + + try models.tokens.create(tx, credentials.account_id, token_hash, self.allocator); + + try tx.commit(); + const info = try models.tokens.getByHash(self.db, token_hash, self.allocator); + defer util.deepFree(self.allocator, 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, + }; +} + +/// 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, diff --git a/src/api/services/accounts.zig b/src/api/services/accounts.zig new file mode 100644 index 0000000..1f28846 --- /dev/null +++ b/src/api/services/accounts.zig @@ -0,0 +1,66 @@ +const std = @import("std"); +const util = @import("util"); + +const Uuid = util.Uuid; +const DateTime = util.DateTime; + +pub const Role = enum { + user, + admin, +}; +pub const CreateOptions = struct { + invite_id: ?Uuid = null, + email: ?[]const u8 = null, + role: Role = .user, +}; + +/// Creates a local account with the given information +pub fn create( + db: anytype, + for_actor: Uuid, + password_hash: []const u8, + options: CreateOptions, + alloc: std.mem.Allocator, +) !void { + const tx = try db.beginOrSavepoint(); + errdefer tx.rollback(); + + tx.insert("account", .{ + .id = for_actor, + .invite_id = options.invite_id, + .email = options.email, + .kind = options.role, + }, alloc) catch return error.DatabaseFailure; + tx.insert("password", .{ + .account_id = for_actor, + .hash = password_hash, + .changed_at = DateTime.now(), + }, alloc) catch return error.DatabaseFailure; + + tx.commitOrRelease() catch return error.DatabaseFailure; +} + +pub const Credentials = struct { + account_id: Uuid, + password_hash: []const u8, +}; + +pub fn getCredentialsByUsername(db: anytype, username: []const u8, community_id: Uuid, alloc: std.mem.Allocator) !Credentials { + return db.queryRow( + Credentials, + \\SELECT account.id as account_id, password.hash as 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 => |e| return e, + }; +} diff --git a/src/api/services/tokens.zig b/src/api/services/tokens.zig new file mode 100644 index 0000000..7290432 --- /dev/null +++ b/src/api/services/tokens.zig @@ -0,0 +1,37 @@ +const std = @import("std"); +const util = @import("util"); + +const Uuid = util.Uuid; +const DateTime = util.DateTime; + +pub const Token = struct { + account_id: Uuid, + issued_at: DateTime, + + hash: []const u8, +}; + +pub fn create(db: anytype, account_id: Uuid, hash: []const u8, alloc: std.mem.Allocator) !void { + const now = DateTime.now(); + try db.insert("token", .{ + .account_id = account_id, + .hash = hash, + .issued_at = now, + }, alloc); +} + +pub fn getByHash(db: anytype, hash: []const u8, alloc: std.mem.Allocator) !Token { + return db.queryRow( + Token, + \\SELECT account_id, issued_at, hash + \\FROM token + \\WHERE hash = $1 + \\LIMIT 1 + , + .{hash}, + alloc, + ) catch |err| switch (err) { + error.NoRows => error.InvalidToken, + else => error.DatabaseFailure, + }; +}