feat: add generateTokenHex and Session
This commit is contained in:
1 parent
20f56efa1a
commit
3292bc54a5
1 file changed
+213
+213
@@ -0,0 +1,213 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
const authmod = @import("auth.zig");
|
||||||
|
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
/// Random session tokens with expiration, independent of any one TCP
|
||||||
|
/// connection - the layer that replaces "authenticated for as long as this
|
||||||
|
/// socket stays open" (server.zig's `connRole`) with "authenticated until
|
||||||
|
/// this token expires or is revoked". A client that authenticates once,
|
||||||
|
/// gets a token back, and disconnects can reconnect later and resume the
|
||||||
|
/// same role by presenting the token (`auth_token` opcode in protocol.zig)
|
||||||
|
/// instead of the password again - and the token stops working on its own
|
||||||
|
/// after its TTL elapses even if the same connection stays open the whole
|
||||||
|
/// time, unlike plain password auth which never expires mid-connection.
|
||||||
|
pub const tokenBytes = 24;
|
||||||
|
pub const tokenHexLen = tokenBytes * 2;
|
||||||
|
|
||||||
|
fn generateTokenHex() [tokenHexLen]u8 {
|
||||||
|
var raw: [tokenBytes]u8 = undefined;
|
||||||
|
std.crypto.random.bytes(&raw);
|
||||||
|
return std.fmt.bytesToHex(raw, .lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const Session = struct {
|
||||||
|
role: authmod.Role,
|
||||||
|
expiresAtNanos: i128,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// In-memory only (never persisted to Db - a session is a login artifact,
|
||||||
|
/// not application data): thread-safe via its own mutex since `server.zig`
|
||||||
|
/// dispatches requests from one worker thread per connection and any of
|
||||||
|
/// them may issue, validate, or revoke a token concurrently.
|
||||||
|
pub const SessionStore = struct {
|
||||||
|
allocator: Allocator,
|
||||||
|
mutex: std.Thread.Mutex = .{},
|
||||||
|
sessions: std.StringHashMapUnmanaged(Session) = .{},
|
||||||
|
|
||||||
|
pub fn init(allocator: Allocator) SessionStore {
|
||||||
|
return .{ .allocator = allocator };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *SessionStore) void {
|
||||||
|
var it = self.sessions.iterator();
|
||||||
|
while (it.next()) |entry| self.allocator.free(entry.key_ptr.*);
|
||||||
|
self.sessions.deinit(self.allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issues a fresh random token bound to `role`, valid until
|
||||||
|
/// `nowNanos + ttlNanos`. Caller owns the returned slice.
|
||||||
|
pub fn issue(self: *SessionStore, role: authmod.Role, ttlNanos: i128, nowNanos: i128) ![]u8 {
|
||||||
|
const hex = generateTokenHex();
|
||||||
|
const keyOwned = try self.allocator.dupe(u8, &hex);
|
||||||
|
errdefer self.allocator.free(keyOwned);
|
||||||
|
const returned = try self.allocator.dupe(u8, &hex);
|
||||||
|
errdefer self.allocator.free(returned);
|
||||||
|
|
||||||
|
self.mutex.lock();
|
||||||
|
defer self.mutex.unlock();
|
||||||
|
try self.sessions.put(self.allocator, keyOwned, .{ .role = role, .expiresAtNanos = nowNanos + ttlNanos });
|
||||||
|
return returned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the session bound to `token` (role and expiry) if it exists
|
||||||
|
/// and has not expired as of `nowNanos`. An expired session is deleted
|
||||||
|
/// right here (lazy cleanup) rather than left around for
|
||||||
|
/// `sweepExpired` to eventually find - a token that has expired must
|
||||||
|
/// never again validate as live, so the deletion happens on the same
|
||||||
|
/// lookup that discovers it. Returning the expiry (not just the role)
|
||||||
|
/// lets a caller such as `server.zig` re-check the same session later
|
||||||
|
/// against a fresh clock without a second store lookup.
|
||||||
|
pub fn validate(self: *SessionStore, token: []const u8, nowNanos: i128) ?Session {
|
||||||
|
self.mutex.lock();
|
||||||
|
defer self.mutex.unlock();
|
||||||
|
const entry = self.sessions.getEntry(token) orelse return null;
|
||||||
|
if (entry.value_ptr.expiresAtNanos <= nowNanos) {
|
||||||
|
const key = entry.key_ptr.*;
|
||||||
|
_ = self.sessions.remove(token);
|
||||||
|
self.allocator.free(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.value_ptr.*;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit logout: removes a token before it would naturally expire.
|
||||||
|
/// Returns whether a session was actually present.
|
||||||
|
pub fn revoke(self: *SessionStore, token: []const u8) bool {
|
||||||
|
self.mutex.lock();
|
||||||
|
defer self.mutex.unlock();
|
||||||
|
const kv = self.sessions.fetchRemove(token) orelse return false;
|
||||||
|
self.allocator.free(kv.key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every session expired as of `nowNanos`, returning how many
|
||||||
|
/// were dropped. `validate` already deletes an expired session the
|
||||||
|
/// moment someone tries to use it, but a token nobody ever presents
|
||||||
|
/// again would otherwise sit in memory forever - this is the
|
||||||
|
/// housekeeping sweep a long-lived server can run periodically.
|
||||||
|
pub fn sweepExpired(self: *SessionStore, nowNanos: i128) usize {
|
||||||
|
self.mutex.lock();
|
||||||
|
defer self.mutex.unlock();
|
||||||
|
|
||||||
|
var doomed = std.ArrayList([]const u8).init(self.allocator);
|
||||||
|
defer doomed.deinit();
|
||||||
|
var it = self.sessions.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
if (entry.value_ptr.expiresAtNanos <= nowNanos) {
|
||||||
|
doomed.append(entry.key_ptr.*) catch continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (doomed.items) |key| {
|
||||||
|
_ = self.sessions.remove(key);
|
||||||
|
self.allocator.free(key);
|
||||||
|
}
|
||||||
|
return doomed.items.len;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(self: *SessionStore) usize {
|
||||||
|
self.mutex.lock();
|
||||||
|
defer self.mutex.unlock();
|
||||||
|
return self.sessions.count();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test "issue then validate returns the bound role before expiry" {
|
||||||
|
var store = SessionStore.init(std.testing.allocator);
|
||||||
|
defer store.deinit();
|
||||||
|
|
||||||
|
const token = try store.issue(.readwrite, 1_000_000_000, 0);
|
||||||
|
defer std.testing.allocator.free(token);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, tokenHexLen), token.len);
|
||||||
|
try std.testing.expectEqual(authmod.Role.readwrite, store.validate(token, 500_000_000).?.role);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), store.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "validate returns null once past expiry and removes the session" {
|
||||||
|
var store = SessionStore.init(std.testing.allocator);
|
||||||
|
defer store.deinit();
|
||||||
|
|
||||||
|
const token = try store.issue(.admin, 1_000, 0);
|
||||||
|
defer std.testing.allocator.free(token);
|
||||||
|
|
||||||
|
try std.testing.expect(store.validate(token, 1_000) == null);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), store.count());
|
||||||
|
// A second lookup of the same (already-removed) token still misses cleanly.
|
||||||
|
try std.testing.expect(store.validate(token, 1_000) == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "unknown token never validates" {
|
||||||
|
var store = SessionStore.init(std.testing.allocator);
|
||||||
|
defer store.deinit();
|
||||||
|
try std.testing.expect(store.validate("does-not-exist", 0) == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "revoke removes a session before its natural expiry" {
|
||||||
|
var store = SessionStore.init(std.testing.allocator);
|
||||||
|
defer store.deinit();
|
||||||
|
|
||||||
|
const token = try store.issue(.readonly, 1_000_000_000, 0);
|
||||||
|
defer std.testing.allocator.free(token);
|
||||||
|
|
||||||
|
try std.testing.expect(store.revoke(token));
|
||||||
|
try std.testing.expect(store.validate(token, 0) == null);
|
||||||
|
// Revoking an already-gone token is a clean no-op, not an error.
|
||||||
|
try std.testing.expect(!store.revoke(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "two tokens for different roles are independent" {
|
||||||
|
var store = SessionStore.init(std.testing.allocator);
|
||||||
|
defer store.deinit();
|
||||||
|
|
||||||
|
const a = try store.issue(.readonly, 1_000_000_000, 0);
|
||||||
|
defer std.testing.allocator.free(a);
|
||||||
|
const b = try store.issue(.admin, 1_000_000_000, 0);
|
||||||
|
defer std.testing.allocator.free(b);
|
||||||
|
|
||||||
|
try std.testing.expect(!std.mem.eql(u8, a, b));
|
||||||
|
try std.testing.expectEqual(authmod.Role.readonly, store.validate(a, 0).?.role);
|
||||||
|
try std.testing.expectEqual(authmod.Role.admin, store.validate(b, 0).?.role);
|
||||||
|
|
||||||
|
try std.testing.expect(store.revoke(a));
|
||||||
|
try std.testing.expect(store.validate(a, 0) == null);
|
||||||
|
// Revoking `a` must never touch `b`'s still-live session.
|
||||||
|
try std.testing.expectEqual(authmod.Role.admin, store.validate(b, 0).?.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "sweepExpired drops only expired sessions and reports the count" {
|
||||||
|
var store = SessionStore.init(std.testing.allocator);
|
||||||
|
defer store.deinit();
|
||||||
|
|
||||||
|
const stale1 = try store.issue(.readonly, 100, 0);
|
||||||
|
defer std.testing.allocator.free(stale1);
|
||||||
|
const stale2 = try store.issue(.readwrite, 100, 0);
|
||||||
|
defer std.testing.allocator.free(stale2);
|
||||||
|
const fresh = try store.issue(.admin, 1_000_000_000, 0);
|
||||||
|
defer std.testing.allocator.free(fresh);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), store.count());
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), store.sweepExpired(200));
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), store.count());
|
||||||
|
try std.testing.expectEqual(authmod.Role.admin, store.validate(fresh, 200).?.role);
|
||||||
|
|
||||||
|
// A second sweep at the same time has nothing left to remove.
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), store.sweepExpired(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "generateTokenHex produces distinct, fully hex-encoded tokens" {
|
||||||
|
const a = generateTokenHex();
|
||||||
|
const b = generateTokenHex();
|
||||||
|
try std.testing.expect(!std.mem.eql(u8, &a, &b));
|
||||||
|
for (a) |c| try std.testing.expect(std.ascii.isHex(c));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user