Menu
Public repository / master
AkurAI-Framework / crates/cli/src/auth.rs
Latest change 30ab30f1996c3474188ec3dd71ba00efc5e3eb8b - feat(auth): email-OTP login flow + user roles by Ólafur Búi Ólafsson
//! Cookie-based session authentication on the embedded B+tree.
//!
//! This is the framework's reference auth feature: a durable session store, a
//! tiny users table, a plain-HTML login form, a session-cookie middleware
//! helper, a protected page, and logout. It mirrors the guestbook form-actions
//! demo (validate → store → 303 Post/Redirect/Get) and the record store
//! (`Arc<Mutex<BTree>>`, durable on commit), adding only what auth needs: a
//! `Cookie:` request-header reader and a `Set-Cookie:` response-header writer.
//!
//! ## Security caveats (read these — this is demo-grade, not production)
//!
//! * **PRNG:** session ids are derived from `std` only — `SystemTime` nanos
//! mixed with a per-process atomic counter and the pid, run through a
//! SplitMix64 diffuser. This is a framework-demo CSPRNG-lite. It is *not* a
//! cryptographically secure RNG; do not rely on it to resist a determined
//! attacker guessing session ids.
//! * **Password hash:** a salted, stretched SHA-1 (reusing the WebSocket SHA-1).
//! It is salted and iterated, but SHA-1 is fast and not memory-hard — this is
//! **not bcrypt/argon2** and must not be presented as such.
//! * **Transport:** the cookie is `HttpOnly`, `Path=/`, `SameSite=Lax`. It is
//! not marked `Secure` here because TLS terminates at the edge (nginx); the
//! HTTPS layer there is what actually protects the cookie in transit.
//!
//! ## Email-OTP login (additive — password login is unchanged)
//!
//! Alongside the password form, an app can offer passwordless email-OTP login.
//! A request mints a 6-digit numeric code, stores a *salted hash* of it (never
//! the code itself) in `data/otp.db` keyed by the normalized email, with a
//! 10-minute TTL and a 5-attempt cap. Verification checks the code, expiry, and
//! attempt cap; on success it creates an ordinary session (reusing the password
//! login's session mechanism) and clears the OTP.
//!
//! Sending the code is a **pluggable seam**: [`OtpDeliver`] is *given* the
//! `(email, code)` pair and owns delivery. The framework ships no outbound-email
//! client (it has no TLS stack), so the default [`LogDeliver`] logs and records
//! the code — fully testable offline. A real sender (e.g. a local SMTP sidecar)
//! implements [`OtpDeliver`] and is injected via [`State::open_with_deliver`].
//!
//! ## Roles
//!
//! Each user record carries a [`Role`] (`user` or `admin`). [`State::current_user`]
//! exposes it on [`CurrentUser`]; [`State::current_role`] and
//! [`State::require_role`] gate admin-only routes (admin satisfies any required
//! role). Records written before roles existed (no `role` field) read back as
//! [`Role::User`], so old stores stay valid and the existing demo/password user
//! keeps working unchanged.
use crate::cmd_serve::{build_engine, error_page, load_context};
use akurai_json::Value;
use akurai_storage::BTree;
use akurai_ws::sha1::sha1;
use std::io;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use akurai_http::{form, Method, Reply, Request, Response};
/// The seeded demo user so the login flow can be exercised out of the box.
pub const DEMO_USERNAME: &str = "demo";
/// The seeded demo user's password. Demo-grade; documented, not a secret.
pub const DEMO_PASSWORD: &str = "akurai";
/// The session cookie name.
const COOKIE_NAME: &str = "session";
/// How long a minted OTP stays valid, in seconds (10 minutes).
const OTP_TTL_SECS: i64 = 600;
/// How many wrong guesses an OTP tolerates before it is locked out.
const OTP_MAX_ATTEMPTS: i64 = 5;
/// The number of decimal digits in a generated OTP code.
const OTP_CODE_DIGITS: usize = 6;
// ---------------------------------------------------------------------------
// Roles
// ---------------------------------------------------------------------------
/// A user's authorization role. `User` is the default; `Admin` gates privileged
/// routes. The hierarchy is flat with one rule: an admin satisfies any required
/// role (see [`State::require_role`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// An ordinary authenticated user.
User,
/// An administrator. Satisfies every role check.
Admin,
}
impl Role {
/// The wire/storage string for this role (`"user"` / `"admin"`).
pub fn as_str(self) -> &'static str {
match self {
Role::User => "user",
Role::Admin => "admin",
}
}
/// Parse a stored role string. Anything other than `"admin"` (including a
/// missing field) is the safe default, [`Role::User`].
pub fn parse(s: &str) -> Role {
match s {
"admin" => Role::Admin,
_ => Role::User,
}
}
}
/// The signed-in user behind a request: their name (username or email) and role.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurrentUser {
/// The account identifier — a username for password users, an email for
/// OTP accounts.
pub username: String,
/// The account's authorization role.
pub role: Role,
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// Auth handler state: durable session, user, and OTP stores, plus the pluggable
/// OTP delivery seam. Every store is an `Arc`, and the deliver is an `Arc<dyn>`,
/// so the whole struct clones freely into the server's request closure.
#[derive(Clone)]
pub struct State {
sessions: SessionStore,
users: UserStore,
otp: OtpStore,
deliver: Arc<dyn OtpDeliver>,
}
impl State {
/// Open (or create) the auth stores under `data_dir` with the default
/// [`LogDeliver`] (logs/records the OTP — no real email is sent). Seeds the
/// demo user (as an admin, so admin gating is demoable) if absent.
pub fn open(data_dir: &Path) -> io::Result<State> {
State::open_with_deliver(data_dir, Arc::new(LogDeliver::new()))
}
/// Like [`State::open`], but with an app-supplied OTP delivery backend. This
/// is the seam a real sender (e.g. a local SMTP sidecar) plugs into:
/// implement [`OtpDeliver`] and pass it here.
pub fn open_with_deliver(data_dir: &Path, deliver: Arc<dyn OtpDeliver>) -> io::Result<State> {
let sessions = SessionStore::open(data_dir.join("sessions.db"))?;
let users = UserStore::open(data_dir.join("users.db"))?;
let otp = OtpStore::open(data_dir.join("otp.db"))?;
// The demo/password user is seeded as an admin so the role-gated parts
// of the demo work out of the box. Idempotent: an existing record (incl.
// a pre-roles one) is left untouched.
users.ensure_user(DEMO_USERNAME, DEMO_PASSWORD, Role::Admin)?;
Ok(State {
sessions,
users,
otp,
deliver,
})
}
/// The [`CurrentUser`] named by a request's session cookie, if it maps to a
/// live session. This is the session middleware/helper: read `Cookie`, look
/// up the session, resolve the account's role, yield the user (or `None`).
pub fn current_user(&self, req: &Request) -> Option<CurrentUser> {
let cookie = req.header("Cookie")?;
let id = parse_cookie(cookie, COOKIE_NAME)?;
let session = self.sessions.lookup(&id).ok().flatten()?;
let role = self.users.role_of(&session.username).unwrap_or(Role::User);
Some(CurrentUser {
username: session.username,
role,
})
}
/// The role of the request's signed-in user, or `None` if unauthenticated.
pub fn current_role(&self, req: &Request) -> Option<Role> {
self.current_user(req).map(|u| u.role)
}
/// Whether the request's user satisfies `role`. An admin satisfies any
/// required role; an unauthenticated request never does.
pub fn require_role(&self, req: &Request, role: Role) -> bool {
match self.current_role(req) {
Some(Role::Admin) => true,
Some(actual) => actual == role,
None => false,
}
}
/// Mint and deliver a fresh OTP for `email` through the configured backend.
pub fn request_otp(&self, email: &str) -> io::Result<()> {
self.otp.request(email, self.deliver.as_ref())
}
/// Verify a submitted OTP `code` for `email`. On [`OtpOutcome::Verified`] the
/// stored OTP is cleared; the caller is responsible for creating the session.
pub fn verify_otp(&self, email: &str, code: &str) -> io::Result<OtpOutcome> {
self.otp.verify(email, code)
}
/// Ensure a passwordless account exists for `email` (created on first OTP
/// login). Such a record carries only a role — no password hash — so it can
/// never be used for password login. Idempotent.
pub fn ensure_account(&self, email: &str) -> io::Result<()> {
self.users
.ensure_account(&normalize_email(email), Role::User)
}
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// Route `GET`/`POST /login`.
pub fn login(state: &State, root: &Path, req: &Request) -> Reply {
// Already signed in? Skip the form and go to the protected page.
if state.current_user(req).is_some() {
return Reply::Response(redirect("/account"));
}
let response = match req.method {
Method::Post => login_submit(state, root, req),
_ => render_login(root, "", ""),
};
Reply::Response(response)
}
/// Validate credentials; on success create a session, set the cookie, and
/// 303-redirect to the protected page. On failure re-render with an error.
fn login_submit(state: &State, root: &Path, req: &Request) -> Response {
let pairs = form::parse_urlencoded(&req.body_str());
let username = form::field(&pairs, "username")
.unwrap_or("")
.trim()
.to_string();
let password = form::field(&pairs, "password").unwrap_or("").to_string();
let ok = state.users.verify(&username, &password).unwrap_or(false);
if !ok {
return render_login(root, "Invalid username or password.", &username);
}
let id = match state.sessions.create(&username) {
Ok(id) => id,
Err(e) => {
return Response::new(500)
.with_html(&error_page(&format!("could not start session: {e}")))
}
};
redirect("/account").with_header("Set-Cookie", &set_cookie(&id))
}
/// Route `GET`/`POST /login/otp`: the passwordless email-OTP flow. A `GET` (or a
/// `POST` with no `code`) is the *request* step — it mints and delivers a code;
/// a `POST` carrying a `code` is the *verify* step — on success it creates a
/// session and redirects to the protected page.
pub fn otp(state: &State, root: &Path, req: &Request) -> Reply {
if state.current_user(req).is_some() {
return Reply::Response(redirect("/account"));
}
let response = match req.method {
Method::Post => otp_submit(state, root, req),
_ => render_otp(root, "", "", false),
};
Reply::Response(response)
}
/// Drive the two-step OTP form. Empty `code` ⇒ request a code; non-empty `code`
/// ⇒ verify it and, on success, sign the user in.
fn otp_submit(state: &State, root: &Path, req: &Request) -> Response {
let pairs = form::parse_urlencoded(&req.body_str());
let email = normalize_email(form::field(&pairs, "email").unwrap_or(""));
let code = form::field(&pairs, "code").unwrap_or("").trim().to_string();
if email.is_empty() {
return render_otp(root, "Enter your email address.", "", false);
}
// Request step: no code submitted yet — mint and deliver one.
if code.is_empty() {
if let Err(e) = state.request_otp(&email) {
return Response::new(500).with_html(&error_page(&format!("could not send code: {e}")));
}
return render_otp(root, "", &email, true);
}
// Verify step.
match state.verify_otp(&email, &code) {
Ok(OtpOutcome::Verified) => {
if let Err(e) = state.ensure_account(&email) {
return Response::new(500)
.with_html(&error_page(&format!("could not create account: {e}")));
}
match state.sessions.create(&email) {
Ok(id) => redirect("/account").with_header("Set-Cookie", &set_cookie(&id)),
Err(e) => Response::new(500)
.with_html(&error_page(&format!("could not start session: {e}"))),
}
}
Ok(OtpOutcome::Expired) => {
render_otp(root, "That code expired. Request a new one.", &email, false)
}
Ok(OtpOutcome::TooManyAttempts) => render_otp(
root,
"Too many attempts. Request a new code.",
&email,
false,
),
Ok(OtpOutcome::NoCode) => render_otp(
root,
"No code outstanding. Request one first.",
&email,
false,
),
Ok(OtpOutcome::WrongCode) => render_otp(root, "Incorrect code. Try again.", &email, true),
Err(e) => Response::new(500).with_html(&error_page(&format!("verify failed: {e}"))),
}
}
/// Render the protected page, or redirect to `/login` when unauthenticated.
pub fn account(state: &State, root: &Path, req: &Request) -> Reply {
let Some(user) = state.current_user(req) else {
return Reply::Response(redirect("/login"));
};
let is_admin = state.require_role(req, Role::Admin);
let engine = match build_engine(root) {
Ok(engine) => engine,
Err(msg) => return Reply::Response(Response::new(500).with_html(&error_page(&msg))),
};
let mut context = load_context(root);
if let Value::Object(pairs) = &mut context {
pairs.push(("username".into(), Value::Str(user.username)));
pairs.push(("role".into(), Value::Str(user.role.as_str().to_string())));
pairs.push(("is_admin".into(), bool_flag(is_admin)));
}
let response = match engine.render("account", &context) {
Ok(html) => Response::ok().with_html(&html),
Err(e) => Response::new(500).with_html(&error_page(&e.message)),
};
Reply::Response(response)
}
/// Clear the session and expire the cookie, then redirect to `/login`.
pub fn logout(state: &State, req: &Request) -> Reply {
if let Some(cookie) = req.header("Cookie") {
if let Some(id) = parse_cookie(cookie, COOKIE_NAME) {
let _ = state.sessions.delete(&id);
}
}
Reply::Response(redirect("/login").with_header("Set-Cookie", &expired_cookie()))
}
/// Render the login template with an optional error and the username the user
/// already typed (so a failed attempt does not clear the field).
fn render_login(root: &Path, error: &str, username: &str) -> Response {
let engine = match build_engine(root) {
Ok(engine) => engine,
Err(msg) => return Response::new(500).with_html(&error_page(&msg)),
};
let mut context = load_context(root);
if let Value::Object(pairs) = &mut context {
pairs.push(("error".into(), Value::Str(error.to_string())));
pairs.push(("form_username".into(), Value::Str(username.to_string())));
}
match engine.render("login", &context) {
Ok(html) => Response::ok().with_html(&html),
Err(e) => Response::new(500).with_html(&error_page(&e.message)),
}
}
/// Render the OTP template. `sent` flips the form from "enter your email" to
/// "enter the code we sent"; `error` shows an alert; `email` keeps the field
/// populated across steps.
fn render_otp(root: &Path, error: &str, email: &str, sent: bool) -> Response {
let engine = match build_engine(root) {
Ok(engine) => engine,
Err(msg) => return Response::new(500).with_html(&error_page(&msg)),
};
let mut context = load_context(root);
if let Value::Object(pairs) = &mut context {
pairs.push(("error".into(), Value::Str(error.to_string())));
pairs.push(("form_email".into(), Value::Str(email.to_string())));
pairs.push(("sent".into(), bool_flag(sent)));
}
match engine.render("otp", &context) {
Ok(html) => Response::ok().with_html(&html),
Err(e) => Response::new(500).with_html(&error_page(&e.message)),
}
}
/// A 303 See Other redirect (Post/Redirect/Get) to `location`.
fn redirect(location: &str) -> Response {
Response::new(303).with_header("Location", location)
}
/// A template-truthy flag: a non-empty string when `true`, empty when `false`.
/// The template engine treats the empty string as falsy (the same convention the
/// login template uses for `{% if error %}`), so `{% if sent %}` works.
fn bool_flag(on: bool) -> Value {
Value::Str(if on { "1" } else { "" }.to_string())
}
/// Normalize an email for use as a stable storage key: trimmed and lowercased.
fn normalize_email(email: &str) -> String {
email.trim().to_ascii_lowercase()
}
// ---------------------------------------------------------------------------
// Cookie helpers
// ---------------------------------------------------------------------------
/// Read a single cookie value out of a `Cookie:` request header. The header is
/// a `; `-separated list of `name=value` pairs; returns the first match for
/// `name`, or `None`.
pub fn parse_cookie(header: &str, name: &str) -> Option<String> {
header.split(';').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
if k.trim() == name {
Some(v.trim().to_string())
} else {
None
}
})
}
/// The `Set-Cookie` value that installs a session: `HttpOnly` (no JS access),
/// `Path=/` (whole site), `SameSite=Lax` (sent on top-level navigations only).
/// Not `Secure` — TLS terminates at the edge (see the module caveats).
pub fn set_cookie(id: &str) -> String {
format!("{COOKIE_NAME}={id}; HttpOnly; Path=/; SameSite=Lax")
}
/// The `Set-Cookie` value that expires the session cookie (empty value,
/// `Max-Age=0`), telling the browser to drop it immediately.
pub fn expired_cookie() -> String {
format!("{COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0")
}
// ---------------------------------------------------------------------------
// Session store
// ---------------------------------------------------------------------------
/// A live session: who it belongs to and when it started (unix seconds).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub username: String,
pub created: i64,
}
/// A durable, clonable session store keyed by opaque session id.
#[derive(Clone)]
struct SessionStore {
tree: Arc<Mutex<BTree>>,
}
impl SessionStore {
fn open(path: impl AsRef<Path>) -> io::Result<SessionStore> {
Ok(SessionStore {
tree: Arc::new(Mutex::new(BTree::open(path)?)),
})
}
/// Create a session for `username`, returning its new opaque id. Durable on
/// return (it commits).
fn create(&self, username: &str) -> io::Result<String> {
let id = random_id();
let created = now_secs();
let record = Value::Object(vec![
("username".into(), Value::Str(username.to_string())),
("created".into(), Value::Int(created)),
]);
let mut tree = self.tree.lock().unwrap();
tree.insert(id.as_bytes(), record.to_json().as_bytes())?;
tree.commit()?;
Ok(id)
}
/// Look up a session by id, or `None` if there is no such session.
fn lookup(&self, id: &str) -> io::Result<Option<Session>> {
let mut tree = self.tree.lock().unwrap();
let Some(bytes) = tree.get(id.as_bytes())? else {
return Ok(None);
};
Ok(decode_session(&bytes))
}
/// Delete a session by id. Durable on return. Returns whether it existed.
fn delete(&self, id: &str) -> io::Result<bool> {
let mut tree = self.tree.lock().unwrap();
let removed = tree.delete(id.as_bytes())?;
tree.commit()?;
Ok(removed)
}
}
/// Decode a stored session JSON blob into a [`Session`], or `None` if malformed.
fn decode_session(bytes: &[u8]) -> Option<Session> {
let text = std::str::from_utf8(bytes).ok()?;
let value = akurai_json::parse(text).ok()?;
let username = value.get("username")?.as_str()?.to_string();
let created = match value.get("created") {
Some(Value::Int(n)) => *n,
_ => 0,
};
Some(Session { username, created })
}
// ---------------------------------------------------------------------------
// User store
// ---------------------------------------------------------------------------
/// A durable, clonable users table keyed by username (or email for passwordless
/// accounts). A password user stores a salt + salted hash + role; a passwordless
/// OTP account stores only a role (no hash ⇒ password login can never succeed).
#[derive(Clone)]
struct UserStore {
tree: Arc<Mutex<BTree>>,
}
impl UserStore {
fn open(path: impl AsRef<Path>) -> io::Result<UserStore> {
Ok(UserStore {
tree: Arc::new(Mutex::new(BTree::open(path)?)),
})
}
/// Create `username` with `password` and `role` if it does not already exist.
/// Idempotent — an existing user is left untouched (so the demo password is
/// not reset, and a pre-roles record is not clobbered, on every boot).
fn ensure_user(&self, username: &str, password: &str, role: Role) -> io::Result<()> {
let mut tree = self.tree.lock().unwrap();
if tree.get(username.as_bytes())?.is_some() {
return Ok(());
}
let salt = random_id();
let record = Value::Object(vec![
("salt".into(), Value::Str(salt.clone())),
("hash".into(), Value::Str(hash_password(&salt, password))),
("role".into(), Value::Str(role.as_str().to_string())),
]);
tree.insert(username.as_bytes(), record.to_json().as_bytes())?;
tree.commit()?;
Ok(())
}
/// Create a passwordless account (a role-only record) for `email` if absent.
/// Idempotent. Used when an OTP login succeeds for a new email.
fn ensure_account(&self, email: &str, role: Role) -> io::Result<()> {
let mut tree = self.tree.lock().unwrap();
if tree.get(email.as_bytes())?.is_some() {
return Ok(());
}
let record = Value::Object(vec![("role".into(), Value::Str(role.as_str().to_string()))]);
tree.insert(email.as_bytes(), record.to_json().as_bytes())?;
tree.commit()?;
Ok(())
}
/// Verify a `username`/`password` pair in constant-ish time against the
/// stored salted hash. A missing user, or a passwordless account (no hash),
/// returns `Ok(false)`.
fn verify(&self, username: &str, password: &str) -> io::Result<bool> {
let mut tree = self.tree.lock().unwrap();
let Some(bytes) = tree.get(username.as_bytes())? else {
return Ok(false);
};
let Some((salt, hash)) = decode_user(&bytes) else {
return Ok(false);
};
Ok(constant_time_eq(&hash_password(&salt, password), &hash))
}
/// The role of `username`, or `None` if there is no such record. A record
/// with no `role` field (written before roles existed) reads as
/// [`Role::User`].
fn role_of(&self, username: &str) -> Option<Role> {
let mut tree = self.tree.lock().unwrap();
let bytes = tree.get(username.as_bytes()).ok()??;
let text = std::str::from_utf8(&bytes).ok()?;
let value = akurai_json::parse(text).ok()?;
let role = value
.get("role")
.and_then(Value::as_str)
.map(Role::parse)
.unwrap_or(Role::User);
Some(role)
}
}
/// Decode a stored user JSON blob into `(salt, hash)`, or `None` if absent or
/// malformed (e.g. a passwordless account that has no hash).
fn decode_user(bytes: &[u8]) -> Option<(String, String)> {
let text = std::str::from_utf8(bytes).ok()?;
let value = akurai_json::parse(text).ok()?;
let salt = value.get("salt")?.as_str()?.to_string();
let hash = value.get("hash")?.as_str()?.to_string();
Some((salt, hash))
}
// ---------------------------------------------------------------------------
// OTP store (email one-time codes)
// ---------------------------------------------------------------------------
/// The result of verifying a submitted OTP code.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OtpOutcome {
/// The code matched, was unexpired, and within the attempt cap. The stored
/// OTP has been cleared.
Verified,
/// The code did not match; an attempt was consumed.
WrongCode,
/// The stored code's TTL had elapsed; it has been cleared.
Expired,
/// The attempt cap was hit; the code is locked out until re-requested.
TooManyAttempts,
/// There was no outstanding code for this email.
NoCode,
}
/// The pluggable OTP delivery seam. An implementation is *given* the email and
/// the freshly minted code and is responsible for getting it to the user
/// (email, SMS, a sidecar, …). The framework intentionally ships no outbound
/// transport — wire a real one via [`State::open_with_deliver`].
pub trait OtpDeliver: Send + Sync {
/// Deliver `code` to `email`. Returning `Err` fails the request step.
fn deliver(&self, email: &str, code: &str) -> io::Result<()>;
}
/// The default [`OtpDeliver`]: it logs the code to stdout and records every
/// delivery in memory so the flow is fully testable offline. **Never** use this
/// in production — the code is not actually sent anywhere private.
#[derive(Clone, Default)]
pub struct LogDeliver {
delivered: Arc<Mutex<Vec<(String, String)>>>,
}
impl LogDeliver {
/// A fresh logging deliverer with an empty record.
pub fn new() -> LogDeliver {
LogDeliver {
delivered: Arc::new(Mutex::new(Vec::new())),
}
}
/// The most recent code delivered to `email`, if any. Handy for tests and
/// local development; not used by the framework itself.
#[allow(dead_code)]
pub fn last_code(&self, email: &str) -> Option<String> {
let email = normalize_email(email);
let log = self.delivered.lock().unwrap();
log.iter()
.rev()
.find(|(e, _)| *e == email)
.map(|(_, c)| c.clone())
}
}
impl OtpDeliver for LogDeliver {
fn deliver(&self, email: &str, code: &str) -> io::Result<()> {
println!(" [otp] code for {email}: {code} (LogDeliver — wire a real sender via State::open_with_deliver)");
self.delivered
.lock()
.unwrap()
.push((email.to_string(), code.to_string()));
Ok(())
}
}
/// A durable, clonable store of outstanding OTP codes keyed by normalized email.
#[derive(Clone)]
struct OtpStore {
tree: Arc<Mutex<BTree>>,
}
/// A decoded OTP record.
struct OtpRecord {
salt: String,
code_hash: String,
expires_at: i64,
attempts: i64,
}
impl OtpStore {
fn open(path: impl AsRef<Path>) -> io::Result<OtpStore> {
Ok(OtpStore {
tree: Arc::new(Mutex::new(BTree::open(path)?)),
})
}
/// Mint a fresh code for `email`, store its *salted hash* (never the code)
/// with a TTL and a zeroed attempt count (overwriting any prior code), then
/// hand the plaintext code to `deliver`. Durable before delivery.
fn request(&self, email: &str, deliver: &dyn OtpDeliver) -> io::Result<()> {
let email = normalize_email(email);
let code = random_code();
let salt = random_id();
let record = Value::Object(vec![
("salt".into(), Value::Str(salt.clone())),
("code_hash".into(), Value::Str(hash_password(&salt, &code))),
("expires_at".into(), Value::Int(now_secs() + OTP_TTL_SECS)),
("attempts".into(), Value::Int(0)),
]);
{
let mut tree = self.tree.lock().unwrap();
tree.insert(email.as_bytes(), record.to_json().as_bytes())?;
tree.commit()?;
}
deliver.deliver(&email, &code)
}
/// Verify `code` for `email`: check existence, expiry, the attempt cap, and
/// the salted-hash match. On success the OTP is cleared; on a wrong guess an
/// attempt is consumed and persisted.
fn verify(&self, email: &str, code: &str) -> io::Result<OtpOutcome> {
let email = normalize_email(email);
let code = code.trim();
let mut tree = self.tree.lock().unwrap();
let Some(bytes) = tree.get(email.as_bytes())? else {
return Ok(OtpOutcome::NoCode);
};
let Some(rec) = decode_otp(&bytes) else {
// Malformed record: clear it and treat as no outstanding code.
tree.delete(email.as_bytes())?;
tree.commit()?;
return Ok(OtpOutcome::NoCode);
};
if now_secs() > rec.expires_at {
tree.delete(email.as_bytes())?;
tree.commit()?;
return Ok(OtpOutcome::Expired);
}
if rec.attempts >= OTP_MAX_ATTEMPTS {
return Ok(OtpOutcome::TooManyAttempts);
}
if constant_time_eq(&hash_password(&rec.salt, code), &rec.code_hash) {
tree.delete(email.as_bytes())?;
tree.commit()?;
return Ok(OtpOutcome::Verified);
}
// Wrong code: burn an attempt and persist.
let attempts = rec.attempts + 1;
let updated = Value::Object(vec![
("salt".into(), Value::Str(rec.salt)),
("code_hash".into(), Value::Str(rec.code_hash)),
("expires_at".into(), Value::Int(rec.expires_at)),
("attempts".into(), Value::Int(attempts)),
]);
tree.insert(email.as_bytes(), updated.to_json().as_bytes())?;
tree.commit()?;
if attempts >= OTP_MAX_ATTEMPTS {
Ok(OtpOutcome::TooManyAttempts)
} else {
Ok(OtpOutcome::WrongCode)
}
}
/// Test-only: seed an OTP with an explicit `expires_at` so expiry can be
/// exercised without sleeping. Returns nothing — the caller knows `code`.
#[cfg(test)]
fn seed_code(&self, email: &str, code: &str, expires_at: i64) -> io::Result<()> {
let email = normalize_email(email);
let salt = random_id();
let record = Value::Object(vec![
("salt".into(), Value::Str(salt.clone())),
("code_hash".into(), Value::Str(hash_password(&salt, code))),
("expires_at".into(), Value::Int(expires_at)),
("attempts".into(), Value::Int(0)),
]);
let mut tree = self.tree.lock().unwrap();
tree.insert(email.as_bytes(), record.to_json().as_bytes())?;
tree.commit()?;
Ok(())
}
}
/// Decode a stored OTP JSON blob into an [`OtpRecord`], or `None` if malformed.
fn decode_otp(bytes: &[u8]) -> Option<OtpRecord> {
let text = std::str::from_utf8(bytes).ok()?;
let value = akurai_json::parse(text).ok()?;
Some(OtpRecord {
salt: value.get("salt")?.as_str()?.to_string(),
code_hash: value.get("code_hash")?.as_str()?.to_string(),
expires_at: value.get("expires_at").and_then(Value::as_i64).unwrap_or(0),
attempts: value.get("attempts").and_then(Value::as_i64).unwrap_or(0),
})
}
// ---------------------------------------------------------------------------
// Password hashing (demo-grade — see module caveats)
// ---------------------------------------------------------------------------
/// Hash stretch rounds. SHA-1 is fast, so we iterate to slow brute force a
/// little; this is *not* a memory-hard KDF and must not be sold as bcrypt.
const HASH_ROUNDS: usize = 4096;
/// A salted, stretched SHA-1 of `password`, returned as lowercase hex. Same
/// `(salt, password)` always yields the same hash; a different salt or password
/// yields a different one. Reused to hash OTP codes (with a per-code salt).
pub fn hash_password(salt: &str, password: &str) -> String {
let mut digest = sha1(format!("{salt}:{password}").as_bytes());
// Re-mix the salt into every round so the salt is not just a first-round
// prefix an attacker can strip.
let salt_bytes = salt.as_bytes();
for _ in 0..HASH_ROUNDS {
let mut buf = Vec::with_capacity(digest.len() + salt_bytes.len());
buf.extend_from_slice(&digest);
buf.extend_from_slice(salt_bytes);
digest = sha1(&buf);
}
to_hex(&digest)
}
/// Compare two hex hashes without an early-exit short circuit on the first
/// differing byte (length differences still short-circuit, which is fine here:
/// our hashes are always the same length).
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.bytes().zip(b.bytes()) {
diff |= x ^ y;
}
diff == 0
}
/// Lowercase hex of a byte slice.
fn to_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
// ---------------------------------------------------------------------------
// CSPRNG-lite id generation (demo-grade — see module caveats)
// ---------------------------------------------------------------------------
/// A monotonic per-process counter mixed into every id so two ids minted within
/// the same `SystemTime` tick still differ.
static ID_COUNTER: AtomicU64 = AtomicU64::new(0);
/// A 128-bit opaque id as 32 hex chars, derived from `std` only:
/// `SystemTime` nanos + pid + an atomic counter, diffused through SplitMix64.
/// Demo-grade randomness — see the module caveats.
fn random_id() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let pid = std::process::id() as u64;
let count = ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut seed_a = nanos ^ pid.rotate_left(32);
let mut seed_b = count
.wrapping_add(nanos.rotate_left(17))
.wrapping_mul(0x9E37_79B9_7F4A_7C15);
let a = split_mix64(&mut seed_a);
let b = split_mix64(&mut seed_b);
format!("{a:016x}{b:016x}")
}
/// A zero-padded `OTP_CODE_DIGITS`-digit numeric code, derived from the same
/// `std`-only entropy as [`random_id`] (demo-grade — see the module caveats).
fn random_code() -> String {
let id = random_id();
let word = u32::from_str_radix(&id[..8], 16).unwrap_or(0);
let n = word % 10u32.pow(OTP_CODE_DIGITS as u32);
format!("{n:0width$}", width = OTP_CODE_DIGITS)
}
/// One SplitMix64 step (Vigna): advance `state` and return a well-diffused word.
fn split_mix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Current unix time in whole seconds (0 on a pre-epoch clock, which never
/// happens in practice).
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
/// The scratch base: `CARGO_TARGET_TMPDIR` when present (integration tests),
/// otherwise a dir under this crate's own `target/` — never `/tmp`.
fn scratch_base() -> PathBuf {
match std::env::var_os("CARGO_TARGET_TMPDIR") {
Some(dir) => PathBuf::from(dir),
None => PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("auth-test-tmp"),
}
}
/// A unique scratch file path under [`scratch_base`] (never `/tmp`).
fn tmp(name: &str) -> PathBuf {
let base = scratch_base();
std::fs::create_dir_all(&base).unwrap();
let p = base.join(format!(
"akurai-auth-{name}-{}-{}.db",
std::process::id(),
ID_COUNTER.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_file(&p);
p
}
// --- session store ----------------------------------------------------
#[test]
fn session_create_lookup_delete_roundtrip() {
let path = tmp("session-roundtrip");
let store = SessionStore::open(&path).unwrap();
let id = store.create("ada").unwrap();
let found = store.lookup(&id).unwrap().expect("session should exist");
assert_eq!(found.username, "ada");
assert!(found.created > 0);
assert!(store.delete(&id).unwrap(), "delete reports it existed");
assert!(store.lookup(&id).unwrap().is_none(), "gone after delete");
assert!(!store.delete(&id).unwrap(), "second delete is a no-op");
}
#[test]
fn unknown_session_id_is_none() {
let path = tmp("session-unknown");
let store = SessionStore::open(&path).unwrap();
assert!(store.lookup("does-not-exist").unwrap().is_none());
}
#[test]
fn sessions_persist_across_reopen() {
let path = tmp("session-persist");
let id = {
let store = SessionStore::open(&path).unwrap();
store.create("grace").unwrap()
};
// Reopen from disk: the session survives.
let store = SessionStore::open(&path).unwrap();
let found = store.lookup(&id).unwrap().expect("session survives reopen");
assert_eq!(found.username, "grace");
}
#[test]
fn ids_are_unique_even_in_a_tight_loop() {
let path = tmp("session-unique");
let store = SessionStore::open(&path).unwrap();
let mut ids = std::collections::HashSet::new();
for _ in 0..1000 {
assert!(ids.insert(store.create("u").unwrap()), "no id collisions");
}
}
// --- password hashing -------------------------------------------------
#[test]
fn same_input_same_hash() {
assert_eq!(hash_password("salt", "pw"), hash_password("salt", "pw"));
}
#[test]
fn different_password_different_hash() {
assert_ne!(hash_password("salt", "pw"), hash_password("salt", "other"));
}
#[test]
fn different_salt_different_hash() {
assert_ne!(hash_password("salt-a", "pw"), hash_password("salt-b", "pw"));
}
#[test]
fn hash_is_hex_and_never_the_plaintext() {
let h = hash_password("s", "hunter2");
assert_eq!(h.len(), 40, "SHA-1 hex is 40 chars");
assert!(h.bytes().all(|b| b.is_ascii_hexdigit()));
assert!(!h.contains("hunter2"));
}
#[test]
fn constant_time_eq_matches_plain_eq() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "abcd"));
}
// --- user store -------------------------------------------------------
#[test]
fn user_verify_accepts_correct_rejects_wrong() {
let path = tmp("user-verify");
let users = UserStore::open(&path).unwrap();
users.ensure_user("demo", "akurai", Role::User).unwrap();
assert!(users.verify("demo", "akurai").unwrap(), "right password");
assert!(!users.verify("demo", "nope").unwrap(), "wrong password");
assert!(!users.verify("ghost", "akurai").unwrap(), "no such user");
}
#[test]
fn ensure_user_is_idempotent() {
let path = tmp("user-idempotent");
let users = UserStore::open(&path).unwrap();
users.ensure_user("demo", "first", Role::User).unwrap();
// A second call with a different password must NOT overwrite the user.
users.ensure_user("demo", "second", Role::User).unwrap();
assert!(users.verify("demo", "first").unwrap());
assert!(!users.verify("demo", "second").unwrap());
}
// --- roles ------------------------------------------------------------
#[test]
fn role_of_reads_stored_role_and_defaults_to_user() {
let path = tmp("role-of");
let users = UserStore::open(&path).unwrap();
users.ensure_user("boss", "pw", Role::Admin).unwrap();
users.ensure_user("worker", "pw", Role::User).unwrap();
assert_eq!(users.role_of("boss"), Some(Role::Admin));
assert_eq!(users.role_of("worker"), Some(Role::User));
assert_eq!(users.role_of("ghost"), None, "no record → None");
}
#[test]
fn role_string_roundtrips_and_unknown_is_user() {
assert_eq!(Role::parse(Role::Admin.as_str()), Role::Admin);
assert_eq!(Role::parse(Role::User.as_str()), Role::User);
assert_eq!(Role::parse("nonsense"), Role::User);
}
#[test]
fn legacy_record_without_role_reads_as_user() {
// Simulate a pre-roles user record: only salt + hash, no `role` field.
let path = tmp("legacy-role");
let users = UserStore::open(&path).unwrap();
{
let mut tree = users.tree.lock().unwrap();
let salt = random_id();
let record = Value::Object(vec![
("salt".into(), Value::Str(salt.clone())),
("hash".into(), Value::Str(hash_password(&salt, "pw"))),
]);
tree.insert(b"old", record.to_json().as_bytes()).unwrap();
tree.commit().unwrap();
}
assert_eq!(users.role_of("old"), Some(Role::User));
assert!(users.verify("old", "pw").unwrap(), "password still works");
}
#[test]
fn passwordless_account_has_role_but_cannot_password_login() {
let path = tmp("passwordless");
let users = UserStore::open(&path).unwrap();
users.ensure_account("a@b.com", Role::User).unwrap();
assert_eq!(users.role_of("a@b.com"), Some(Role::User));
// No hash stored → password login can never succeed.
assert!(!users.verify("a@b.com", "").unwrap());
assert!(!users.verify("a@b.com", "anything").unwrap());
}
// --- OTP store --------------------------------------------------------
fn open_otp(name: &str) -> OtpStore {
OtpStore::open(tmp(name)).unwrap()
}
#[test]
fn otp_generate_then_verify_happy_path() {
let store = open_otp("otp-happy");
let log = LogDeliver::new();
store.request("User@Example.com", &log).unwrap();
// The code is delivered (keyed by the normalized email) and is 6 digits.
let code = log.last_code("user@example.com").expect("code delivered");
assert_eq!(code.len(), OTP_CODE_DIGITS);
assert!(code.bytes().all(|b| b.is_ascii_digit()));
// Email match is case/space-insensitive; correct code verifies once.
assert_eq!(
store.verify(" USER@EXAMPLE.COM ", &code).unwrap(),
OtpOutcome::Verified
);
// The OTP is single-use: a second verify finds nothing.
assert_eq!(
store.verify("user@example.com", &code).unwrap(),
OtpOutcome::NoCode
);
}
#[test]
fn otp_unknown_email_is_nocode() {
let store = open_otp("otp-nocode");
assert_eq!(
store.verify("nobody@example.com", "000000").unwrap(),
OtpOutcome::NoCode
);
}
#[test]
fn otp_expired_code_is_rejected_and_cleared() {
let store = open_otp("otp-expired");
// Seed a code that expired one second ago.
store
.seed_code("u@x.com", "123456", now_secs() - 1)
.unwrap();
assert_eq!(
store.verify("u@x.com", "123456").unwrap(),
OtpOutcome::Expired
);
// Expiry clears the record.
assert_eq!(
store.verify("u@x.com", "123456").unwrap(),
OtpOutcome::NoCode
);
}
#[test]
fn otp_wrong_code_then_attempt_cap() {
let store = open_otp("otp-cap");
let log = LogDeliver::new();
store.request("cap@x.com", &log).unwrap();
let code = log.last_code("cap@x.com").unwrap();
// The first OTP_MAX_ATTEMPTS-1 wrong guesses report WrongCode...
for _ in 0..(OTP_MAX_ATTEMPTS - 1) {
assert_eq!(
store.verify("cap@x.com", "000000").unwrap(),
OtpOutcome::WrongCode
);
}
// ...the one that hits the cap, and every guess after, is locked out —
// even the *correct* code.
assert_eq!(
store.verify("cap@x.com", "000000").unwrap(),
OtpOutcome::TooManyAttempts
);
assert_eq!(
store.verify("cap@x.com", &code).unwrap(),
OtpOutcome::TooManyAttempts,
"cap locks out even the right code"
);
}
#[test]
fn otp_request_overwrites_prior_code_and_resets_attempts() {
let store = open_otp("otp-overwrite");
let log = LogDeliver::new();
store.request("re@x.com", &log).unwrap();
// Burn an attempt against the first code.
assert_eq!(
store.verify("re@x.com", "000000").unwrap(),
OtpOutcome::WrongCode
);
// Re-request: a new code with a fresh attempt counter.
store.request("re@x.com", &log).unwrap();
let code = log.last_code("re@x.com").unwrap();
assert_eq!(
store.verify("re@x.com", &code).unwrap(),
OtpOutcome::Verified
);
}
// --- cookie helpers ---------------------------------------------------
#[test]
fn parse_cookie_finds_named_value() {
let header = "theme=dark; session=abc123; lang=en";
assert_eq!(parse_cookie(header, "session").as_deref(), Some("abc123"));
assert_eq!(parse_cookie(header, "theme").as_deref(), Some("dark"));
assert_eq!(parse_cookie(header, "missing"), None);
}
#[test]
fn parse_cookie_handles_single_pair_and_spacing() {
assert_eq!(
parse_cookie("session=xyz", "session").as_deref(),
Some("xyz")
);
assert_eq!(
parse_cookie(" session = spaced ", "session").as_deref(),
Some("spaced")
);
}
#[test]
fn set_and_expire_cookie_have_the_right_attributes() {
let set = set_cookie("tok");
assert!(set.starts_with("session=tok"));
assert!(set.contains("HttpOnly"));
assert!(set.contains("Path=/"));
assert!(set.contains("SameSite=Lax"));
let clear = expired_cookie();
assert!(clear.starts_with("session=;"));
assert!(clear.contains("Max-Age=0"));
}
// --- login state machine (function level, no socket) ------------------
fn open_state(name: &str) -> State {
open_state_with_log(name).0
}
/// Open a fresh [`State`] plus a handle to its [`LogDeliver`] so tests can
/// read the code it "sent".
fn open_state_with_log(name: &str) -> (State, LogDeliver) {
let dir = scratch_base().join(format!(
"akurai-auth-state-{name}-{}-{}",
std::process::id(),
ID_COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
// Clean any prior run's files so seeding is deterministic.
for f in ["sessions.db", "users.db", "otp.db"] {
let _ = std::fs::remove_file(dir.join(f));
}
let log = LogDeliver::new();
let state = State::open_with_deliver(&dir, Arc::new(log.clone())).unwrap();
(state, log)
}
fn req_with_cookie(cookie: Option<&str>) -> Request {
let head = match cookie {
Some(c) => format!("GET /account HTTP/1.1\r\nCookie: {c}\r\n\r\n"),
None => "GET /account HTTP/1.1\r\n\r\n".to_string(),
};
Request::parse_head(head.as_bytes()).unwrap()
}
#[test]
fn full_login_cycle_via_state() {
let state = open_state("cycle");
// No cookie → not authenticated.
assert!(state.current_user(&req_with_cookie(None)).is_none());
// Create a session for the seeded demo user (what login_submit does).
let id = state.sessions.create(DEMO_USERNAME).unwrap();
let cookie = set_cookie(&id);
// The browser would send back just `name=value`; emulate that.
let sent = format!("{COOKIE_NAME}={id}");
let user = state
.current_user(&req_with_cookie(Some(&sent)))
.expect("authenticated");
assert_eq!(user.username, DEMO_USERNAME);
assert!(cookie.contains(&id));
// Logout deletes the session; the same cookie now resolves to nobody.
assert!(state.sessions.delete(&id).unwrap());
assert!(state.current_user(&req_with_cookie(Some(&sent))).is_none());
}
#[test]
fn account_redirects_when_unauthenticated() {
let state = open_state("account-guard");
let root = Path::new("nonexistent-frontend");
let Reply::Response(resp) = account(&state, root, &req_with_cookie(None)) else {
panic!("expected a buffered response");
};
assert_eq!(resp.status, 303);
assert_eq!(
resp.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("location"))
.map(|(_, v)| v.as_str()),
Some("/login")
);
}
#[test]
fn demo_credentials_are_seeded_as_admin() {
let state = open_state("seed");
assert!(state.users.verify(DEMO_USERNAME, DEMO_PASSWORD).unwrap());
assert_eq!(state.users.role_of(DEMO_USERNAME), Some(Role::Admin));
}
#[test]
fn current_user_exposes_role_and_require_role_gates() {
let state = open_state("role-gate");
// Sign in as the seeded admin demo user.
let admin_id = state.sessions.create(DEMO_USERNAME).unwrap();
let admin_req = req_with_cookie(Some(&format!("{COOKIE_NAME}={admin_id}")));
let admin = state.current_user(&admin_req).expect("admin signed in");
assert_eq!(admin.role, Role::Admin);
assert!(
state.require_role(&admin_req, Role::Admin),
"admin gates admin"
);
assert!(
state.require_role(&admin_req, Role::User),
"admin gates user"
);
// A passwordless OTP account is a plain user.
state.ensure_account("plain@x.com").unwrap();
let user_id = state.sessions.create("plain@x.com").unwrap();
let user_req = req_with_cookie(Some(&format!("{COOKIE_NAME}={user_id}")));
let user = state.current_user(&user_req).expect("user signed in");
assert_eq!(user.role, Role::User);
assert!(state.require_role(&user_req, Role::User), "user gates user");
assert!(
!state.require_role(&user_req, Role::Admin),
"user is denied admin"
);
// Unauthenticated requests satisfy no role.
let anon = req_with_cookie(None);
assert!(!state.require_role(&anon, Role::User));
assert!(!state.require_role(&anon, Role::Admin));
assert_eq!(state.current_role(&anon), None);
}
#[test]
fn otp_full_flow_via_state_signs_in() {
let (state, log) = open_state_with_log("otp-state");
let email = "golfer@example.com";
// Request a code, then verify it through the State seam.
state.request_otp(email).unwrap();
let code = log.last_code(email).expect("code delivered");
assert_eq!(
state.verify_otp(email, &code).unwrap(),
OtpOutcome::Verified
);
// A successful OTP login creates the account; it is a plain user.
state.ensure_account(email).unwrap();
let id = state.sessions.create(email).unwrap();
let req = req_with_cookie(Some(&format!("{COOKIE_NAME}={id}")));
let who = state.current_user(&req).expect("signed in via OTP");
assert_eq!(who.username, email);
assert_eq!(who.role, Role::User);
}
}