diff --git a/src/main/api.zig b/src/main/api.zig index 722d37b..6e01973 100644 --- a/src/main/api.zig +++ b/src/main/api.zig @@ -7,6 +7,11 @@ pub const models = @import("./models.zig"); pub const DateTime = util.DateTime; pub const Uuid = util.Uuid; +const PwHash = std.crypto.pwhash.scrypt; +const pw_hash_params = PwHash.Params.interactive; +const pw_hash_encoding = .phc; +const pw_hash_buf_size = 128; + // Frees an api struct and its fields allocated from alloc pub fn free(alloc: std.mem.Allocator, val: anytype) void { switch (@typeInfo(@TypeOf(val))) { @@ -60,7 +65,7 @@ fn reify(comptime T: type, id: Uuid, val: CreateInfo(T)) T { pub const ApiContext = struct { user_context: struct { - user: models.User, + user: models.LocalUser, }, alloc: std.mem.Allocator, @@ -70,23 +75,31 @@ pub const NoteCreate = struct { content: []const u8, }; +pub const RegistrationInfo = struct { + username: []const u8, + password: []const u8, + email: ?[]const u8, +}; + pub const ApiServer = struct { prng: std.rand.DefaultPrng, db: db.Database, + internal_alloc: std.mem.Allocator, - pub fn init(_: std.mem.Allocator) !ApiServer { + pub fn init(alloc: std.mem.Allocator) !ApiServer { return ApiServer{ .prng = std.rand.DefaultPrng.init(@bitCast(u64, std.time.milliTimestamp())), .db = try db.Database.init(), + .internal_alloc = alloc, }; } pub fn makeApiContext(self: *ApiServer, token: []const u8, alloc: std.mem.Allocator) !ApiContext { if (token.len == 0) return error.InvalidToken; - const user_handle = token; + const username = token; - const user = (try self.db.getBy(models.User, .handle, user_handle, alloc)) orelse return error.InvalidToken; + const user = (try self.db.getBy(models.LocalUser, .username, username, alloc)) orelse return error.InvalidToken; return ApiContext{ .user_context = .{ @@ -100,11 +113,10 @@ pub const ApiServer = struct { pub fn createNoteUser(self: *ApiServer, info: NoteCreate, ctx: ApiContext) !models.Note { const id = Uuid.randV4(self.prng.random()); // TODO: check for dupes - std.debug.print("user {s} making a note\n", .{ctx.user_context.user.handle}); const note = models.Note{ .id = id, - .author_id = ctx.user_context.user.id, + .author_id = ctx.user_context.user.actor_id.?, .content = info.content, .created_at = DateTime.now(), @@ -114,33 +126,72 @@ pub const ApiServer = struct { return note; } - pub fn createUser(self: *ApiServer, info: CreateInfo(models.User)) !models.User { + pub fn register(self: *ApiServer, info: RegistrationInfo) !models.Actor { const id = Uuid.randV4(self.prng.random()); + // TODO: transaction? - if (try self.db.existsWhereEq(models.User, .handle, info.handle)) { - return error.HandleNotAvailable; + if (try self.db.existsWhereEq(models.LocalUser, .username, info.username)) { + return error.UsernameUnavailable; } - const user = reify(models.User, id, info); - try self.db.insert(models.User, user); + if (try self.db.existsWhereEq(models.Actor, .handle, info.username)) { + return error.InconsistentDb; + } - return user; + // use internal alloc because necessary buffer is *big* + var buf: [pw_hash_buf_size]u8 = undefined; + const hash = try PwHash.strHash(info.password, .{ .allocator = self.internal_alloc, .params = pw_hash_params, .encoding = pw_hash_encoding }, &buf); + const now = DateTime.now(); + + const actor = models.Actor{ + .id = id, + .handle = info.username, + .created_at = now, + }; + const user = models.LocalUser{ + .actor_id = id, + .username = info.username, + .email = info.email, + .hashed_password = hash, + .password_changed_at = now, + .created_at = now, + }; + try self.db.insert(models.Actor, actor); + try self.db.insert(models.LocalUser, user); + + // TODO: return token instead + return actor; + } + + pub fn login(self: *ApiServer, username: []const u8, password: []const u8, alloc: std.mem.Allocator) !?models.Actor { + // TODO: This gives away the existence of a user through a timing side channel. is that acceptable? + const user_info = (try self.db.getBy(models.LocalUser, .username, username, alloc)) orelse return error.InvalidLogin; + defer free(alloc, user_info); + + const Hash = std.crypto.pwhash.scrypt; + Hash.strVerify(user_info.hashed_password, password, .{ .allocator = alloc }) catch |err| switch (err) { + error.PasswordVerificationFailed => return error.InvalidLogin, + else => return err, + }; + + // TODO: return token instead + return (try self.db.getBy(models.Actor, .id, user_info.actor_id.?, alloc)) orelse unreachable; } pub fn getNote(self: *ApiServer, id: Uuid, alloc: std.mem.Allocator) !?models.Note { return self.db.getBy(models.Note, .id, id, alloc); } - pub fn getUser(self: *ApiServer, id: Uuid, alloc: std.mem.Allocator) !?models.User { - return self.db.getBy(models.User, .id, id, alloc); + pub fn getActor(self: *ApiServer, id: Uuid, alloc: std.mem.Allocator) !?models.Actor { + return self.db.getBy(models.Actor, .id, id, alloc); } - pub fn getUserByHandle(self: *ApiServer, handle: []const u8, alloc: std.mem.Allocator) !?models.User { - return self.db.getBy(models.User, .handle, handle, alloc); + pub fn getActorByHandle(self: *ApiServer, handle: []const u8, alloc: std.mem.Allocator) !?models.Actor { + return self.db.getBy(models.Actor, .handle, handle, alloc); } pub fn react(self: *ApiServer, note_id: Uuid, ctx: ApiContext) !void { - try self.db.insert(models.Reaction, .{ .note_id = note_id, .reactor_id = ctx.user_context.user.id }); + try self.db.insert(models.Reaction, .{ .note_id = note_id, .reactor_id = ctx.user_context.user.actor_id.? }); } pub fn listReacts(self: *ApiServer, note_id: Uuid, ctx: ApiContext) ![]models.Reaction { diff --git a/src/main/controllers.zig b/src/main/controllers.zig index 72d1156..5d34b3f 100644 --- a/src/main/controllers.zig +++ b/src/main/controllers.zig @@ -76,17 +76,12 @@ pub fn createNote(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) try utils.respondJson(ctx, .created, note); } -pub fn createUser(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void { - const credentials = try utils.parseRequestBody(struct { handle: []const u8, password: []const u8 }, ctx); - defer utils.freeRequestBody(credentials, ctx.alloc); +pub fn register(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void { + const info = try utils.parseRequestBody(api.RegistrationInfo, ctx); + defer utils.freeRequestBody(info, ctx.alloc); - var hash_buf: [128]u8 = undefined; - - const Hash = std.crypto.pwhash.scrypt; - const hashed_password = try Hash.strHash(credentials.password, .{ .allocator = ctx.alloc, .params = Hash.Params.interactive, .encoding = .phc }, &hash_buf); - - const user = srv.api.createUser(.{ .handle = credentials.handle, .hashed_password = hashed_password }) catch |err| switch (err) { - error.HandleNotAvailable => return try utils.respondError(ctx, .bad_request, "handle not available"), + const user = srv.api.register(info) catch |err| switch (err) { + error.UsernameUnavailable => return try utils.respondError(ctx, .bad_request, "Username Unavailable"), else => return err, }; @@ -105,7 +100,7 @@ pub fn getNote(srv: *RequestServer, ctx: *http.server.Context, args: RouteArgs) pub fn getUser(srv: *RequestServer, ctx: *http.server.Context, args: RouteArgs) !void { const id_str = args.get("id") orelse return error.NotFound; const id = Uuid.parse(id_str) catch return utils.respondError(ctx, .bad_request, "Invalid UUID"); - const user = (try srv.api.getUser(id, ctx.alloc)) orelse return utils.respondError(ctx, .not_found, "Note not found"); + const user = (try srv.api.getActor(id, ctx.alloc)) orelse return utils.respondError(ctx, .not_found, "Note not found"); defer api.free(ctx.alloc, user); try utils.respondJson(ctx, .ok, user); @@ -144,16 +139,13 @@ pub fn login(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void const credentials = try utils.parseRequestBody(struct { username: []const u8, password: []const u8 }, ctx); defer utils.freeRequestBody(credentials, ctx.alloc); - // TODO: This gives away the existence of a user through a timing side channel. is that acceptable? - const user = (try srv.api.getUserByHandle(credentials.username, ctx.alloc)) orelse return utils.respondError(ctx, .bad_request, "Invalid Login"); - - const Hash = std.crypto.pwhash.scrypt; - Hash.strVerify(user.hashed_password, credentials.password, .{ .allocator = ctx.alloc }) catch |err| switch (err) { + const actor = srv.api.login(credentials.username, credentials.password, ctx.alloc) catch |err| switch (err) { error.PasswordVerificationFailed => return utils.respondError(ctx, .bad_request, "Invalid Login"), else => return err, }; + defer api.free(ctx.alloc, actor); - try utils.respondJson(ctx, .ok, .{ .id = user.id }); + try utils.respondJson(ctx, .ok, actor); } pub fn healthcheck(_: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void { diff --git a/src/main/db.zig b/src/main/db.zig index ec417c0..f2e6edb 100644 --- a/src/main/db.zig +++ b/src/main/db.zig @@ -9,8 +9,9 @@ const comptimePrint = std.fmt.comptimePrint; fn tableName(comptime T: type) String { return switch (T) { models.Note => "note", - models.User => "user", + models.Actor => "actor", models.Reaction => "reaction", + models.LocalUser => "local_user", else => unreachable, }; } @@ -102,11 +103,28 @@ pub const Database = struct { const init_sql_stmts = [_][]const u8{ \\CREATE TABLE IF NOT EXISTS - \\user( - \\ id TEXT PRIMARY KEY, + \\actor( + \\ id TEXT, \\ handle TEXT NOT NULL, + \\ created_at INTEGER NOT NULL, \\ - \\ hashed_password TEXT NOT NULL + \\ PRIMARY KEY (id) + \\) STRICT; + , + \\CREATE TABLE IF NOT EXISTS + \\local_user( + \\ username TEXT NOT NULL, + \\ actor_id TEXT, + \\ email TEXT, + \\ hashed_password TEXT NOT NULL, + \\ + \\ created_at INTEGER NOT NULL, + \\ password_changed_at INTEGER NOT NULL, + \\ + \\ UNIQUE(actor_id), + \\ FOREIGN KEY (actor_id) REFERENCES actor(id), + \\ + \\ PRIMARY KEY (username) \\) STRICT; , \\CREATE TABLE IF NOT EXISTS @@ -116,7 +134,7 @@ pub const Database = struct { \\ author_id TEXT NOT NULL, \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, \\ - \\ FOREIGN KEY (author_id) REFERENCES user(id) + \\ FOREIGN KEY (author_id) REFERENCES actor(id) \\) STRICT; , \\CREATE TABLE IF NOT EXISTS @@ -124,7 +142,7 @@ pub const Database = struct { \\ reactor_id TEXT NOT NULL, \\ note_id TEXT NOT NULL, \\ - \\ FOREIGN KEY(reactor_id) REFERENCES user(id), + \\ FOREIGN KEY(reactor_id) REFERENCES actor(id), \\ FOREIGN KEY(note_id) REFERENCES note(id), \\ \\ PRIMARY KEY(reactor_id, note_id) diff --git a/src/main/main.zig b/src/main/main.zig index 7c9f918..4cbb343 100644 --- a/src/main/main.zig +++ b/src/main/main.zig @@ -24,7 +24,7 @@ const router = Router{ Route.new(.GET, "/notes/:id/reacts", c.listReacts), Route.new(.POST, "/notes/:id/reacts", c.react), - Route.new(.POST, "/users", c.createUser), + Route.new(.POST, "/auth/register", c.register), Route.new(.GET, "/users/:id", c.getUser), }, }; diff --git a/src/main/models.zig b/src/main/models.zig index d859673..ce0990a 100644 --- a/src/main/models.zig +++ b/src/main/models.zig @@ -12,11 +12,23 @@ pub const Note = struct { created_at: DateTime, }; -pub const User = struct { +pub const Actor = struct { id: Uuid, handle: []const u8, + created_at: DateTime, +}; + +pub const LocalUser = struct { + actor_id: ?Uuid, + + username: []const u8, + email: ?[]const u8, + hashed_password: []const u8, // encoded in PHC format, with salt + password_changed_at: DateTime, + + created_at: DateTime, }; pub const Reaction = struct {