Split users into actors and localusers

This commit is contained in:
jaina heartles 2022-07-21 21:19:08 -07:00
parent a4f6fca675
commit ee0db68c5e
5 changed files with 115 additions and 42 deletions

View File

@ -7,6 +7,11 @@ pub const models = @import("./models.zig");
pub const DateTime = util.DateTime; pub const DateTime = util.DateTime;
pub const Uuid = util.Uuid; 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 // Frees an api struct and its fields allocated from alloc
pub fn free(alloc: std.mem.Allocator, val: anytype) void { pub fn free(alloc: std.mem.Allocator, val: anytype) void {
switch (@typeInfo(@TypeOf(val))) { switch (@typeInfo(@TypeOf(val))) {
@ -60,7 +65,7 @@ fn reify(comptime T: type, id: Uuid, val: CreateInfo(T)) T {
pub const ApiContext = struct { pub const ApiContext = struct {
user_context: struct { user_context: struct {
user: models.User, user: models.LocalUser,
}, },
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
@ -70,23 +75,31 @@ pub const NoteCreate = struct {
content: []const u8, content: []const u8,
}; };
pub const RegistrationInfo = struct {
username: []const u8,
password: []const u8,
email: ?[]const u8,
};
pub const ApiServer = struct { pub const ApiServer = struct {
prng: std.rand.DefaultPrng, prng: std.rand.DefaultPrng,
db: db.Database, 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{ return ApiServer{
.prng = std.rand.DefaultPrng.init(@bitCast(u64, std.time.milliTimestamp())), .prng = std.rand.DefaultPrng.init(@bitCast(u64, std.time.milliTimestamp())),
.db = try db.Database.init(), .db = try db.Database.init(),
.internal_alloc = alloc,
}; };
} }
pub fn makeApiContext(self: *ApiServer, token: []const u8, alloc: std.mem.Allocator) !ApiContext { pub fn makeApiContext(self: *ApiServer, token: []const u8, alloc: std.mem.Allocator) !ApiContext {
if (token.len == 0) return error.InvalidToken; 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{ return ApiContext{
.user_context = .{ .user_context = .{
@ -100,11 +113,10 @@ pub const ApiServer = struct {
pub fn createNoteUser(self: *ApiServer, info: NoteCreate, ctx: ApiContext) !models.Note { pub fn createNoteUser(self: *ApiServer, info: NoteCreate, ctx: ApiContext) !models.Note {
const id = Uuid.randV4(self.prng.random()); const id = Uuid.randV4(self.prng.random());
// TODO: check for dupes // TODO: check for dupes
std.debug.print("user {s} making a note\n", .{ctx.user_context.user.handle});
const note = models.Note{ const note = models.Note{
.id = id, .id = id,
.author_id = ctx.user_context.user.id, .author_id = ctx.user_context.user.actor_id.?,
.content = info.content, .content = info.content,
.created_at = DateTime.now(), .created_at = DateTime.now(),
@ -114,33 +126,72 @@ pub const ApiServer = struct {
return note; 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()); const id = Uuid.randV4(self.prng.random());
// TODO: transaction?
if (try self.db.existsWhereEq(models.User, .handle, info.handle)) { if (try self.db.existsWhereEq(models.LocalUser, .username, info.username)) {
return error.HandleNotAvailable; return error.UsernameUnavailable;
} }
const user = reify(models.User, id, info); if (try self.db.existsWhereEq(models.Actor, .handle, info.username)) {
try self.db.insert(models.User, user); 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 { pub fn getNote(self: *ApiServer, id: Uuid, alloc: std.mem.Allocator) !?models.Note {
return self.db.getBy(models.Note, .id, id, alloc); return self.db.getBy(models.Note, .id, id, alloc);
} }
pub fn getUser(self: *ApiServer, id: Uuid, alloc: std.mem.Allocator) !?models.User { pub fn getActor(self: *ApiServer, id: Uuid, alloc: std.mem.Allocator) !?models.Actor {
return self.db.getBy(models.User, .id, id, alloc); return self.db.getBy(models.Actor, .id, id, alloc);
} }
pub fn getUserByHandle(self: *ApiServer, handle: []const u8, alloc: std.mem.Allocator) !?models.User { pub fn getActorByHandle(self: *ApiServer, handle: []const u8, alloc: std.mem.Allocator) !?models.Actor {
return self.db.getBy(models.User, .handle, handle, alloc); return self.db.getBy(models.Actor, .handle, handle, alloc);
} }
pub fn react(self: *ApiServer, note_id: Uuid, ctx: ApiContext) !void { 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 { pub fn listReacts(self: *ApiServer, note_id: Uuid, ctx: ApiContext) ![]models.Reaction {

View File

@ -76,17 +76,12 @@ pub fn createNote(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs)
try utils.respondJson(ctx, .created, note); try utils.respondJson(ctx, .created, note);
} }
pub fn createUser(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void { pub fn register(srv: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void {
const credentials = try utils.parseRequestBody(struct { handle: []const u8, password: []const u8 }, ctx); const info = try utils.parseRequestBody(api.RegistrationInfo, ctx);
defer utils.freeRequestBody(credentials, ctx.alloc); defer utils.freeRequestBody(info, ctx.alloc);
var hash_buf: [128]u8 = undefined; const user = srv.api.register(info) catch |err| switch (err) {
error.UsernameUnavailable => return try utils.respondError(ctx, .bad_request, "Username Unavailable"),
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"),
else => return err, 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 { pub fn getUser(srv: *RequestServer, ctx: *http.server.Context, args: RouteArgs) !void {
const id_str = args.get("id") orelse return error.NotFound; 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 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); defer api.free(ctx.alloc, user);
try utils.respondJson(ctx, .ok, 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); const credentials = try utils.parseRequestBody(struct { username: []const u8, password: []const u8 }, ctx);
defer utils.freeRequestBody(credentials, ctx.alloc); defer utils.freeRequestBody(credentials, ctx.alloc);
// TODO: This gives away the existence of a user through a timing side channel. is that acceptable? const actor = srv.api.login(credentials.username, credentials.password, ctx.alloc) catch |err| switch (err) {
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) {
error.PasswordVerificationFailed => return utils.respondError(ctx, .bad_request, "Invalid Login"), error.PasswordVerificationFailed => return utils.respondError(ctx, .bad_request, "Invalid Login"),
else => return err, 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 { pub fn healthcheck(_: *RequestServer, ctx: *http.server.Context, _: RouteArgs) !void {

View File

@ -9,8 +9,9 @@ const comptimePrint = std.fmt.comptimePrint;
fn tableName(comptime T: type) String { fn tableName(comptime T: type) String {
return switch (T) { return switch (T) {
models.Note => "note", models.Note => "note",
models.User => "user", models.Actor => "actor",
models.Reaction => "reaction", models.Reaction => "reaction",
models.LocalUser => "local_user",
else => unreachable, else => unreachable,
}; };
} }
@ -102,11 +103,28 @@ pub const Database = struct {
const init_sql_stmts = [_][]const u8{ const init_sql_stmts = [_][]const u8{
\\CREATE TABLE IF NOT EXISTS \\CREATE TABLE IF NOT EXISTS
\\user( \\actor(
\\ id TEXT PRIMARY KEY, \\ id TEXT,
\\ handle TEXT NOT NULL, \\ 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; \\) STRICT;
, ,
\\CREATE TABLE IF NOT EXISTS \\CREATE TABLE IF NOT EXISTS
@ -116,7 +134,7 @@ pub const Database = struct {
\\ author_id TEXT NOT NULL, \\ author_id TEXT NOT NULL,
\\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
\\ \\
\\ FOREIGN KEY (author_id) REFERENCES user(id) \\ FOREIGN KEY (author_id) REFERENCES actor(id)
\\) STRICT; \\) STRICT;
, ,
\\CREATE TABLE IF NOT EXISTS \\CREATE TABLE IF NOT EXISTS
@ -124,7 +142,7 @@ pub const Database = struct {
\\ reactor_id TEXT NOT NULL, \\ reactor_id TEXT NOT NULL,
\\ note_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), \\ FOREIGN KEY(note_id) REFERENCES note(id),
\\ \\
\\ PRIMARY KEY(reactor_id, note_id) \\ PRIMARY KEY(reactor_id, note_id)

View File

@ -24,7 +24,7 @@ const router = Router{
Route.new(.GET, "/notes/:id/reacts", c.listReacts), Route.new(.GET, "/notes/:id/reacts", c.listReacts),
Route.new(.POST, "/notes/:id/reacts", c.react), 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), Route.new(.GET, "/users/:id", c.getUser),
}, },
}; };

View File

@ -12,11 +12,23 @@ pub const Note = struct {
created_at: DateTime, created_at: DateTime,
}; };
pub const User = struct { pub const Actor = struct {
id: Uuid, id: Uuid,
handle: []const u8, 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 hashed_password: []const u8, // encoded in PHC format, with salt
password_changed_at: DateTime,
created_at: DateTime,
}; };
pub const Reaction = struct { pub const Reaction = struct {