fediglam/src/api/methods/auth.zig

438 lines
15 KiB
Zig

const std = @import("std");
const util = @import("util");
const pkg = @import("../lib.zig");
const services = @import("../services.zig");
const invites = @import("./invites.zig");
const Allocator = std.mem.Allocator;
const Uuid = util.Uuid;
const DateTime = util.DateTime;
const ApiContext = pkg.ApiContext;
const Token = pkg.tokens.Token;
const RegistrationOptions = pkg.auth.RegistrationOptions;
const Invite = services.invites.Invite;
pub fn register(
alloc: std.mem.Allocator,
ctx: ApiContext,
svcs: anytype,
opt: RegistrationOptions,
) !Uuid {
const tx = try svcs.beginTx();
errdefer tx.rollbackTx();
const maybe_invite = if (opt.invite_code) |code|
tx.getInviteByCode(alloc, code, ctx.community.id) catch |err| switch (err) {
error.NotFound => return error.InvalidInvite,
else => |e| return e,
}
else
null;
defer if (maybe_invite) |inv| util.deepFree(alloc, inv);
if (maybe_invite) |invite| {
if (!Uuid.eql(invite.community_id, ctx.community.id)) return error.WrongCommunity;
if (!invites.isValid(invite)) return error.InvalidInvite;
}
const invite_kind = if (maybe_invite) |inv| inv.kind else .user;
if (ctx.community.kind == .admin) @panic("Unimplmented");
const account_id = try createLocalAccount(
alloc,
tx,
.{
.username = opt.username,
.password = opt.password,
.community_id = ctx.community.id,
.invite_id = if (maybe_invite) |inv| @as(?Uuid, inv.id) else null,
.email = opt.email,
},
);
switch (invite_kind) {
.user => {},
.system => @panic("System user invites unimplemented"),
.community_owner => {
try tx.transferCommunityOwnership(ctx.community.id, account_id);
},
}
try tx.commitTx();
return account_id;
}
pub const AccountCreateArgs = struct {
username: []const u8,
password: []const u8,
community_id: Uuid,
invite_id: ?Uuid = null,
email: ?[]const u8 = null,
role: services.accounts.Role = .user,
};
pub fn createLocalAccount(
alloc: std.mem.Allocator,
svcs: anytype,
args: AccountCreateArgs,
) !Uuid {
const tx = try svcs.beginTx();
errdefer tx.rollbackTx();
const hash = try hashPassword(args.password, alloc);
defer alloc.free(hash);
const id = try tx.createActor(alloc, args.username, args.community_id, false);
try tx.createAccount(alloc, .{
.for_actor = id,
.password_hash = hash,
.invite_id = args.invite_id,
.email = args.email,
.role = args.role,
});
try tx.commitTx();
return id;
}
pub fn verifyToken(alloc: std.mem.Allocator, ctx: ApiContext, svcs: anytype, token: []const u8) !Token.Info {
const hash = try hashToken(token, alloc);
defer alloc.free(hash);
const info = try svcs.getTokenByHash(alloc, hash, ctx.community.id);
defer util.deepFree(alloc, info);
return .{ .account_id = info.account_id, .issued_at = info.issued_at };
}
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 svcs.getTokenByHash(alloc, token_hash, community_id);
defer util.deepFree(alloc, info);
return .{
.value = token,
.info = .{
.account_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 max_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 => return error.InvalidLogin,
error.OutOfMemory => return error.OutOfMemory,
else => |e| return e,
};
}
const scrypt_params = if (!@import("builtin").is_test)
scrypt.Params.interactive
else
scrypt.Params{
.ln = 8,
.r = 8,
.p = 1,
};
fn hashPassword(password: []const u8, alloc: std.mem.Allocator) ![]const u8 {
var buf: [max_password_hash_len]u8 = undefined;
const hash = try scrypt.strHash(
password,
.{
.allocator = alloc,
.params = scrypt_params,
.encoding = .phc,
},
&buf,
);
return util.deepClone(alloc, hash);
}
/// 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);
}
test "register" {
const testCase = struct {
const test_invite_code = "xyz";
const test_invite_id = Uuid.parse("d24e7f2a-7e6e-4e2a-8e9d-987538a04a40") catch unreachable;
const test_acc_id = Uuid.parse("e8e21e1d-7b80-4e48-876d-9929326af511") catch unreachable;
const test_community_id = Uuid.parse("8bf88bd7-fb07-492d-a89a-6350c036183f") catch unreachable;
const Args = struct {
username: []const u8 = "username",
password: []const u8 = "password1234",
use_invite: bool = false,
invite_community_id: Uuid = test_community_id,
invite_kind: services.invites.Kind = .user,
invite_max_uses: ?usize = null,
invite_current_uses: usize = 0,
invite_expires_at: ?DateTime = null,
get_invite_error: ?anyerror = null,
create_account_error: ?anyerror = null,
create_actor_error: ?anyerror = null,
transfer_error: ?anyerror = null,
expect_error: ?anyerror = null,
expect_transferred: bool = false,
};
fn runCaseOnce(allocator: std.mem.Allocator, test_args: Args) anyerror!void {
const Svc = struct {
test_args: Args,
tx_level: usize = 0,
rolled_back: bool = false,
committed: bool = false,
account_created: bool = false,
actor_created: bool = false,
community_transferred: bool = false,
fn beginTx(self: *@This()) !*@This() {
self.tx_level += 1;
return self;
}
fn rollbackTx(self: *@This()) void {
self.tx_level -= 1;
self.rolled_back = true;
}
fn commitTx(self: *@This()) !void {
self.tx_level -= 1;
self.committed = true;
}
fn getInviteByCode(self: *@This(), alloc: Allocator, code: []const u8, community_id: Uuid) anyerror!services.invites.Invite {
try std.testing.expect(self.tx_level > 0);
try std.testing.expectEqualStrings(test_invite_code, code);
try std.testing.expectEqual(test_community_id, community_id);
if (self.test_args.get_invite_error) |err| return err;
return try util.deepClone(alloc, std.mem.zeroInit(services.invites.Invite, .{
.id = test_invite_id,
.community_id = self.test_args.invite_community_id,
.code = code,
.kind = self.test_args.invite_kind,
.times_used = self.test_args.invite_current_uses,
.max_uses = self.test_args.invite_max_uses,
.expires_at = self.test_args.invite_expires_at,
}));
}
fn createActor(self: *@This(), _: Allocator, username: []const u8, community_id: Uuid, _: bool) anyerror!Uuid {
try std.testing.expect(self.tx_level > 0);
if (self.test_args.create_actor_error) |err| return err;
try std.testing.expectEqualStrings(self.test_args.username, username);
try std.testing.expectEqual(test_community_id, community_id);
self.actor_created = true;
return test_acc_id;
}
fn createAccount(self: *@This(), alloc: Allocator, args: services.accounts.CreateArgs) anyerror!void {
try std.testing.expect(self.tx_level > 0);
if (self.test_args.create_account_error) |err| return err;
try verifyPassword(args.password_hash, self.test_args.password, alloc);
if (self.test_args.use_invite)
try std.testing.expectEqual(@as(?Uuid, test_invite_id), args.invite_id)
else
try std.testing.expect(args.invite_id == null);
try std.testing.expectEqual(services.accounts.Role.user, args.role);
self.account_created = true;
}
fn transferCommunityOwnership(self: *@This(), community_id: Uuid, account_id: Uuid) !void {
try std.testing.expect(self.tx_level > 0);
if (self.test_args.transfer_error) |err| return err;
self.community_transferred = true;
try std.testing.expectEqual(test_community_id, community_id);
try std.testing.expectEqual(test_acc_id, account_id);
}
};
var svc = Svc{ .test_args = test_args };
const community = std.mem.zeroInit(pkg.Community, .{ .kind = .local, .id = test_community_id });
const result = register(
allocator,
.{ .community = community },
&svc,
.{
.username = test_args.username,
.password = test_args.password,
.invite_code = if (test_args.use_invite) test_invite_code else null,
},
// shortcut out of memory errors to test allocation
) catch |err| if (err == error.OutOfMemory) return err else err;
if (test_args.expect_error) |err| {
try std.testing.expectError(err, result);
try std.testing.expect(!svc.committed);
if (svc.account_created or svc.actor_created or svc.community_transferred) {
try std.testing.expect(svc.rolled_back);
}
} else {
try std.testing.expectEqual(test_acc_id, try result);
try std.testing.expect(svc.committed);
try std.testing.expect(!svc.rolled_back);
try std.testing.expect(svc.account_created);
try std.testing.expect(svc.actor_created);
try std.testing.expectEqual(test_args.expect_transferred, svc.community_transferred);
}
}
fn case(args: Args) !void {
try std.testing.checkAllAllocationFailures(std.testing.allocator, runCaseOnce, .{args});
}
}.case;
// regular registration
try testCase(.{});
// registration with invite
try testCase(.{ .use_invite = true });
// registration with invite for a different community
try testCase(.{
.invite_community_id = Uuid.parse("11111111-1111-1111-1111-111111111111") catch unreachable,
.use_invite = true,
.expect_error = error.WrongCommunity,
});
// registration as a new community owner
try testCase(.{
.use_invite = true,
.invite_kind = .community_owner,
.expect_transferred = true,
});
// invite with expiration info
try testCase(.{
.use_invite = true,
.invite_max_uses = 100,
.invite_current_uses = 10,
.invite_expires_at = DateTime{ .seconds_since_epoch = DateTime.test_now_timestamp + 3600 },
});
// missing invite
try testCase(.{
.use_invite = true,
.get_invite_error = error.NotFound,
.expect_error = error.InvalidInvite,
});
// expired invite
try testCase(.{
.use_invite = true,
.invite_expires_at = DateTime{ .seconds_since_epoch = DateTime.test_now_timestamp - 3600 },
.expect_error = error.InvalidInvite,
});
// used invite
try testCase(.{
.use_invite = true,
.invite_max_uses = 100,
.invite_current_uses = 110,
.expect_error = error.InvalidInvite,
});
}