diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.zig | 3 | ||||
| -rw-r--r-- | src/routes/access.zig | 6 | ||||
| -rw-r--r-- | src/routes/api/auth/first-login.zig | 5 | ||||
| -rw-r--r-- | src/routes/api/auth/login.zig | 18 | ||||
| -rw-r--r-- | src/routes/api/auth/root.zig | 2 | ||||
| -rw-r--r-- | src/routes/api/root.zig | 1 | ||||
| -rw-r--r-- | src/routes/api/session/current.zig | 23 | ||||
| -rw-r--r-- | src/routes/api/session/drop.zig | 8 | ||||
| -rw-r--r-- | src/routes/api/session/renew.zig | 8 | ||||
| -rw-r--r-- | src/routes/api/session/root.zig | 5 | ||||
| -rw-r--r-- | src/routes/context.zig | 2 | ||||
| -rw-r--r-- | src/routes/handler-info.zig | 201 | ||||
| -rw-r--r-- | src/routes/root.zig | 45 | ||||
| -rw-r--r-- | src/routes/static.zig (renamed from src/routes/fallback.zig) | 5 | ||||
| -rw-r--r-- | src/storage/root.zig | 9 | ||||
| -rw-r--r-- | src/storage/session-manager/root.zig | 59 | ||||
| -rw-r--r-- | src/storage/session-manager/session.zig | 34 | ||||
| -rw-r--r-- | src/storage/user.zig | 36 |
18 files changed, 387 insertions, 83 deletions
diff --git a/src/config.zig b/src/config.zig index 8d4ab29..687cd25 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1 +1,4 @@ +const std = @import("std"); + pub const storage_path = "./storage/"; +pub const session_expires_after = std.time.ns_per_week * 4; diff --git a/src/routes/access.zig b/src/routes/access.zig new file mode 100644 index 0000000..39872b8 --- /dev/null +++ b/src/routes/access.zig @@ -0,0 +1,6 @@ + +pub const Access = enum { + everyone, + users, + admins, +}; diff --git a/src/routes/api/auth/first-login.zig b/src/routes/api/auth/first-login.zig index 2fb8c02..34f04d8 100644 --- a/src/routes/api/auth/first-login.zig +++ b/src/routes/api/auth/first-login.zig @@ -1,14 +1,13 @@ const std = @import("std"); const Context = @import("../../context.zig"); -pub const needs_auth = true; -pub const method = .POST; +pub const access = .everyone; const Result = struct { is_first: bool, }; -pub fn handler(ctx: *Context) anyerror!Result { +pub fn post(ctx: *Context) anyerror!Result { _ = ctx; return .{ .is_first = false }; } diff --git a/src/routes/api/auth/login.zig b/src/routes/api/auth/login.zig index c3f2bef..76efcf4 100644 --- a/src/routes/api/auth/login.zig +++ b/src/routes/api/auth/login.zig @@ -3,9 +3,6 @@ const std = @import("std"); const Context = @import("../../context.zig"); const Storage = @import("../../../storage/root.zig"); -pub const needs_auth = false; -pub const method = .POST; - const Body = struct { user: []const u8, password: []const u8, @@ -15,11 +12,22 @@ const Result = struct { success: bool, }; -pub fn handler(ctx: *Context, body: Body) anyerror!Result { +pub const access = .everyone; + +pub fn post(ctx: *Context, body: Body) anyerror!Result { var user = Storage.User.open(ctx.storage, body.user, ctx.allocator) catch return .{ .success = false }; defer user.deinit(); - return .{ .success = user.check_password(body.password) }; + const result = Result { + .success = user.check_password(body.password) + }; + + if (result.success) { + const session = try ctx.storage.sessions.add(ctx.storage, user.info); + ctx.response.headers.fingerprint = session.fingerprint; + } + + return result; } diff --git a/src/routes/api/auth/root.zig b/src/routes/api/auth/root.zig index 785271e..5f45891 100644 --- a/src/routes/api/auth/root.zig +++ b/src/routes/api/auth/root.zig @@ -1,4 +1,4 @@ const HandlerInfo = @import("../../handler-info.zig"); pub const login: HandlerInfo = .from_type(@import("login.zig")); -pub const first_login: HandlerInfo = .from_type(@import("first-login.zig")); +pub const @"first-login": HandlerInfo = .from_type(@import("first-login.zig")); diff --git a/src/routes/api/root.zig b/src/routes/api/root.zig index 19c4074..e3c5d6f 100644 --- a/src/routes/api/root.zig +++ b/src/routes/api/root.zig @@ -1 +1,2 @@ pub const auth = @import("auth/root.zig"); +pub const session = @import("session/root.zig"); diff --git a/src/routes/api/session/current.zig b/src/routes/api/session/current.zig new file mode 100644 index 0000000..31c8476 --- /dev/null +++ b/src/routes/api/session/current.zig @@ -0,0 +1,23 @@ +const std = @import("std"); +const Context = @import("../../context.zig"); + +pub const access = .users; + +const Result = struct { + name: []const u8, + full_name: []const u8, + birthday: []const u8, +}; + +pub fn get(ctx: *Context) !Result { + const session = ctx.storage.sessions.get( + ctx.storage, + ctx.fingerprint, + ) orelse return error.UserDoesNotExist; + + return .{ + .name = session.info.name, + .full_name = session.info.full_name, + .birthday = session.info.birthday, + }; +} diff --git a/src/routes/api/session/drop.zig b/src/routes/api/session/drop.zig new file mode 100644 index 0000000..eacf8ce --- /dev/null +++ b/src/routes/api/session/drop.zig @@ -0,0 +1,8 @@ +const Context = @import("../../context.zig"); + +pub const access = .users; + +pub fn get(ctx: *Context) !void { + ctx.storage.sessions.remove(ctx.storage, ctx.fingerprint); + ctx.response.headers.fingerprint = ""; +} diff --git a/src/routes/api/session/renew.zig b/src/routes/api/session/renew.zig new file mode 100644 index 0000000..7dfa491 --- /dev/null +++ b/src/routes/api/session/renew.zig @@ -0,0 +1,8 @@ +const Context = @import("../../context.zig"); + +pub const access = .users; + +pub fn get(ctx: *Context) !void { + const new = try ctx.storage.sessions.renew(ctx.storage, ctx.fingerprint); + ctx.response.headers.fingerprint = new.fingerprint; +} diff --git a/src/routes/api/session/root.zig b/src/routes/api/session/root.zig new file mode 100644 index 0000000..ea155e5 --- /dev/null +++ b/src/routes/api/session/root.zig @@ -0,0 +1,5 @@ +const HandlerInfo = @import("../../handler-info.zig"); + +pub const current: HandlerInfo = .from_type(@import("current.zig")); +pub const drop: HandlerInfo = .from_type(@import("renew.zig")); +pub const renew: HandlerInfo = .from_type(@import("drop.zig")); diff --git a/src/routes/context.zig b/src/routes/context.zig index bc3e710..7c0ca4e 100644 --- a/src/routes/context.zig +++ b/src/routes/context.zig @@ -6,9 +6,11 @@ const Self = @This(); allocator: std.mem.Allocator, request: *std.http.Server.Request, storage: *Storage, +fingerprint: []const u8, user: ?[]const u8 = null, response: struct { headers: struct { content_type: []const u8 = "application/json", + fingerprint: ?[]const u8 = null, } = .{}, } = .{}, diff --git a/src/routes/handler-info.zig b/src/routes/handler-info.zig index bac7612..7b744d2 100644 --- a/src/routes/handler-info.zig +++ b/src/routes/handler-info.zig @@ -2,24 +2,65 @@ const std = @import("std"); const Context = @import("context.zig"); const Storage = @import("../storage/root.zig"); +const Access = @import("access.zig").Access; + const log = std.log.scoped(.handler_info); -handler: *const fn (*Context) anyerror![]const u8, -needs_auth: bool, -method: std.http.Method, +const Self = @This(); + +const Handler = *const fn (*Context) anyerror![]const u8; + +get: ?Handler, +head: ?Handler, +post: ?Handler, +put: ?Handler, +delete: ?Handler, +connect: ?Handler, +options: ?Handler, +trace: ?Handler, +patch: ?Handler, +access: Access, + +inline fn handler_from_method(self: *const Self, method: std.http.Method) ?Handler { + return switch (method) { + .GET => self.get, + .HEAD => self.head, + .POST => self.post, + .PUT => self.put, + .DELETE => self.delete, + .CONNECT => self.connect, + .OPTIONS => self.options, + .TRACE => self.trace, + .PATCH => self.patch, + }; +} + +inline fn get_fingerprint(cookie: []const u8) []const u8 { + const start = std.mem.indexOf(u8, cookie, "fingerprint=") orelse return ""; + const end = std.mem.indexOf(u8, cookie[start + 12..], " ") orelse return cookie[start + 12..]; + return cookie[start..end + 1]; +} pub fn handle( - self: *const @This(), + self: *const Self, request: *std.http.Server.Request, storage: *Storage, allocator: std.mem.Allocator, ) !void { - if (request.head.method != self.method) { - return request.respond( - "{ \"error\": \"Bad Request\" }", - .{ .status = .bad_request } - ); - } + const handler = self.handler_from_method(request.head.method) orelse return request.respond( + "{ \"error\": \"Bad Request\" }", + .{ .status = .bad_request } + ); + + const cookie = cookie: { + var iterator = request.iterateHeaders(); + while (iterator.next()) |header| { + if (std.ascii.eqlIgnoreCase(header.name, "cookie")) { + break :cookie header.value; + } + } + break :cookie ""; + }; var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); @@ -27,9 +68,28 @@ pub fn handle( .request = request, .storage = storage, .allocator = arena.allocator(), + .fingerprint = get_fingerprint(cookie), }; - const response = self.handler(&context) catch |err| { + const allowed = switch (self.access) { + .everyone => true, + .users => storage.sessions.get(storage, context.fingerprint) != null, + .admins => admin: { + if (storage.sessions.get(storage, context.fingerprint)) |session| { + break :admin session.info.is_admin; + } + break :admin false; + }, + }; + + if (!allowed) { + return request.respond( + "{ \"error\": \"Forbidden\" }", + .{ .status = .forbidden } + ); + } + + const response = handler(&context) catch |err| { const response, const status_code: std.http.Status = switch (err) { error.BadRequest => .{ "{ \"error\": \"Bad Request\" }", .bad_request }, error.Unauthorized => .{ "{ \"error\": \"Unauthorized\" }", .unauthorized }, @@ -44,70 +104,91 @@ pub fn handle( return request.respond(response, .{ .status = status_code }); }; + var headers: std.ArrayList(std.http.Header) = .empty; + defer headers.deinit(allocator); + + try headers.append(allocator, .{ + .name = "Content-Type", + .value = context.response.headers.content_type + }); + + if (context.response.headers.fingerprint) |auth_token| { + var value = std.Io.Writer.Allocating.init(arena.allocator()); + + try value.writer.print("fingerprint={s}; Secure; Path=/", .{auth_token}); + + try headers.append(allocator, .{ + .name = "Set-Cookie", + .value = value.written(), + }); + } + try request.respond(response, .{ - .extra_headers = &.{ - .{ - .name = "Content-Type", - .value = context.response.headers.content_type - }, - }, + .extra_headers = headers.items }); } -pub fn from_type(T: type) @This() { - const info = @typeInfo(@TypeOf(T.handler)); +fn HandlerWrapper(T: type, name: []const u8) type { + const info = @typeInfo(@TypeOf(@field(T, name))); const return_type = info.@"fn".return_type orelse void; const payload_type = @typeInfo(return_type).error_union.payload; - const Handler = type: { - break :type struct { - pub fn call(ctx: *Context) anyerror![]const u8 { - const args = args: { - const tuple = std.meta.fields(std.meta.ArgsTuple(@TypeOf(T.handler))); + return struct { + pub fn call(ctx: *Context) anyerror![]const u8 { + const args = args: { + const tuple = std.meta.fields(std.meta.ArgsTuple(@TypeOf(@field(T, name)))); - if (tuple.len == 1) { - break :args .{ ctx }; - } else if (tuple.len == 2) { - const Body = tuple[1].@"type"; + if (tuple.len == 1) { + break :args .{ ctx }; + } else if (tuple.len == 2) { + const Body = tuple[1].@"type"; - var writer = std.Io.Writer.Allocating.init(ctx.allocator); - const interface = &writer.writer; + var writer = std.Io.Writer.Allocating.init(ctx.allocator); + const interface = &writer.writer; - var buffer: [1024]u8 = undefined; - const reader = try ctx.request.readerExpectContinue(&buffer); + var buffer: [1024]u8 = undefined; + const reader = try ctx.request.readerExpectContinue(&buffer); - try reader.streamExact64(interface, ctx.request.head.content_length orelse 0); + try reader.streamExact64(interface, ctx.request.head.content_length orelse 0); - const body = std.json.parseFromSliceLeaky( - Body, - ctx.allocator, - writer.written(), - .{} - ) catch return error.BadRequest; - break :args .{ ctx, body }; - } else { - @compileError("invalid amount of arguments for request function"); - } - }; - - if (payload_type == []const u8) { - return @call(.auto, T.handler, args); - } else if (payload_type == void) { - try @call(.auto, T.handler, args); - return ""; - } else { - var writer = std.Io.Writer.Allocating.init(ctx.allocator); - var stringify = std.json.Stringify { .writer = &writer.writer }; - try stringify.write(try @call(.auto, T.handler, args)); - return writer.written(); - } + const body = std.json.parseFromSliceLeaky( + Body, + ctx.allocator, + writer.written(), + .{} + ) catch return error.BadRequest; + break :args .{ ctx, body }; + } else { + @compileError("invalid amount of arguments for request function"); } }; + + if (payload_type == []const u8) { + return @call(.auto, @field(T, name), args); + } else if (payload_type == void) { + try @call(.auto, @field(T, name), args); + return ""; + } else { + var writer = std.Io.Writer.Allocating.init(ctx.allocator); + var stringify = std.json.Stringify { .writer = &writer.writer }; + try stringify.write(try @call(.auto, @field(T, name), args)); + return writer.written(); + } + } }; +} - return @This() { - .handler = Handler.call, - .needs_auth = T.needs_auth, - .method = T.method, +pub fn from_type(T: type) @This() { + return Self { + .get = if (@hasDecl(T, "get")) HandlerWrapper(T, "get").call else null, + .head = if (@hasDecl(T, "head")) HandlerWrapper(T, "head").call else null, + .post = if (@hasDecl(T, "post")) HandlerWrapper(T, "post").call else null, + .put = if (@hasDecl(T, "put")) HandlerWrapper(T, "put").call else null, + .delete = if (@hasDecl(T, "delete")) HandlerWrapper(T, "delete").call else null, + .connect = if (@hasDecl(T, "connect")) HandlerWrapper(T, "connect").call else null, + .options = if (@hasDecl(T, "options")) HandlerWrapper(T, "options").call else null, + .trace = if (@hasDecl(T, "trace")) HandlerWrapper(T, "trace").call else null, + .patch = if (@hasDecl(T, "patch")) HandlerWrapper(T, "patch").call else null, + .access = T.access, }; } diff --git a/src/routes/root.zig b/src/routes/root.zig index dbfce32..878b9f2 100644 --- a/src/routes/root.zig +++ b/src/routes/root.zig @@ -4,14 +4,47 @@ pub const HandlerInfo = @import("handler-info.zig"); pub const Context = @import("context.zig"); pub const api = @import("api/root.zig"); -pub const fallback: HandlerInfo = .from_type(@import("fallback.zig")); +pub const static: HandlerInfo = .from_type(@import("static.zig")); -pub const handlers = std.StaticStringMap(HandlerInfo).initComptime(.{ - .{ "", fallback }, - .{ "/api/auth/login", api.auth.login }, - .{ "/api/auth/first-login", api.auth.first_login }, -}); +const routes = (Routes {}) + .with("", static) + .with_module("/api", api); + +pub const handlers = std.StaticStringMap(HandlerInfo).initComptime(routes.items); pub fn get(path: []const u8) HandlerInfo { return (handlers.getLongestPrefix(std.mem.trimEnd(u8, path, "/")) orelse unreachable).value; } + +const Routes = struct { + const Self = @This(); + + const Route = struct{ []const u8, HandlerInfo }; + + items: []const Route = &[0]Route{}, + + pub fn with(self: Self, comptime route: []const u8, handler: HandlerInfo) Self { + var next = self; + const tail: []const Route = &[1]Route { .{ route, handler } }; + next.items = self.items ++ tail; + return next; + } + + pub fn with_module(self: Self, comptime prefix: []const u8, module: type) Self { + var next = self; + + if (@typeInfo(module) != .@"struct") return self; + + inline for (@typeInfo(module).@"struct".decls) |decl| { + const field = @field(module, decl.name); + const route = prefix ++ "/" ++ decl.name; + switch (@TypeOf(field)) { + type => next = next.with_module(route, field), + HandlerInfo => next = next.with(route, field), + else => {}, + } + } + + return next; + } +}; diff --git a/src/routes/fallback.zig b/src/routes/static.zig index 25e1927..8719cc9 100644 --- a/src/routes/fallback.zig +++ b/src/routes/static.zig @@ -4,10 +4,9 @@ const Context = @import("context.zig"); const log = std.log.scoped(.fallback); -pub const needs_auth = false; -pub const method = .GET; +pub const access = .everyone; -pub fn handler(ctx: *Context) anyerror![]const u8 { +pub fn get(ctx: *Context) anyerror![]const u8 { var static = try std.fs.cwd().openDir("static", .{}); defer static.close(); diff --git a/src/storage/root.zig b/src/storage/root.zig index bf9803e..f1a753c 100644 --- a/src/storage/root.zig +++ b/src/storage/root.zig @@ -3,10 +3,14 @@ const config = @import("../config.zig"); const prompt = @import("../prompt.zig"); pub const User = @import("user.zig"); +pub const SessionManager = @import("session-manager/root.zig"); +pub const Session = SessionManager.Session; const Self = @This(); dir: std.fs.Dir, +sessions: SessionManager = .empty, +allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator) !Self { const dir = std.fs.cwd().openDir(config.storage_path, .{}) catch blk: { @@ -14,7 +18,10 @@ pub fn init(allocator: std.mem.Allocator) !Self { break :blk try std.fs.cwd().openDir(config.storage_path, .{}); }; - var self = Self { .dir = dir }; + var self = Self { + .dir = dir, + .allocator = allocator, + }; dir.access("user", .{}) catch |err| switch (err) { error.FileNotFound => { diff --git a/src/storage/session-manager/root.zig b/src/storage/session-manager/root.zig new file mode 100644 index 0000000..7f44bf0 --- /dev/null +++ b/src/storage/session-manager/root.zig @@ -0,0 +1,59 @@ +const std = @import("std"); +const config = @import("../../config.zig"); +const Storage = @import("../root.zig"); + +pub const Session = @import("session.zig"); + +const Self = @This(); + +pub const empty: Self = .{ + .cache = .empty, +}; + +cache: std.StringHashMapUnmanaged(Session), + +pub fn add(self: *Self, storage: *Storage, info: Storage.User.Info) !*Session { + const session = try Session.init(storage.allocator, info); + errdefer session.deinit(storage.allocator); + try self.cache.put(storage.allocator, session.fingerprint, session); + return self.cache.getPtr(session.fingerprint) orelse unreachable; +} + +pub fn renew(self: *Self, storage: *Storage, fingerprint: []const u8) !*Session { + if (self.cache.get(fingerprint)) |s| { + var session = s; + try session.reset(); + + try self.cache.put(storage.allocator, session.fingerprint, session); + + _ = self.cache.remove(fingerprint); + + return self.cache.getPtr(session.fingerprint) orelse unreachable; + } + return error.SessionNotFound; +} + +pub fn remove(self: *Self, storage: *Storage, fingerprint: []const u8) void { + if (self.cache.getPtr(fingerprint)) |session| { + session.deinit(storage.allocator); + _ = self.cache.remove(fingerprint); + } +} + +pub fn get(self: *Self, storage: *Storage, fingerprint: []const u8) ?*Session { + if (self.cache.getPtr(fingerprint)) |session| { + const now = std.time.Instant.now() catch return null; + const since = now.since(session.age); + + if (since > config.session_expires_after) { + std.debug.print("here\n", .{}); + session.deinit(storage.allocator); + _ = self.cache.remove(fingerprint); + return null; + } + + return session; + } + + return null; +} diff --git a/src/storage/session-manager/session.zig b/src/storage/session-manager/session.zig new file mode 100644 index 0000000..943334b --- /dev/null +++ b/src/storage/session-manager/session.zig @@ -0,0 +1,34 @@ +const std = @import("std"); +const User = @import("../user.zig"); + +const Self = @This(); + +const fingerprint_size = 512; + +age: std.time.Instant, +info: User.Info, +fingerprint: []u8, + +pub fn init(allocator: std.mem.Allocator, info: User.Info) !Self { + var self: Self = undefined; + self.info = try info.clone(allocator); + self.fingerprint = try allocator.alloc(u8, fingerprint_size); + try self.reset(); + + return self; +} + +pub fn reset(self: *Self) !void { + var raw_buffer: [fingerprint_size / 2]u8 = undefined; + std.crypto.random.bytes(&raw_buffer); + + var writer = std.Io.Writer.fixed(self.fingerprint); + writer.print("{x}", .{raw_buffer}) catch unreachable; + + self.age = try std.time.Instant.now(); +} + +pub fn deinit(self: *const Self, allocator: std.mem.Allocator) void { + self.info.deinit(allocator); + allocator.free(self.fingerprint); +} diff --git a/src/storage/user.zig b/src/storage/user.zig index c981661..4170fd1 100644 --- a/src/storage/user.zig +++ b/src/storage/user.zig @@ -3,16 +3,44 @@ const Storage = @import("root.zig"); const Self = @This(); -pub const UserInfo = struct { +pub const Info = struct { name: []const u8, full_name: []const u8, birthday: []const u8, hash: []const u8, is_admin: bool, + + pub fn clone(self: *const @This(), allocator: std.mem.Allocator) !@This() { + var clone_self: @This() = undefined; + var i: usize = 0; + + inline for (std.meta.fields(@This())) |field_info| { + if (field_info.type == []const u8) { + @field(clone_self, field_info.name) = try allocator.dupe( + u8, + @field(self, field_info.name), + ); + errdefer allocator.free(@field(clone_self, field_info.name)); + } else { + @field(clone_self, field_info.name) = @field(self, field_info.name); + } + i += 1; + } + + return clone_self; + } + + pub fn deinit(self: *const @This(), allocator: std.mem.Allocator) void { + inline for (std.meta.fields(@This())) |field_info| { + if (field_info.type == []const u8) { + allocator.free(@field(self, field_info.name)); + } + } + } }; dir: std.fs.Dir, -info: UserInfo, +info: Info, arena: std.heap.ArenaAllocator, pub fn open( @@ -34,7 +62,7 @@ pub fn open( defer file.close(); const content = try file.readToEndAlloc(allocator, std.math.maxInt(usize)); - const info = try std.json.parseFromSliceLeaky(UserInfo, allocator, content, .{}); + const info = try std.json.parseFromSliceLeaky(Info, allocator, content, .{}); return .{ .dir = dir, @@ -79,7 +107,7 @@ pub fn new( hash_buf ); - const info: UserInfo = .{ + const info: Info = .{ .name = name, .full_name = full_name, .birthday = birthday, |