1
0
Fork 0
mirror of https://github.com/zigzap/zap.git synced 2025-10-20 15:14:08 +00:00
zap/src/http_auth.zig

614 lines
26 KiB
Zig

const std = @import("std");
const zap = @import("zap.zig");
/// Authentication Scheme enum: Basic or Bearer.
pub const AuthScheme = enum {
Basic,
Bearer,
pub fn str(self: AuthScheme) []const u8 {
return switch (self) {
.Basic => "Basic ",
.Bearer => "Bearer ",
};
}
pub fn headerFieldStrFio(self: AuthScheme) []const u8 {
return switch (self) {
.Basic => "authentication",
.Bearer => "authorization",
};
}
pub fn headerFieldStrHeader(self: AuthScheme) [:0]const u8 {
return switch (self) {
.Basic => "Authentication",
.Bearer => "Authorization",
};
}
};
/// Used internally: check for presence of the requested auth header.
pub fn checkAuthHeader(scheme: AuthScheme, auth_header: []const u8) bool {
return switch (scheme) {
.Basic => |b| std.mem.startsWith(u8, auth_header, b.str()) and auth_header.len > b.str().len,
.Bearer => |b| std.mem.startsWith(u8, auth_header, b.str()) and auth_header.len > b.str().len,
};
}
/// Used internally: return the requested auth header.
pub fn extractAuthHeader(scheme: AuthScheme, r: *const zap.Request) ?[]const u8 {
return switch (scheme) {
.Basic => |b| r.getHeader(b.headerFieldStrFio()),
.Bearer => |b| r.getHeader(b.headerFieldStrFio()),
};
}
/// Decoding Strategy for Basic Authentication
const BasicAuthStrategy = enum {
/// decode into user and pass, then check pass
UserPass,
/// just look up the encoded user:pass token
Token68,
};
/// Authentication result
pub const AuthResult = enum {
/// authentication / authorization was successful
AuthOK,
/// authentication / authorization failed
AuthFailed,
/// The authenticator handled the request that didn't pass authentication /
/// authorization.
/// This is used to implement authenticators that redirect to a login
/// page. An Authenticating endpoint will not do the default, which is
/// trying to call the `unauthorized` callback. `unauthorized()` must be
/// implemented in the endpoint.
Handled,
};
/// HTTP Basic Authentication RFC 7617.
/// "Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
/// user-pass strings: "$username:$password" -> base64
///
/// Notes:
/// - we only look at the Authentication header
/// - we ignore the required realm parameter
/// - we ignore the optional charset parameter
///
/// Errors:
/// WWW-Authenticate: Basic realm="this"
///
/// Lookup : any kind of map that implements get([]const u8) -> []const u8
pub fn Basic(comptime Lookup: type, comptime kind: BasicAuthStrategy) type {
return struct {
allocator: std.mem.Allocator,
realm: ?[]const u8,
lookup: *Lookup,
const BasicAuth = @This();
/// Creates a BasicAuth. `lookup` must implement `.get([]const u8) -> []const u8`
/// different implementations can
/// - either decode, lookup and compare passwords
/// - or just check for existence of the base64-encoded user:pass combination
/// if realm is provided (not null), a copy of it is taken -> call deinit() to clean up
pub fn init(allocator: std.mem.Allocator, lookup: *Lookup, realm: ?[]const u8) !BasicAuth {
return .{
.allocator = allocator,
.lookup = lookup,
.realm = if (realm) |the_realm| try allocator.dupe(u8, the_realm) else null,
};
}
/// Deinit the authenticator.
pub fn deinit(self: *BasicAuth) void {
if (self.realm) |the_realm| {
self.allocator.free(the_realm);
}
}
/// Use this to decode the auth_header into user:pass, lookup pass in lookup.
/// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
pub fn authenticateUserPass(self: *BasicAuth, auth_header: []const u8) AuthResult {
zap.debug("AuthenticateUserPass\n", .{});
const encoded = auth_header[AuthScheme.Basic.str().len..];
const decoder = std.base64.standard.Decoder;
var buffer: [0x100]u8 = undefined;
if (decoder.calcSizeForSlice(encoded)) |decoded_size| {
if (decoded_size >= buffer.len) {
zap.debug(
"ERROR: UserPassAuth: decoded_size {d} >= buffer.len {d}\n",
.{ decoded_size, buffer.len },
);
return .AuthFailed;
}
const decoded = buffer[0..decoded_size];
decoder.decode(decoded, encoded) catch |err| {
zap.debug(
"ERROR: UserPassAuth: unable to decode `{s}`: {any}\n",
.{ encoded, err },
);
return .AuthFailed;
};
// we have decoded
// we can split
var it = std.mem.splitScalar(u8, decoded, ':');
const user = it.next();
const pass = it.next();
if (user == null or pass == null) {
zap.debug(
"ERROR: UserPassAuth: user {any} or pass {any} is null\n",
.{ user, pass },
);
return .AuthFailed;
}
// now, do the lookup
const actual_pw = self.lookup.*.get(user.?);
if (actual_pw) |pw| {
const ret = std.mem.eql(u8, pass.?, pw);
zap.debug(
"INFO: UserPassAuth for user `{s}`: `{s}` == pass `{s}` = {}\n",
.{ user.?, pw, pass.?, ret },
);
return if (ret) .AuthOK else .AuthFailed;
} else {
zap.debug(
"ERROR: UserPassAuth: user `{s}` not found in map of size {d}!\n",
.{ user.?, self.lookup.*.count() },
);
return .AuthFailed;
}
} else |err| {
// can't calc slice size --> fallthrough to return false
zap.debug(
"ERROR: UserPassAuth: cannot calc slize size for encoded `{s}`: {any} \n",
.{ encoded, err },
);
return .AuthFailed;
}
zap.debug("UNREACHABLE\n", .{});
return .AuthFailed;
}
/// Use this to just look up if the base64-encoded auth_header exists in lookup.
/// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
pub fn authenticateToken68(self: *BasicAuth, auth_header: []const u8) AuthResult {
const token = auth_header[AuthScheme.Basic.str().len..];
return if (self.lookup.*.contains(token)) .AuthOK else .AuthFailed;
}
/// dispatch based on kind (.UserPass / .Token689) and try to authenticate based on the header.
/// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
pub fn authenticate(self: *BasicAuth, auth_header: []const u8) AuthResult {
zap.debug("AUTHENTICATE\n", .{});
switch (kind) {
.UserPass => return self.authenticateUserPass(auth_header),
.Token68 => return self.authenticateToken68(auth_header),
}
}
/// The zap authentication request handler.
///
/// Tries to extract the authentication header and perform the authentication.
/// If no authentication header is found, an authorization header is tried.
pub fn authenticateRequest(self: *BasicAuth, r: *const zap.Request) AuthResult {
zap.debug("AUTHENTICATE REQUEST\n", .{});
if (extractAuthHeader(.Basic, r)) |auth_header| {
zap.debug("Authentication Header found!\n", .{});
return self.authenticate(auth_header);
} else {
// try with .Authorization
if (extractAuthHeader(.Bearer, r)) |auth_header| {
zap.debug("Authorization Header found!\n", .{});
return self.authenticate(auth_header);
}
}
zap.debug("NO fitting Auth Header found!\n", .{});
return .AuthFailed;
}
};
}
/// HTTP bearer authentication for a single token
/// RFC 6750
/// "Authentication: Bearer TOKEN"
/// `Bearer` is case-sensitive
/// - we don't support form-encoded `access_token` body parameter
/// - we don't support URI query parameter `access_token`
///
/// Errors:
/// HTTP/1.1 401 Unauthorized
/// WWW-Authenticate: Bearer realm="example", error="invalid_token", error_description="..."
pub const BearerSingle = struct {
allocator: std.mem.Allocator,
token: []const u8,
realm: ?[]const u8,
/// Creates a Single-Token Bearer Authenticator.
/// Takes a copy of the token.
/// If realm is provided (not null), a copy is taken call deinit() to clean up.
pub fn init(allocator: std.mem.Allocator, token: []const u8, realm: ?[]const u8) !BearerSingle {
return .{
.allocator = allocator,
.token = try allocator.dupe(u8, token),
.realm = if (realm) |the_realm| try allocator.dupe(u8, the_realm) else null,
};
}
/// Try to authenticate based on the header.
/// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
pub fn authenticate(self: *BearerSingle, auth_header: []const u8) AuthResult {
if (checkAuthHeader(.Bearer, auth_header) == false) {
return .AuthFailed;
}
const token = auth_header[AuthScheme.Bearer.str().len..];
return if (std.mem.eql(u8, token, self.token)) .AuthOK else .AuthFailed;
}
/// The zap authentication request handler.
///
/// Tries to extract the authentication header and perform the authentication.
pub fn authenticateRequest(self: *BearerSingle, r: *const zap.Request) AuthResult {
if (extractAuthHeader(.Bearer, r)) |auth_header| {
return self.authenticate(auth_header);
}
return .AuthFailed;
}
/// Deinits the authenticator.
pub fn deinit(self: *BearerSingle) void {
if (self.realm) |the_realm| {
self.allocator.free(the_realm);
}
self.allocator.free(self.token);
}
};
/// HTTP bearer authentication for multiple tokens
/// RFC 6750
/// "Authentication: Bearer TOKEN"
/// `Bearer` is case-sensitive
/// - we don't support form-encoded `access_token` body parameter
/// - we don't support URI query parameter `access_token`
///
/// Errors:
/// HTTP/1.1 401 Unauthorized
/// WWW-Authenticate: Bearer realm="example", error="invalid_token", error_description="..."
pub fn BearerMulti(comptime Lookup: type) type {
return struct {
allocator: std.mem.Allocator,
lookup: *Lookup,
realm: ?[]const u8,
const BearerMultiAuth = @This();
/// Creates a Multi Token Bearer Authenticator. `lookup` must implement
/// `.get([]const u8) -> []const u8` to look up tokens.
/// If realm is provided (not null), a copy of it is taken -> call deinit() to clean up.
pub fn init(allocator: std.mem.Allocator, lookup: *Lookup, realm: ?[]const u8) !BearerMultiAuth {
return .{
.allocator = allocator,
.lookup = lookup,
.realm = if (realm) |the_realm| try allocator.dupe(u8, the_realm) else null,
};
}
/// Deinit the authenticator. Only required if a realm was provided at
/// init() time.
pub fn deinit(self: *BearerMultiAuth) void {
if (self.realm) |the_realm| {
self.allocator.free(the_realm);
}
}
/// Try to authenticate based on the header.
/// Note: usually, you don't want to use this; you'd go for `authenticateRequest()`.
pub fn authenticate(self: *BearerMultiAuth, auth_header: []const u8) AuthResult {
if (checkAuthHeader(.Bearer, auth_header) == false) {
return .AuthFailed;
}
const token = auth_header[AuthScheme.Bearer.str().len..];
return if (self.lookup.*.contains(token)) .AuthOK else .AuthFailed;
}
/// The zap authentication request handler.
///
/// Tries to extract the authentication header and perform the authentication.
pub fn authenticateRequest(self: *BearerMultiAuth, r: *const zap.Request) AuthResult {
if (extractAuthHeader(.Bearer, r)) |auth_header| {
return self.authenticate(auth_header);
}
return .AuthFailed;
}
};
}
/// Settings to initialize a UserPassSession authenticator.
pub const UserPassSessionArgs = 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 auth cookie
cookieName: []const u8,
/// cookie max age in seconds; 0 -> session cookie
cookieMaxAge: u8 = 0,
/// redirect status code, defaults to 302 found
redirectCode: zap.http.StatusCode = .found,
};
/// UserPassSession 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 still kick in. For that reason: please know what
/// you're doing.
///
/// See UserPassSessionArgs:
/// - 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`.
///
/// 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 UserPassSession(comptime Lookup: type, comptime lockedPwLookups: bool) type {
return struct {
allocator: std.mem.Allocator,
lookup: *Lookup,
settings: UserPassSessionArgs,
// TODO: cookie store per user?
sessionTokens: SessionTokenMap,
passwordLookupLock: std.Thread.Mutex = .{},
tokenLookupLock: std.Thread.Mutex = .{},
const UserPassSessionAuth = @This();
const SessionTokenMap = std.StringHashMap(void);
const Hash = std.crypto.hash.sha2.Sha256;
const Token = [Hash.digest_length * 2]u8;
/// Construct this authenticator. See above and related types for more
/// information.
pub fn init(
allocator: std.mem.Allocator,
lookup: *Lookup,
args: UserPassSessionArgs,
) !UserPassSessionAuth {
const ret: UserPassSessionAuth = .{
.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),
};
return ret;
}
/// De-init this authenticator.
pub fn deinit(self: *UserPassSessionAuth) 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);
// clean up the session tokens: the key strings are duped
var key_it = self.sessionTokens.keyIterator();
while (key_it.next()) |key_ptr| {
self.allocator.free(key_ptr.*);
}
self.sessionTokens.deinit();
}
/// Check for session token cookie, remove the token from the valid tokens
pub fn logout(self: *UserPassSessionAuth, r: *const zap.Request) void {
// we erase the list of valid tokens server-side (later) and set the
// cookie to "invalid" on the client side.
if (r.setCookie(.{
.name = self.settings.cookieName,
.value = "invalid",
.max_age_s = -1,
})) {
zap.debug("logout ok", .{});
} else |err| {
zap.debug("logout cookie setting failed: {any}", .{err});
}
r.parseCookies(false);
// check for session cookie
if (r.getCookieStr(self.allocator, self.settings.cookieName)) |maybe_cookie| {
if (maybe_cookie) |cookie| {
defer self.allocator.free(cookie);
self.tokenLookupLock.lock();
defer self.tokenLookupLock.unlock();
if (self.sessionTokens.getKeyPtr(cookie)) |keyPtr| {
const keySlice = keyPtr.*;
// if cookie is a valid session, remove it!
_ = self.sessionTokens.remove(cookie);
// only now can we let go of the cookie str slice that
// was used as the key
self.allocator.free(keySlice);
}
}
} else |err| {
zap.debug("unreachable: UserPassSession.logout: {any}", .{err});
}
}
fn _internal_authenticateRequest(self: *UserPassSessionAuth, r: *const zap.Request) 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 UserPassSession: {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.allocator, self.settings.cookieName)) |maybe_cookie| {
if (maybe_cookie) |cookie| {
defer self.allocator.free(cookie);
// locked or unlocked token lookup
self.tokenLookupLock.lock();
defer self.tokenLookupLock.unlock();
if (self.sessionTokens.contains(cookie)) {
// cookie is a valid session!
zap.debug("Auth: COOKIE IS OK!!!!: {s}\n", .{cookie});
return .AuthOK;
} else {
zap.debug("Auth: COOKIE IS BAD!!!!: {s}\n", .{cookie});
// this is not necessarily a bad thing. it could be a
// stale cookie from a previous session. So let's check
// if username and password are being sent and correct.
}
}
} else |err| {
zap.debug("unreachable: could not check for cookie in UserPassSession: {any}", .{err});
}
// get params of username and password
if (r.getParamStr(self.allocator, self.settings.usernameParam)) |maybe_username| {
if (maybe_username) |username| {
defer self.allocator.free(username);
if (r.getParamStr(self.allocator, self.settings.passwordParam)) |maybe_pw| {
if (maybe_pw) |pw| {
defer self.allocator.free(pw);
// now check
const correct_pw_optional = brk: {
if (lockedPwLookups) {
self.passwordLookupLock.lock();
defer self.passwordLookupLock.unlock();
break :brk self.lookup.*.get(username);
} else {
break :brk self.lookup.*.get(username);
}
};
if (correct_pw_optional) |correct_pw| {
if (std.mem.eql(u8, pw, correct_pw)) {
// create session token
if (self.createAndStoreSessionToken(username, pw)) |token| {
defer self.allocator.free(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("getParamStr() for password failed in UserPassSession: {any}", .{err});
return .AuthFailed;
}
}
} else |err| {
zap.debug("getParamStr() for user failed in UserPassSession: {any}", .{err});
return .AuthFailed;
}
return .AuthFailed;
}
/// The zap authentication request handler.
///
/// See above for how it works.
pub fn authenticateRequest(self: *UserPassSessionAuth, r: *const zap.Request) 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 UserPassSession: {any}", .{err});
};
return .Handled;
},
}
}
fn redirect(self: *UserPassSessionAuth, r: *const zap.Request) !void {
try r.redirectTo(self.settings.loginPage, self.settings.redirectCode);
}
fn createSessionToken(self: *UserPassSessionAuth, username: []const u8, password: []const u8) ![]const u8 {
var hasher = Hash.init(.{});
hasher.update(username);
hasher.update(password);
var buf: [16]u8 = undefined;
const time_nano = std.time.nanoTimestamp();
const timestampHex = try std.fmt.bufPrint(&buf, "{0x}", .{time_nano});
hasher.update(timestampHex);
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: *UserPassSessionAuth, username: []const u8, password: []const u8) ![]const u8 {
const token = try self.createSessionToken(username, password);
self.tokenLookupLock.lock();
defer self.tokenLookupLock.unlock();
if (!self.sessionTokens.contains(token)) {
try self.sessionTokens.put(try self.allocator.dupe(u8, token), {});
}
return token;
}
};
}