2022-12-26 16:05:26 +00:00
|
|
|
const std = @import("std");
|
|
|
|
const util = @import("util");
|
|
|
|
const types = @import("../types.zig");
|
|
|
|
|
|
|
|
const Uuid = util.Uuid;
|
|
|
|
const DateTime = util.DateTime;
|
|
|
|
const UserResponse = @import("../lib.zig").UserResponse;
|
|
|
|
const Invite = @import("../lib.zig").Invite;
|
2023-01-02 00:14:04 +00:00
|
|
|
pub const Token = types.Token;
|
2022-12-26 16:05:26 +00:00
|
|
|
|
2023-01-02 01:18:05 +00:00
|
|
|
pub const RegistrationOptions = struct {
|
|
|
|
invite_code: ?[]const u8 = null,
|
|
|
|
email: ?[]const u8 = null,
|
|
|
|
};
|
|
|
|
|
|
|
|
pub const AccountCreateOptions = @import("../services/accounts.zig").CreateOptions;
|
|
|
|
|
2022-12-26 16:05:26 +00:00
|
|
|
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|
|
2023-01-01 23:58:17 +00:00
|
|
|
try models.invites.getByCode(tx, code, self.context.community.id, self.allocator)
|
2022-12-26 16:05:26 +00:00
|
|
|
else
|
|
|
|
null;
|
|
|
|
defer if (maybe_invite) |inv| util.deepFree(self.allocator, inv);
|
|
|
|
|
|
|
|
if (maybe_invite) |invite| {
|
2023-01-01 23:58:17 +00:00
|
|
|
if (!Uuid.eql(invite.community_id, self.context.community.id)) return error.WrongCommunity;
|
2022-12-26 16:05:26 +00:00
|
|
|
if (!isInviteValid(invite)) return error.InvalidInvite;
|
|
|
|
}
|
|
|
|
|
|
|
|
const invite_kind = if (maybe_invite) |inv| inv.kind else .user;
|
|
|
|
|
2023-01-01 23:58:17 +00:00
|
|
|
if (self.context.community.kind == .admin) @panic("Unimplmented");
|
2022-12-26 16:05:26 +00:00
|
|
|
|
2023-01-02 01:18:05 +00:00
|
|
|
const user_id = try createLocalAccount(
|
2022-12-26 16:05:26 +00:00
|
|
|
tx,
|
|
|
|
username,
|
|
|
|
password,
|
2023-01-01 23:58:17 +00:00
|
|
|
self.context.community.id,
|
2022-12-26 16:05:26 +00:00
|
|
|
.{
|
|
|
|
.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 => {
|
2023-01-01 23:58:17 +00:00
|
|
|
try models.communities.transferOwnership(tx, self.context.community.id, user_id);
|
2022-12-26 16:05:26 +00:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
2023-01-02 00:14:04 +00:00
|
|
|
|
2023-01-02 01:32:17 +00:00
|
|
|
// Only for internal use
|
|
|
|
pub fn createLocalAccount(
|
2023-01-02 01:18:05 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2023-01-02 00:14:04 +00:00
|
|
|
pub fn login(self: anytype, username: []const u8, password: []const u8) !Token {
|
2023-01-02 00:28:36 +00:00
|
|
|
const community_id = self.context.community.id;
|
|
|
|
const credentials = try models.accounts.getCredentialsByUsername(
|
2023-01-02 00:14:04 +00:00
|
|
|
self.db,
|
|
|
|
username,
|
2023-01-02 00:28:36 +00:00
|
|
|
community_id,
|
2023-01-02 00:14:04 +00:00
|
|
|
self.allocator,
|
|
|
|
);
|
2023-01-02 00:28:36 +00:00
|
|
|
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,
|
|
|
|
},
|
|
|
|
};
|
2023-01-02 00:14:04 +00:00
|
|
|
}
|
2023-01-02 01:32:17 +00:00
|
|
|
|
|
|
|
pub fn verifyToken(self: anytype, token: []const u8) !Token.Info {
|
|
|
|
const hash = try hashToken(token, self.allocator);
|
|
|
|
defer self.allocator.free(hash);
|
|
|
|
|
|
|
|
return self.db.queryRow(
|
|
|
|
Token.Info,
|
|
|
|
\\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, self.context.community.id },
|
|
|
|
self.allocator,
|
|
|
|
) catch |err| switch (err) {
|
|
|
|
error.NoRows => error.InvalidToken,
|
|
|
|
else => error.DatabaseFailure,
|
|
|
|
};
|
|
|
|
}
|
2022-12-26 16:05:26 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-01-02 00:28:36 +00:00
|
|
|
// 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,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2023-01-02 01:18:05 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2023-01-02 00:28:36 +00:00
|
|
|
/// 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);
|
|
|
|
}
|
2022-12-26 16:05:26 +00:00
|
|
|
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,
|
2023-01-02 01:32:17 +00:00
|
|
|
_: AccountCreateOptions,
|
2022-12-26 16:05:26 +00:00
|
|
|
_: 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);
|
|
|
|
}
|