╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │118 else => |e| return e, │ │119 }; │ │120 │ │115 - handleRequest(io, &request, allocator, admin_repo_path) catch |request_err| { │ │121 + handleRequest(io, &request, allocator, admin_repo_path, session_store) catch |request_err| {│ │122 try err.print("web ui request failed: {s}\n", .{@errorName(request_err)}); │ │123 try err.flush(); │ │124 // best-effort 500. if handleRequest already started writing a │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │137 request: *std.http.Server.Request, │ │138 allocator: std.mem.Allocator, │ │139 admin_repo_path: []const u8, │ │140 + session_store: SessionStore, │ │141 ) !void { │ │142 const method = request.head.method; │ │143 const uri = try std.Uri.parseAfterScheme("", request.head.target);│ ╰───────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │144 const path = uri.path.percent_encoded; │ │145 │ │146 if (method == .POST and std.mem.eql(u8, path, "/login")) { │ │140 - return handleLogin(io, request, allocator, admin_repo_path); │ │147 + return handleLogin(io, request, allocator, admin_repo_path, session_store);│ │148 } │ │149 if (method == .POST and std.mem.eql(u8, path, "/logout")) { │ │143 - return handleLogout(request); │ │150 + return handleLogout(request, session_store); │ │151 } │ │152 │ │153 const get_or_head = method == .GET or method == .HEAD; │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │168 } │ │169 │ │170 if (ui.RoutablePage.fromUrl(path)) |current_page| { │ │164 - // decode the haxy_user cookie into raw bytes on the stack; the │ │165 - // slice lives for the rest of handleRequest, which is plenty for │ │166 - // renderIndexHtml to consume. │ │171 + // resolve the haxy_session cookie's token to a user_id via the │ │172 + // store. user_id_buf lives on the stack for the rest of handleRequest, │ │173 + // which is plenty for renderIndexHtml to consume. │ │174 var user_id_buf: [evt.event_id_size]u8 = undefined; │ │175 const user_id: ?[]const u8 = blk: { │ │169 - const hex = getCookieValue(request, cookie_name) orelse break :blk null; │ │170 - if (hex.len != evt.event_id_size * 2) break :blk null; │ │171 - _ = std.fmt.hexToBytes(&user_id_buf, hex) catch break :blk null; │ │176 + const token = getCookieValue(request, cookie_name) orelse break :blk null;│ │177 + if (!session_store.lookup(token, &user_id_buf)) break :blk null; │ │178 break :blk user_id_buf[0..evt.event_id_size]; │ │179 }; │ │180 const login_failure: ?ui.Home.Auth.Login.Failure = │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────╮ │224 request: *std.http.Server.Request, │ │225 allocator: std.mem.Allocator, │ │226 admin_repo_path: []const u8, │ │227 + session_store: SessionStore, │ │228 ) !void { │ │229 var body_buf: [256]u8 = undefined; │ │230 const reader = request.readerExpectNone(&body_buf);│ ╰────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │249 │ │250 switch (result) { │ │251 .success => |user_id| { │ │245 - const hex = std.fmt.bytesToHex(user_id, .lower); │ │252 + const token = try session_store.create(&user_id); │ │253 var cookie_buf: [256]u8 = undefined; │ │247 - const cookie = try std.fmt.bufPrint(&cookie_buf, cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict", .{hex}); │ │254 + const cookie = try std.fmt.bufPrint(&cookie_buf, cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict", .{token});│ │255 try request.respond("", .{ │ │256 .status = .see_other, │ │257 .extra_headers = &.{ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────╮ │281 } │ │282 } │ │283 │ │277 -fn handleLogout(request: *std.http.Server.Request) !void { │ │284 +fn handleLogout(request: *std.http.Server.Request, session_store: SessionStore) !void {│ │285 + // revoke the session server-side so the cookie is dead for anyone holding │ │286 + // a copy, not just this browser. │ │287 + if (getCookieValue(request, cookie_name)) |token| session_store.remove(token); │ │288 // close the connection instead of keeping it alive: we don't read the │ │289 // request body, and respond()'s keep-alive path would otherwise try to │ │290 // discard it — which asserts on a bodyless POST that carries no │ ╰────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │692 return try out.toOwnedSlice(allocator); │ │693 } │ │694 │ │695 +// disk-backed web session store. each login mints a random, opaque token │ │696 +// (not the user's id) that is stored as a file named by the token hex, whose │ │697 +// contents are the raw user_id bytes. authenticating a request is a lookup of │ │698 +// the cookie's token; logout deletes the file, revoking the session. there is │ │699 +// deliberately no expiry — a session lives until logout. │ │700 +pub const SessionStore = struct { │ │701 + io: std.Io, │ │702 + dir: std.Io.Dir, │ │703 + │ │704 + pub const token_hex_len = evt.event_id_size * 2; │ │705 + │ │706 + pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { │ │707 + try data_dir.createDirPath(io, "sessions"); │ │708 + const dir = try data_dir.openDir(io, "sessions", .{}); │ │709 + return .{ .io = io, .dir = dir }; │ │710 + } │ │711 + │ │712 + pub fn deinit(self: SessionStore) void { │ │713 + self.dir.close(self.io); │ │714 + } │ │715 + │ │716 + // mint a new session for user_id, returning the token hex to set as the │ │717 + // cookie value. │ │718 + pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { │ │719 + var token: [evt.event_id_size]u8 = undefined; │ │720 + self.io.random(&token); │ │721 + const token_hex = std.fmt.bytesToHex(token, .lower); │ │722 + const file = try self.dir.createFile(self.io, &token_hex, .{}); │ │723 + defer file.close(self.io); │ │724 + try file.writeStreamingAll(self.io, user_id); │ │725 + return token_hex; │ │726 + } │ │727 + │ │728 + // resolve a cookie's token to its user_id. returns false (logged out) for a │ │729 + // missing, malformed, or unknown token. │ │730 + pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { │ │731 + if (!isToken(token_hex)) return false; │ │732 + const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false;│ │733 + defer file.close(self.io); │ │734 + var storage: [evt.event_id_size]u8 = undefined; │ │735 + var file_reader = file.reader(self.io, &storage); │ │736 + file_reader.interface.readSliceAll(out) catch return false; │ │737 + return true; │ │738 + } │ │739 + │ │740 + // revoke a session. a no-op for a missing or malformed token. │ │741 + pub fn remove(self: SessionStore, token_hex: []const u8) void { │ │742 + if (!isToken(token_hex)) return; │ │743 + self.dir.deleteFile(self.io, token_hex) catch {}; │ │744 + } │ │745 + │ │746 + // a token is exactly token_hex_len hex chars. validating before using the │ │747 + // value as a path keeps attacker-supplied cookies from escaping the │ │748 + // sessions dir (e.g. via "/" or ".." bytes). │ │749 + fn isToken(token_hex: []const u8) bool { │ │750 + if (token_hex.len != token_hex_len) return false; │ │751 + for (token_hex) |c| if (!std.ascii.isHex(c)) return false; │ │752 + return true; │ │753 + } │ │754 +}; │ │755 + │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯