diff --git a/README.md b/README.md index ab64f0b..e3144b5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ Here's what works: - **[cookies](examples/cookies/cookies.zig)**: a simple example sending itself a cookie and responding with a session cookie. - **[websockets](examples/websockets/)**: a simple websockets chat for the browser. +- **[Username/Password Session Authentication](./examples/userpass_session_auth/)**: + A convenience authenticator that redirects un-authenticated requests to a + login page and sends cookies containing session tokens based on + username/password pairs transmitted via POST request. I'll continue wrapping more of facil.io's functionality and adding stuff to zap diff --git a/build.zig b/build.zig index 7ae9697..9d30810 100644 --- a/build.zig +++ b/build.zig @@ -53,6 +53,7 @@ pub fn build(b: *std.build.Builder) !void { .{ .name = "http_params", .src = "examples/http_params/http_params.zig" }, .{ .name = "cookies", .src = "examples/cookies/cookies.zig" }, .{ .name = "websockets", .src = "examples/websockets/websockets.zig" }, + .{ .name = "userpass_session", .src = "examples/userpass_session_auth/userpass_session_auth.zig" }, }) |excfg| { const ex_name = excfg.name; const ex_src = excfg.src; diff --git a/build.zig.zon b/build.zig.zon index 66b6566..240abfe 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = "zap", - .version = "0.0.16", + .version = "0.0.17", .dependencies = .{ .@"facil.io" = .{ diff --git a/examples/userpass_session_auth/html/Ziggy_the_Ziguana.svg.png b/examples/userpass_session_auth/html/Ziggy_the_Ziguana.svg.png new file mode 100644 index 0000000..a823b72 Binary files /dev/null and b/examples/userpass_session_auth/html/Ziggy_the_Ziguana.svg.png differ diff --git a/examples/userpass_session_auth/html/login.html b/examples/userpass_session_auth/html/login.html new file mode 100644 index 0000000..7bc6b6a --- /dev/null +++ b/examples/userpass_session_auth/html/login.html @@ -0,0 +1,90 @@ + +
+ + + + + + + diff --git a/examples/userpass_session_auth/userpass_session_auth.zig b/examples/userpass_session_auth/userpass_session_auth.zig new file mode 100644 index 0000000..8520252 --- /dev/null +++ b/examples/userpass_session_auth/userpass_session_auth.zig @@ -0,0 +1,147 @@ +const std = @import("std"); +const zap = @import("zap"); + +const Lookup = std.StringHashMap([]const u8); +const auth_lock_token_table = false; +const auth_lock_pw_table = false; + +// see the source for more info +const Authenticator = zap.UserPassSessionAuth( + Lookup, + auth_lock_pw_table, // we may set this to true if we expect our username -> password map to change + auth_lock_token_table, // we may set this to true to have session tokens deleted server-side on logout +); + +const loginpath = "/login"; +const loginpage = @embedFile("html/login.html"); +const img = @embedFile("./html/Ziggy_the_Ziguana.svg.png"); + +// global vars yeah! +var authenticator: Authenticator = undefined; + +// the login page (embedded) +fn on_login(r: zap.SimpleRequest) void { + r.sendBody(loginpage) catch return; +} + +// the "normal page" +fn on_normal_page(r: zap.SimpleRequest) void { + zap.debug("on_normal_page()\n", .{}); + r.sendBody( + \\ + \\You are logged in!!!> + \\
You are logged out!!!
+ \\ + ) catch return; +} + +fn on_request(r: zap.SimpleRequest) void { + switch (authenticator.authenticateRequest(&r)) { + .Handled => { + // the authenticator handled the entire request for us. probably + // a redirect to the login page + std.log.info("Authenticator handled it", .{}); + return; + }, + .AuthFailed => unreachable, + .AuthOK => { + // the authenticator says it is ok to proceed as usual + std.log.info("Auth OK", .{}); + // dispatch to target path + if (r.path) |p| { + // used in the login page + // note: our login page is /login + // so, anything that starts with /login will not be touched by + // the authenticator. Hence, we name the img /login/Ziggy....png + if (std.mem.startsWith(u8, p, "/login/Ziggy_the_Ziguana.svg.png")) { + std.log.info("Auth OK for img", .{}); + r.setContentTypeFromPath() catch unreachable; + r.sendBody(img) catch unreachable; + return; + } + + // aha! got redirected to /login + if (std.mem.startsWith(u8, p, loginpath)) { + std.log.info(" + for /login --> login page", .{}); + return on_login(r); + } + + // /logout can be shown since we're still authenticated for this + // very request + if (std.mem.startsWith(u8, p, "/logout")) { + std.log.info(" + for /logout --> logout page", .{}); + return on_logout(r); + } + + // any other paths will still show the normal page + std.log.info(" + --> normal page", .{}); + return on_normal_page(r); + } + // if there is no path, we're still authenticated, so let's show + // the user something + return on_normal_page(r); + }, + } +} + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{ + .thread_safe = true, + }){}; + var allocator = gpa.allocator(); + + var listener = zap.SimpleHttpListener.init(.{ + .port = 3000, + .on_request = on_request, + .log = true, + .max_clients = 100000, + }); + try listener.listen(); + + zap.enableDebugLog(); + + // add a single user to our allowed users + var userpass = Lookup.init(allocator); + try userpass.put("zap", "awesome"); + + // init our auth + authenticator = try Authenticator.init( + allocator, + &userpass, + .{ + .usernameParam = "username", + .passwordParam = "password", + .loginPage = loginpath, + .cookieName = "zap-session", + }, + ); + + // just some debug output: listing the session tokens the authenticator may + // have generated already (if auth_lock_token_table == false). + const lookup = authenticator.sessionTokens; + std.debug.print("\nauth token list len: {d}\n", .{lookup.count()}); + var it = lookup.iterator(); + while (it.next()) |item| { + std.debug.print(" {s}\n", .{item.key_ptr.*}); + } + + std.debug.print("Visit me on http://127.0.0.1:3000\n", .{}); + + // start worker threads + zap.start(.{ + .threads = 2, + .workers = 2, + }); +} diff --git a/src/http_auth.zig b/src/http_auth.zig index be96f21..e918bef 100644 --- a/src/http_auth.zig +++ b/src/http_auth.zig @@ -300,3 +300,308 @@ pub fn BearerAuthMulti(comptime Lookup: type) type { } }; } + +pub const UserPassSessionAuthArgs = struct { + /// username body parameter + usernameParam: []const u8, + /// password body parameter + passwordParam: []const u8, + /// redirect to this page if auth fails + loginPage: []const u8, + /// name of the cookie + cookieName: []const u8, + /// cookie max age in seconds; 0 -> session cookie + cookieMaxAge: u8 = 0, + /// redirect status code, defaults to 302 found + redirectCode: zap.StatusCode = .found, +}; + +/// UserPassSessionAuth supports the following use case: +/// +/// - checks every request: is it going to the login page? -> let the request through. +/// - else: +/// - checks every request for a session token in a cookie +/// - if there is no token, it checks for correct username and password body params +/// - if username and password are present and correct, it will create a session token, +/// create a response cookie containing the token, and carry on with the request +/// - else it will redirect to the login page +/// - if the session token is present and correct: it will let the request through +/// - else: it will redirect to the login page +/// +/// Please note the implications of this simple approach: IF YOU REUSE "username" +/// and "password" body params for anything else in your application, then the +/// mechanisms described above will kick in. For that reason: please know what you're +/// doing. +/// +/// See UserPassSessionAuthArgs: +/// - username & password param names can be defined by you +/// - session cookie name and max-age can be defined by you +/// - login page and redirect code (.302) can be defined by you +/// +/// Comptime Parameters: +/// +/// - `Lookup` must implement .get([]const u8) -> []const u8 for user password retrieval +/// - `lockedPwLookups` : if true, accessing the provided Lookup instance will be protected +/// by a Mutex. You can access the mutex yourself via the `passwordLookupLock`. +/// - `lockedTokenLookups` : if true, accessing the internal token table will be protected +/// by a Mutex. You can access the mutex yourself via the `passwordLookupLock`. +/// +/// Note: In order to be quick, you can set lockedTokenLookups to false. +/// -> we generate it on init() and leave it static +/// -> there is no way to 100% log out apart from re-starting the server +/// -> because: we send a cookie to the browser that invalidates the session cookie +/// -> another browser program with the page still open would still be able to use +/// -> the session. Which is kindof OK, but not as cool as erasing the token +/// -> on the server side which immediately block all other browsers as well. +pub fn UserPassSessionAuth(comptime Lookup: type, comptime lockedPwLookups: bool, comptime lockedTokenLookups: bool) type { + return struct { + allocator: std.mem.Allocator, + lookup: *Lookup, + settings: UserPassSessionAuthArgs, + + // TODO: cookie store per user + sessionTokens: SessionTokenMap, + passwordLookupLock: std.Thread.Mutex = .{}, + tokenLookupLock: std.Thread.Mutex = .{}, + + const Self = @This(); + const SessionTokenMap = std.StringHashMap(void); + const Hash = std.crypto.hash.sha2.Sha256; + + const Token = [Hash.digest_length * 2]u8; + + pub fn init( + allocator: std.mem.Allocator, + lookup: *Lookup, + args: UserPassSessionAuthArgs, + ) !Self { + var ret: Self = .{ + .allocator = allocator, + .settings = .{ + .usernameParam = try allocator.dupe(u8, args.usernameParam), + .passwordParam = try allocator.dupe(u8, args.passwordParam), + .loginPage = try allocator.dupe(u8, args.loginPage), + .cookieName = try allocator.dupe(u8, args.cookieName), + .cookieMaxAge = args.cookieMaxAge, + .redirectCode = args.redirectCode, + }, + .lookup = lookup, + .sessionTokens = SessionTokenMap.init(allocator), + }; + + if (lockedTokenLookups == false) { + // we populate on init and forbid logout() + var it = lookup.iterator(); + while (it.next()) |kv| { + // we iterate over all usernames and passwords, create tokens, + // and memorize the tokens + _ = try ret.createAndStoreSessionToken(kv.key_ptr.*, kv.value_ptr.*); + } + } + return ret; + } + + /// Check for session token cookie, remove the token from the valid tokens + /// Note: only valid if lockedTokenLookups == true + pub fn logout(self: *Self, r: *const zap.SimpleRequest) void { + if (lockedTokenLookups == false) { + if (r.setCookie(.{ + .name = self.settings.cookieName, + .value = "invalid", + .max_age_s = self.settings.cookieMaxAge, + })) { + zap.debug("logout ok\n", .{}); + } else |err| { + zap.debug("logout cookie setting failed: {any}\n", .{err}); + } + // @compileLog("WARNING! If lockedTokenLookups==false, logout() cannot erase the token from its internal, server-side list of valid tokens"); + return; + } else { + zap.debug("logout cookie setting failed\n", .{}); + } + + // if we are allowed to lock the table, we can erase the list of valid tokens server-side + if (r.setCookie(.{ + .name = self.settings.cookieName, + .value = "invalid", + .max_age_s = self.settings.cookieMaxAge, + })) { + zap.debug("logout ok\n", .{}); + } else |err| { + zap.debug("logout cookie setting failed: {any}\n", .{err}); + } + + r.parseCookies(); + + // check for session cookie + if (r.getCookieStr(self.settings.cookieName, self.allocator, false)) |maybe_cookie| { + if (maybe_cookie) |cookie| { + defer cookie.deinit(); + self.tokenLookupLock.lock(); + defer self.tokenLookupLock.unlock(); + // if cookie is a valid session, remove it! + _ = self.sessionTokens.remove(cookie); + } + } else |err| { + zap.debug("unreachable: UserPassSessionAuth.logout: {any}", .{err}); + } + } + + pub fn deinit(self: *const Self) void { + self.allocator.free(self.settings.usernameParam); + self.allocator.free(self.settings.passwordParam); + self.allocator.free(self.settings.loginPage); + self.allocator.free(self.settings.cookieName); + } + + fn _internal_authenticateRequest(self: *Self, r: *const zap.SimpleRequest) AuthResult { + // if we're requesting the login page, let the request through + if (r.path) |p| { + if (std.mem.startsWith(u8, p, self.settings.loginPage)) { + return .AuthOK; + } + } + + // parse body + r.parseBody() catch { + // zap.debug("warning: parseBody() failed in UserPassSessionAuth: {any}", .{err}); + // this is not an error in case of e.g. gets with querystrings + }; + + r.parseCookies(false); + + // check for session cookie + if (r.getCookieStr(self.settings.cookieName, self.allocator, false)) |maybe_cookie| { + if (maybe_cookie) |cookie| { + defer cookie.deinit(); + // locked or unlocked token lookup + if (lockedTokenLookups) { + self.tokenLookupLock.lock(); + defer self.tokenLookupLock.unlock(); + if (self.sessionTokens.contains(cookie.str)) { + // cookie is a valid session! + zap.debug("Auth: COKIE IS OK!!!!: {s}\n", .{cookie.str}); + return .AuthOK; + } else { + zap.debug("Auth: COKIE IS BAD!!!!: {s}\n", .{cookie.str}); + } + } else { + if (self.sessionTokens.contains(cookie.str)) { + // cookie is a valid session! + zap.debug("Auth: COKIE IS OK!!!!: {s}\n", .{cookie.str}); + return .AuthOK; + } else { + zap.debug("Auth: COKIE IS BAD!!!!: {s}\n", .{cookie.str}); + } + } + } + } else |err| { + zap.debug("unreachable: could not check for cookie in UserPassSessionAuth: {any}", .{err}); + } + + // get params of username and password + if (r.getParamStr(self.settings.usernameParam, self.allocator, false)) |maybe_username| { + if (maybe_username) |*username| { + defer username.deinit(); + if (r.getParamStr(self.settings.passwordParam, self.allocator, false)) |maybe_pw| { + if (maybe_pw) |*pw| { + defer pw.deinit(); + + // now check + const correct_pw_optional = brk: { + if (lockedPwLookups) { + self.passwordLookupLock.lock(); + defer self.passwordLookupLock.unlock(); + break :brk self.lookup.*.get(username.str); + } else { + break :brk self.lookup.*.get(username.str); + } + }; + if (correct_pw_optional) |correct_pw| { + if (std.mem.eql(u8, pw.str, correct_pw)) { + // create session token + if (self.createAndStoreSessionToken(username.str, pw.str)) |token| { + // now set the cookie header + if (r.setCookie(.{ + .name = self.settings.cookieName, + .value = token, + .max_age_s = self.settings.cookieMaxAge, + })) { + return .AuthOK; + } else |err| { + zap.debug("could not set session token: {any}", .{err}); + } + } else |err| { + zap.debug("could not create session token: {any}", .{err}); + } + // errors with token don't mean the auth itself wasn't OK + return .AuthOK; + } + } + } + } else |err| { + zap.debug("getParamSt() for password failed in UserPassSessionAuth: {any}", .{err}); + return .AuthFailed; + } + } + } else |err| { + zap.debug("getParamSt() for user failed in UserPassSessionAuth: {any}", .{err}); + return .AuthFailed; + } + return .AuthFailed; + } + + pub fn authenticateRequest(self: *Self, r: *const zap.SimpleRequest) AuthResult { + switch (self._internal_authenticateRequest(r)) { + .AuthOK => { + // username and pass are ok -> created token, set header, caller can continue + return .AuthOK; + }, + // this does not happen, just for completeness + .Handled => return .Handled, + // auth failed -> redirect + .AuthFailed => { + // we need to redirect and return .Handled + self.redirect(r) catch |err| { + // we just give up + zap.debug("redirect() failed in UserPassSessionAuth: {any}", .{err}); + }; + return .Handled; + }, + } + } + + fn redirect(self: *Self, r: *const zap.SimpleRequest) !void { + try r.redirectTo(self.settings.loginPage, self.settings.redirectCode); + } + + fn createSessionToken(self: *Self, username: []const u8, password: []const u8) ![]const u8 { + var hasher = Hash.init(.{}); + hasher.update(username); + hasher.update(password); + var digest: [Hash.digest_length]u8 = undefined; + hasher.final(&digest); + const token: Token = std.fmt.bytesToHex(digest, .lower); + const token_str = try self.allocator.dupe(u8, token[0..token.len]); + return token_str; + } + + fn createAndStoreSessionToken(self: *Self, username: []const u8, password: []const u8) ![]const u8 { + const token = try self.createSessionToken(username, password); + // put locked or not + if (lockedTokenLookups) { + self.tokenLookupLock.lock(); + defer self.tokenLookupLock.unlock(); + + if (!self.sessionTokens.contains(token)) { + try self.sessionTokens.put(token, {}); + } + } else { + if (!self.sessionTokens.contains(token)) { + try self.sessionTokens.put(token, {}); + } + } + return token; + } + }; +} diff --git a/src/zap.zig b/src/zap.zig index 06b8ac6..d43b4c3 100644 --- a/src/zap.zig +++ b/src/zap.zig @@ -97,6 +97,13 @@ pub const SimpleRequest = struct { return self.setHeader("content-type", s); } + // redirect to path with status code 302 by default + pub fn redirectTo(self: *const Self, path: []const u8, code: ?_module.StatusCode) HttpError!void { + self.setStatus(if (code) |status| status else .found); + try self.setHeader("Location", path); + try self.sendBody("moved"); + } + /// shows how to use the logger pub fn setContentTypeWithLogger( self: *const Self, diff --git a/targets.txt b/targets.txt index 5263529..6958208 100644 --- a/targets.txt +++ b/targets.txt @@ -7,4 +7,9 @@ endpoint wrk mustache endpoint_auth +http_params pkghash +cookies +websockets +userpass_session +