Move login behavior into methods
This commit is contained in:
parent
ecd844ddd1
commit
58bc1af969
4 changed files with 211 additions and 3 deletions
|
@ -16,6 +16,9 @@ const services = struct {
|
||||||
pub const invites = @import("./services/invites.zig");
|
pub const invites = @import("./services/invites.zig");
|
||||||
pub const notes = @import("./services/notes.zig");
|
pub const notes = @import("./services/notes.zig");
|
||||||
pub const follows = @import("./services/follows.zig");
|
pub const follows = @import("./services/follows.zig");
|
||||||
|
|
||||||
|
pub const accounts = @import("./services/accounts.zig");
|
||||||
|
pub const tokens = @import("./services/tokens.zig");
|
||||||
};
|
};
|
||||||
|
|
||||||
test {
|
test {
|
||||||
|
|
|
@ -64,17 +64,119 @@ pub fn methods(comptime models: type) type {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn login(self: anytype, username: []const u8, password: []const u8) !Token {
|
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,
|
self.db,
|
||||||
username,
|
username,
|
||||||
self.context.community.id,
|
community_id,
|
||||||
password,
|
|
||||||
self.allocator,
|
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 {
|
const TestDb = struct {
|
||||||
tx_level: usize = 0,
|
tx_level: usize = 0,
|
||||||
rolled_back: bool = false,
|
rolled_back: bool = false,
|
||||||
|
|
66
src/api/services/accounts.zig
Normal file
66
src/api/services/accounts.zig
Normal file
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
37
src/api/services/tokens.zig
Normal file
37
src/api/services/tokens.zig
Normal file
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in a new issue