diff --git a/CHANGELOG.md b/CHANGELOG.md index 6929312..1f41be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2026-07-27 + +The production user-system release: everything Laravel's auth scaffolding does, still zero third-party dependencies. + +### Added + +- Auth: **remember me** (`sutegi_auth::Remember`) — selector/validator cookies where only the validator's SHA-256 is stored, the validator **rotates on every use** (a stolen-then-replayed copy revokes the row, surfacing the theft), tokens are **bound to the password hash** at mint (password change kills them silently), and server-side expiry (default 30 days) ignores whatever the client claims. `Auth::remember(store)` + `Auth::login_remembered` mint it; **`Auth::identify`** is the handler-side revival point — session-or-remember, returning an `Identified { user, via_remember }` whose `attach(resp)` sets the fresh session + rotated remember cookies (sutegi middleware cannot set cookies on pass-through, so revival lives in whatever endpoint establishes client identity: a `/me`, a page shell). `Auth::logout_from(req, resp)` revokes the presented token and expires both cookies; `Auth::logout_everywhere(uid)` revokes them all. +- Auth: **login throttling** (`sutegi_auth::Throttle`) — Laravel's `ThrottlesLogins` as a DB-backed fixed window (default 5 attempts / 60 s, per any key you choose — the convention is `login:|`), so every pod counts the same attempts. `too_many(key)` → retry-after seconds, `hit(key)` (atomic `UPDATE … attempts + 1`), `clear(key)` on success. +- Session/Auth: **CSRF tokens** — `Sessions::csrf(&mut Session)` get-or-mints a 32-byte token inside the signed session, `Sessions::verify_csrf` compares in constant time, `Auth::csrf(req, resp)` is the handler shape, and the `require_csrf` guard enforces `X-CSRF-Token` on mutating methods (419 on mismatch, Laravel's "Page Expired") while passing reads and `Authorization`-header callers — bearer clients carry no ambient credential, which is what CSRF forges. +- Auth: **sessions bound to the password hash** — `Auth::login` stamps a 16-hex fingerprint of the current PHC string into the signed session; `Auth::current`/`Auth::identify` treat a stale binding as anonymous. Changing a password now logs out every other device on their next store-checking request (`AuthenticateSession` semantics). `Auth::user_id` stays pure cookie-HMAC (documented as not enforcing the binding). Sessions minted by 0.7 (no fingerprint) still pass — strict on mismatch, lenient on absence. +- Auth: **auto-rehash at login** — `Users::authenticate` transparently re-hashes a verified password whose stored iteration count is below the store's (best-effort; a rehash failure never fails a valid login). Raise the work factor in one place and the fleet upgrades itself credential-by-credential. +- Auth: `require_verified` guard — Laravel's `verified` middleware: 401 anonymous, 403 `email unverified` until `verified_at` is set. +- Auth: profile operations — `Users::change_password(id, current, new)` (verifies the current password first; the profile-screen shape), `Users::set_name`, and `Users::set_email` (normalizes, checks uniqueness, **resets `verified_at` to 0** so the new address must re-verify). +- Auth: API-token lifecycle — `Tokens::issue_expiring(uid, name, ttl)` mints tokens that stop verifying after their deadline (Sanctum expiration), every successful `verify` stamps `last_used_at`, and both fields ride `ApiToken::to_json()`/`list()`. Existing `api_tokens` tables upgrade in place (tolerant `ALTER`s, same pattern as `users.verified_at`). +- Session: `Sessions::cookie_for(&Session)` — the `Set-Cookie` value `save` would attach, for callers that collect cookies before touching a response (what `Auth::identify` rides). +- Example: `examples/auth` now exercises the whole system — `"remember": true` login, `/me` revival, throttled login (429 + `retry_after`), full logout. + +### Changed + +- Auth: `Auth` has a new public `remember: Option>` field; code constructing `Auth` as a struct literal must add `remember: None` (the `Auth::new` builder is unaffected). +- Auth: a login that triggers an auto-rehash changes the password-hash fingerprint, so **other** devices' sessions and remember tokens from before the rehash stop validating — same semantics as an actual password change, and a one-time event per credential after raising the work factor. + ## [0.7.0] - 2026-07-25 ### Added diff --git a/Cargo.toml b/Cargo.toml index 361aa56..a6ac794 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ members = [ exclude = ["benches", "fuzz", "differential"] [workspace.package] -version = "0.7.0" +version = "0.8.0" edition = "2021" rust-version = "1.75" license = "MIT" diff --git a/crates/sutegi-actors/Cargo.toml b/crates/sutegi-actors/Cargo.toml index 2de9fbe..482cdfa 100644 --- a/crates/sutegi-actors/Cargo.toml +++ b/crates/sutegi-actors/Cargo.toml @@ -11,4 +11,4 @@ keywords = ["actors", "supervision", "fault-tolerance", "zero-dependency"] categories = ["concurrency"] [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } diff --git a/crates/sutegi-auth/Cargo.toml b/crates/sutegi-auth/Cargo.toml index e6e4325..443a032 100644 --- a/crates/sutegi-auth/Cargo.toml +++ b/crates/sutegi-auth/Cargo.toml @@ -11,19 +11,19 @@ keywords = ["auth", "users", "sessions", "tokens", "zero-dependency"] categories = ["authentication", "web-programming"] [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-crypto = { path = "../sutegi-crypto", version = "0.7.0" } -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0" } -sutegi-session = { path = "../sutegi-session", version = "0.7.0" } -sutegi-web = { path = "../sutegi-web", version = "0.7.0" } -sutegi-mail = { path = "../sutegi-mail", version = "0.7.0", optional = true } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-crypto = { path = "../sutegi-crypto", version = "0.8.0" } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0" } +sutegi-session = { path = "../sutegi-session", version = "0.8.0" } +sutegi-web = { path = "../sutegi-web", version = "0.8.0" } +sutegi-mail = { path = "../sutegi-mail", version = "0.8.0", optional = true } [features] # Email verification + password reset flows over sutegi-mail. mail = ["dep:sutegi-mail"] [dev-dependencies] -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0", features = ["sqlite", "postgres"] } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0", features = ["sqlite", "postgres"] } [package.metadata.docs.rs] all-features = true diff --git a/crates/sutegi-auth/src/flows.rs b/crates/sutegi-auth/src/flows.rs index 5ec0c07..96e3e3b 100644 --- a/crates/sutegi-auth/src/flows.rs +++ b/crates/sutegi-auth/src/flows.rs @@ -20,7 +20,6 @@ use crate::links::Links; use crate::users::{now_secs, User, Users}; use std::sync::Arc; -use sutegi_crypto::{hex, sha256}; use sutegi_mail::{Mailer, Theme}; use sutegi_orm::Backend; @@ -193,7 +192,7 @@ impl AuthMail { fn hash_bind(&self, users: &Users, uid: i64) -> Result, String> { Ok(users .password_hash_of(uid)? - .map(|h| hex(&sha256(h.as_bytes()))[..16].to_string())) + .map(|h| crate::password::fingerprint(&h))) } } diff --git a/crates/sutegi-auth/src/lib.rs b/crates/sutegi-auth/src/lib.rs index ef05d56..82531cf 100644 --- a/crates/sutegi-auth/src/lib.rs +++ b/crates/sutegi-auth/src/lib.rs @@ -10,9 +10,19 @@ //! **server-side expiry** stamped into the signed payload, so a stolen //! cookie dies on schedule regardless of what the client claims. //! - [`Tokens`] — hashed bearer tokens for **agents and services**; the -//! plaintext is returned once and only its SHA-256 is stored. -//! - Route guards: [`require_auth`], [`require_role`], [`require_token`] — -//! plug into `App::group(prefix, vec![mw(...)], …)`. +//! plaintext is returned once and only its SHA-256 is stored. Optional +//! expiry, `last_used_at` tracking. +//! - [`Remember`] — "remember me" tokens: selector/validator cookies that +//! rotate on every use and die on password change; +//! [`Auth::identify`] revives an expired session from one. +//! - [`Throttle`] — DB-backed login rate limiting (Laravel's +//! `ThrottlesLogins`), one counter shared by every pod. +//! - Route guards: [`require_auth`], [`require_role`], [`require_verified`], +//! [`require_token`], [`require_csrf`] — plug into +//! `App::group(prefix, vec![mw(...)], …)`. +//! - Sessions carry a fingerprint of the password hash: **changing the +//! password logs out every other device** on the next store-checking +//! lookup, and logins transparently **re-hash upgraded work factors**. //! //! ```ignore //! let db = Db::open("app.db")?; @@ -35,6 +45,8 @@ pub mod links; pub mod password; +pub mod remember; +pub mod throttle; pub mod tokens; pub mod users; @@ -44,7 +56,9 @@ pub mod flows; pub use flows::AuthMail; pub use links::Links; -pub use password::{hash_password, verify_password, DEFAULT_ITERATIONS}; +pub use password::{hash_password, needs_rehash, verify_password, DEFAULT_ITERATIONS}; +pub use remember::{Remember, REMEMBER_COOKIE, REMEMBER_TTL}; +pub use throttle::Throttle; pub use tokens::{ApiToken, Tokens, TOKEN_PREFIX}; pub use users::{User, Users, MIN_PASSWORD_LEN}; @@ -52,17 +66,21 @@ use std::sync::Arc; use sutegi_json::Json; use sutegi_orm::Backend; use sutegi_session::Sessions; -use sutegi_web::{json, Request, Response}; +use sutegi_web::{json, Method, Request, Response}; /// Session keys used inside the signed cookie payload. const UID_KEY: &str = "uid"; const EXP_KEY: &str = "exp"; +/// Fingerprint of the password hash at login — sessions die with it. +const PWB_KEY: &str = "pwb"; /// The session glue: a [`Users`] store plus a [`Sessions`] cookie signer, and -/// the login/logout/current-user operations between them. +/// the login/logout/current-user operations between them. Add a [`Remember`] +/// store to get Laravel's "remember me" on top. pub struct Auth { pub users: Users, pub sessions: Sessions, + pub remember: Option>, ttl: i64, } @@ -72,6 +90,7 @@ impl Auth { Auth { users, sessions, + remember: None, ttl: 86_400, } } @@ -84,22 +103,80 @@ impl Auth { self } + /// Attach a [`Remember`] store: [`login_remembered`](Auth::login_remembered) + /// mints long-lived tokens and [`identify`](Auth::identify) revives + /// expired sessions from them. + pub fn remember(mut self, store: Remember) -> Auth { + self.remember = Some(store); + self + } + /// Stamp `user` into the (signed) session and attach it to `resp`. - /// Existing session data is preserved. + /// Existing session data is preserved. The session carries a fingerprint + /// of the current password hash, so a password change invalidates every + /// other session on the next store-checking lookup. pub fn login(&self, req: &Request, user: &User, resp: Response) -> Response { let mut s = self.sessions.load(req); - s.set(UID_KEY, Json::int(user.id)); - s.set(EXP_KEY, Json::int(users::now_secs() + self.ttl)); + self.stamp(&mut s, user.id); self.sessions.save(&s, resp) } - /// Expire the session cookie. + /// [`login`](Auth::login) plus a rotating remember-me cookie (requires a + /// [`remember`](Auth::remember) store). The Laravel + /// `Auth::attempt($creds, remember: true)` shape. + pub fn login_remembered( + &self, + req: &Request, + user: &User, + resp: Response, + ) -> Result { + let Some(rem) = &self.remember else { + return Err("no remember store configured — Auth::remember(...)".to_string()); + }; + let bind = self.bind_of(user.id)?.unwrap_or_default(); + let cookie = rem.issue(user.id, &bind)?; + Ok(self + .login(req, user, resp) + .with_header("set-cookie", &rem.cookie_header(&cookie))) + } + + /// Expire the session cookie. If a remember store is attached, also + /// revoke the request's remember token and expire that cookie — pass the + /// request via [`logout_from`](Auth::logout_from) to get that. pub fn logout(&self, resp: Response) -> Response { self.sessions.clear(resp) } + /// Full logout for this device: expires the session cookie **and** + /// revokes + expires the remember cookie, when one rode in. + pub fn logout_from(&self, req: &Request, resp: Response) -> Response { + let resp = self.sessions.clear(resp); + match &self.remember { + Some(rem) => { + if let Some(presented) = rem.read(req) { + let _ = rem.revoke_presented(&presented); + } + resp.with_header("set-cookie", &rem.clear_header()) + } + None => resp, + } + } + + /// Kill every remember token a user holds (Laravel's + /// `logoutOtherDevices` reach). Live session cookies die at their + /// server-side expiry, or immediately on password change (the session's + /// hash binding stops matching). + pub fn logout_everywhere(&self, user_id: i64) -> Result { + match &self.remember { + Some(rem) => rem.revoke_all(user_id), + None => Ok(0), + } + } + /// The logged-in user id, if the request carries a valid, unexpired - /// session. Pure cookie-HMAC work — no database access. + /// session. Pure cookie-HMAC work — no database access (so no password + /// binding check; [`current`](Auth::current) and [`identify`](Auth::identify) + /// enforce it). pub fn user_id(&self, req: &Request) -> Option { let s = self.sessions.load(req); let exp = s.get(EXP_KEY).and_then(Json::as_i64)?; @@ -109,12 +186,101 @@ impl Auth { s.get(UID_KEY).and_then(Json::as_i64) } - /// The logged-in [`User`], loaded from the store (one lookup). + /// The logged-in [`User`], loaded from the store (one lookup). A session + /// whose password binding no longer matches the stored hash — the + /// password changed since login — is treated as anonymous. pub fn current(&self, req: &Request) -> Result, String> { - match self.user_id(req) { - Some(id) => self.users.find(id), - None => Ok(None), + let Some(id) = self.user_id(req) else { + return Ok(None); + }; + let s = self.sessions.load(req); + if let Some(pwb) = s.get_str(PWB_KEY) { + if self.bind_of(id)?.as_deref() != Some(pwb) { + return Ok(None); + } } + self.users.find(id) + } + + /// The request's user via session **or** remember-me revival: when the + /// session is gone but a valid remember cookie rides along, the token is + /// consumed (rotated) and fresh cookies are minted — call + /// [`Identified::attach`] on your response to set them. This is the + /// handler-side revival point (sutegi middleware cannot set cookies on + /// pass-through), so wire it into whatever endpoint establishes your + /// client's identity — a `/me`, a page shell, a session probe. + pub fn identify(&self, req: &Request) -> Result, String> { + if let Some(user) = self.current(req)? { + return Ok(Some(Identified { + user, + via_remember: false, + cookies: vec![], + })); + } + let Some(rem) = &self.remember else { + return Ok(None); + }; + let Some(presented) = rem.read(req) else { + return Ok(None); + }; + let Some((uid, rotated)) = rem.consume(&presented, |uid| self.bind_of(uid))? else { + return Ok(None); + }; + let Some(user) = self.users.find(uid)? else { + return Ok(None); + }; + let mut s = self.sessions.load(req); + self.stamp(&mut s, user.id); + Ok(Some(Identified { + user, + via_remember: true, + cookies: vec![self.sessions.cookie_for(&s), rem.cookie_header(&rotated)], + })) + } + + /// Get-or-mint the session's CSRF token and attach the (possibly + /// re-signed) session to `resp`. Serve it from a small `GET` endpoint; + /// clients echo it back in `X-CSRF-Token` past [`require_csrf`]. + pub fn csrf(&self, req: &Request, resp: Response) -> Result<(String, Response), String> { + let mut s = self.sessions.load(req); + let token = self.sessions.csrf(&mut s)?; + Ok((token, self.sessions.save(&s, resp))) + } + + fn stamp(&self, s: &mut sutegi_session::Session, uid: i64) { + s.set(UID_KEY, Json::int(uid)); + s.set(EXP_KEY, Json::int(users::now_secs() + self.ttl)); + if let Ok(Some(bind)) = self.bind_of(uid) { + s.set(PWB_KEY, Json::str(bind)); + } + } + + /// Fingerprint of the user's current password hash (`None` = user gone). + fn bind_of(&self, uid: i64) -> Result, String> { + Ok(self + .users + .password_hash_of(uid)? + .map(|h| password::fingerprint(&h))) + } +} + +/// A resolved request identity from [`Auth::identify`] — the user plus any +/// cookies a remember-me revival minted. +pub struct Identified { + pub user: User, + /// `true` when the identity came from a remember token rather than a + /// live session (Laravel's `viaRemember`). + pub via_remember: bool, + cookies: Vec, +} + +impl Identified { + /// Set any revival cookies on the response. A no-op for plain session + /// hits. + pub fn attach(&self, resp: Response) -> Response { + self.cookies + .iter() + .fold(resp, |r, c| r.with_header("set-cookie", c)) } } @@ -154,6 +320,56 @@ where } } +/// Guard: `401` without a valid session, `403 {"error":"email unverified"}` +/// until the logged-in user confirms their address — Laravel's `verified` +/// middleware (one store lookup per request). +pub fn require_verified( + auth: Arc>, +) -> impl Fn(&Request) -> Option + Send + Sync + 'static +where + B: Backend + Send + Sync + 'static, +{ + move |req| match auth.current(req) { + Ok(Some(user)) if user.is_verified() => None, + Ok(Some(_)) => Some(json( + 403, + &Json::obj(vec![("error", Json::str("email unverified"))]), + )), + Ok(None) => Some(unauthenticated()), + Err(e) => Some(json(500, &Json::obj(vec![("error", Json::str(e))]))), + } +} + +/// Guard: on mutating methods, require an `X-CSRF-Token` header matching the +/// session's token (see [`Auth::csrf`]). Reads (`GET`/`HEAD`/`OPTIONS`) pass, +/// and so do requests authenticating with an `Authorization` header — bearer +/// callers carry no ambient cookie credential, which is what CSRF forges. +pub fn require_csrf( + auth: Arc>, +) -> impl Fn(&Request) -> Option + Send + Sync + 'static +where + B: Backend + Send + Sync + 'static, +{ + move |req| { + if matches!(req.method, Method::Get | Method::Head | Method::Options) { + return None; + } + if req.header("authorization").is_some() { + return None; + } + let s = auth.sessions.load(req); + let presented = req.header("x-csrf-token").unwrap_or(""); + if auth.sessions.verify_csrf(&s, presented) { + None + } else { + Some(json( + 419, // Laravel's "Page Expired" — distinct from a plain 403 + &Json::obj(vec![("error", Json::str("csrf token mismatch"))]), + )) + } + } +} + /// Guard: reject with `401` unless the request carries a valid /// `Authorization: Bearer stg_…` API token — the agent/service door. /// Handlers can identify the caller with [`token_user`]. @@ -253,6 +469,7 @@ mod tests { let auth = Auth { users: Users::new(auth_rig.users.backend().clone()).iterations(1_000), sessions: Sessions::new(b"test-secret").insecure(), + remember: None, ttl: 0, // expires immediately (ttl(0) would clamp to 1) }; let user = auth.users.register("x@y.co", "password1").unwrap(); @@ -314,6 +531,187 @@ mod tests { assert_eq!(owner.id, user.id); } + fn rig_remembered() -> Arc> { + let db = Db::memory().unwrap(); + let users = Users::new(db.clone()).iterations(1_000); + users.migrate().unwrap(); + let remember = Remember::new(db).insecure(); + remember.migrate().unwrap(); + Arc::new(Auth::new(users, Sessions::new(b"test-secret").insecure()).remember(remember)) + } + + fn cookies_of(resp: &Response) -> Vec<(String, String)> { + resp.headers + .iter() + .filter(|(k, _)| k.eq_ignore_ascii_case("set-cookie")) + .filter_map(|(_, v)| { + v.split(';') + .next()? + .split_once('=') + .map(|(k, v)| (k.to_string(), v.to_string())) + }) + .collect() + } + + #[test] + fn password_change_invalidates_other_sessions() { + let auth = rig(); + let user = auth.users.register("p@a.co", "password1").unwrap(); + let cookie = cookie_of(&auth.login(&request(vec![]), &user, Response::new(200))); + let req = request(vec![("Cookie".into(), cookie)]); + assert!(auth.current(&req).unwrap().is_some()); + + auth.users.set_password(user.id, "newpassword").unwrap(); + // Cookie-only check still passes (documented); the store check dies. + assert_eq!(auth.user_id(&req), Some(user.id)); + assert!(auth.current(&req).unwrap().is_none()); + assert!(auth.identify(&req).unwrap().is_none()); + } + + #[test] + fn remember_revives_expired_session_and_rotates() { + let auth = rig_remembered(); + let user = auth.users.register("r@a.co", "password1").unwrap(); + let resp = auth + .login_remembered(&request(vec![]), &user, Response::new(200)) + .unwrap(); + let jar = cookies_of(&resp); + assert_eq!(jar.len(), 2, "session + remember cookies"); + let remember_cookie = jar + .iter() + .find(|(k, _)| k == REMEMBER_COOKIE) + .map(|(_, v)| v.clone()) + .unwrap(); + + // Only the remember cookie survives (session expired / new browser). + let req = request(vec![( + "Cookie".into(), + format!("{REMEMBER_COOKIE}={remember_cookie}"), + )]); + assert!(auth.current(&req).unwrap().is_none()); + let hit = auth.identify(&req).unwrap().unwrap(); + assert_eq!(hit.user.id, user.id); + assert!(hit.via_remember); + let fresh = cookies_of(&hit.attach(Response::new(200))); + assert_eq!(fresh.len(), 2, "revival mints session + rotated remember"); + let rotated = fresh + .iter() + .find(|(k, _)| k == REMEMBER_COOKIE) + .map(|(_, v)| v.clone()) + .unwrap(); + assert_ne!(rotated, remember_cookie); + + // The revived session cookie works on its own. + let session_cookie = fresh + .iter() + .find(|(k, _)| k == "sutegi_session") + .map(|(_, v)| v.clone()) + .unwrap(); + let next = request(vec![( + "Cookie".into(), + format!("sutegi_session={session_cookie}"), + )]); + assert_eq!(auth.current(&next).unwrap().unwrap().id, user.id); + + // The pre-rotation remember cookie is dead (and burns the row). + assert!(auth.identify(&req).unwrap().is_none()); + + // Password change kills remember tokens too. + let user2 = auth.users.register("s@a.co", "password1").unwrap(); + let resp2 = auth + .login_remembered(&request(vec![]), &user2, Response::new(200)) + .unwrap(); + let rc2 = cookies_of(&resp2) + .iter() + .find(|(k, _)| k == REMEMBER_COOKIE) + .map(|(_, v)| v.clone()) + .unwrap(); + auth.users.set_password(user2.id, "changed-pass").unwrap(); + let req2 = request(vec![("Cookie".into(), format!("{REMEMBER_COOKIE}={rc2}"))]); + assert!(auth.identify(&req2).unwrap().is_none()); + } + + #[test] + fn logout_from_revokes_and_clears() { + let auth = rig_remembered(); + let user = auth.users.register("l@a.co", "password1").unwrap(); + let resp = auth + .login_remembered(&request(vec![]), &user, Response::new(200)) + .unwrap(); + let rc = cookies_of(&resp) + .iter() + .find(|(k, _)| k == REMEMBER_COOKIE) + .map(|(_, v)| v.clone()) + .unwrap(); + + let req = request(vec![("Cookie".into(), format!("{REMEMBER_COOKIE}={rc}"))]); + let out = auth.logout_from(&req, Response::new(200)); + let cleared = cookies_of(&out); + assert_eq!(cleared.len(), 2, "clears both cookies"); + assert!(cleared.iter().all(|(_, v)| v.is_empty())); + // The token row is gone: revival is impossible. + assert!(auth.identify(&req).unwrap().is_none()); + } + + #[test] + fn logout_everywhere_kills_all_remember_tokens() { + let auth = rig_remembered(); + let user = auth.users.register("e@a.co", "password1").unwrap(); + for _ in 0..3 { + auth.login_remembered(&request(vec![]), &user, Response::new(200)) + .unwrap(); + } + assert_eq!(auth.logout_everywhere(user.id).unwrap(), 3); + } + + #[test] + fn csrf_guard_gates_mutations() { + let auth = rig(); + let (token, resp) = auth.csrf(&request(vec![]), Response::new(200)).unwrap(); + let cookie = cookie_of(&resp); + let guard = require_csrf(auth.clone()); + + let mut ok = request(vec![ + ("Cookie".into(), cookie.clone()), + ("X-CSRF-Token".into(), token.clone()), + ]); + ok.method = Method::Post; + assert!(guard(&ok).is_none()); + + let mut missing = request(vec![("Cookie".into(), cookie.clone())]); + missing.method = Method::Post; + assert_eq!(guard(&missing).unwrap().status, 419); + + let mut wrong = request(vec![ + ("Cookie".into(), cookie), + ("X-CSRF-Token".into(), "forged".into()), + ]); + wrong.method = Method::Delete; + assert_eq!(guard(&wrong).unwrap().status, 419); + + // Reads pass without a token; bearer callers pass on any method. + let get = request(vec![]); + assert!(guard(&get).is_none()); + let mut bearer = request(vec![("Authorization".into(), "Bearer stg_x".into())]); + bearer.method = Method::Post; + assert!(guard(&bearer).is_none()); + } + + #[test] + fn verified_guard() { + let auth = rig(); + let user = auth.users.register("v@a.co", "password1").unwrap(); + let req = request(vec![( + "Cookie".into(), + cookie_of(&auth.login(&request(vec![]), &user, Response::new(200))), + )]); + let guard = require_verified(auth.clone()); + assert_eq!(guard(&req).unwrap().status, 403); + assert_eq!(guard(&request(vec![])).unwrap().status, 401); + auth.users.mark_verified(user.id).unwrap(); + assert!(guard(&req).is_none()); + } + #[test] fn tampered_cookie_is_anonymous() { let auth = rig(); diff --git a/crates/sutegi-auth/src/password.rs b/crates/sutegi-auth/src/password.rs index 9fadc0f..65e70ae 100644 --- a/crates/sutegi-auth/src/password.rs +++ b/crates/sutegi-auth/src/password.rs @@ -62,6 +62,14 @@ pub fn needs_rehash(stored: &str, iterations: u32) -> bool { } } +/// A short fingerprint of a stored hash — what sessions, remember tokens, +/// and reset links bind to so they die when the password changes. Not +/// secret-bearing: 16 hex chars of SHA-256 over the PHC string. +pub(crate) fn fingerprint(stored_hash: &str) -> String { + use sutegi_crypto::{hex, sha256}; + hex(&sha256(stored_hash.as_bytes()))[..16].to_string() +} + fn parse_phc(stored: &str) -> Option<(u32, Vec, Vec)> { let mut parts = stored.split('$'); if !parts.next()?.is_empty() { diff --git a/crates/sutegi-auth/src/remember.rs b/crates/sutegi-auth/src/remember.rs new file mode 100644 index 0000000..019fc2b --- /dev/null +++ b/crates/sutegi-auth/src/remember.rs @@ -0,0 +1,318 @@ +//! **Remember me** — the long-lived login that survives the session cookie, +//! done the selector/validator way rather than Laravel's single +//! `remember_token` column: +//! +//! - the cookie is `.`; only the validator's SHA-256 is +//! stored, so a leaked table recalls nobody; +//! - the validator **rotates on every use** — a stolen-then-used cookie +//! invalidates the victim's copy, which surfaces the theft as a logout; +//! - each token is **bound to the password hash** at mint time, so changing +//! the password silently kills every outstanding remember cookie; +//! - tokens expire server-side (default 30 days) regardless of the cookie's +//! `Max-Age`. +//! +//! Pair it with [`crate::Auth`]: `Auth::remember(store)` makes +//! `login_remembered` mint the cookie and `identify` revive expired sessions +//! from it. + +use crate::users::now_secs; +use sutegi_crypto::{constant_time_eq, hex, random_bytes, sha256}; +use sutegi_json::Json; +use sutegi_orm::{Backend, ColType, Column, TableSchema, Value}; +use sutegi_web::Request; + +/// Default cookie name. +pub const REMEMBER_COOKIE: &str = "sutegi_remember"; + +/// Default server-side lifetime: 30 days. +pub const REMEMBER_TTL: i64 = 30 * 86_400; + +/// The remember-token store, over any ORM [`Backend`]. +pub struct Remember { + backend: B, + ttl: i64, + cookie: String, + secure: bool, +} + +impl Remember { + pub fn new(backend: B) -> Remember { + Remember { + backend, + ttl: REMEMBER_TTL, + cookie: REMEMBER_COOKIE.to_string(), + secure: true, + } + } + + /// Server-side token lifetime in seconds (default 30 days). + pub fn ttl(mut self, secs: i64) -> Remember { + self.ttl = secs.max(1); + self + } + + pub fn cookie_name(mut self, name: &str) -> Remember { + self.cookie = name.to_string(); + self + } + + /// Drop the cookie's `Secure` attribute (local `http://` dev only). + pub fn insecure(mut self) -> Remember { + self.secure = false; + self + } + + /// Create the `remember_tokens` table and its selector index if absent. + pub fn migrate(&self) -> Result<(), String> { + self.backend.migrate( + &TableSchema::new("remember_tokens") + .column(Column::new("id", ColType::Integer).primary()) + .column(Column::new("user_id", ColType::Integer)) + .column(Column::new("selector", ColType::Text)) + .column(Column::new("validator_hash", ColType::Text)) + .column(Column::new("pw_bind", ColType::Text)) + .column(Column::new("created_at", ColType::Integer)) + .column(Column::new("last_used_at", ColType::Integer)) + .column(Column::new("expires_at", ColType::Integer)), + )?; + self.backend + .execute( + "CREATE UNIQUE INDEX IF NOT EXISTS remember_selector_unique \ + ON remember_tokens (selector)", + &[], + ) + .map(|_| ()) + } + + /// Mint a token for `user_id`, bound to `pw_bind` (a fingerprint of the + /// current password hash — [`crate::Auth`] supplies it). Returns the + /// cookie **value**; wrap it with [`cookie_header`](Remember::cookie_header). + pub fn issue(&self, user_id: i64, pw_bind: &str) -> Result { + let selector = hex(&random_bytes(9)?); + let validator = hex(&random_bytes(32)?); + let now = now_secs(); + self.backend.insert( + "remember_tokens", + &[ + ("user_id", Value::Int(user_id)), + ("selector", Value::Text(selector.clone())), + ("validator_hash", Value::Text(hash_of(&validator))), + ("pw_bind", Value::Text(pw_bind.to_string())), + ("created_at", Value::Int(now)), + ("last_used_at", Value::Int(now)), + ("expires_at", Value::Int(now + self.ttl)), + ], + "id", + )?; + Ok(format!("{selector}.{validator}")) + } + + /// Redeem a presented cookie value. `current_bind_of` maps a candidate + /// user id to the fingerprint of their **current** password hash (`None` + /// = user gone); a stale binding is a dead token. On success the + /// validator is **rotated** and the fresh cookie value returned alongside + /// the user id. + pub fn consume( + &self, + presented: &str, + current_bind_of: impl FnOnce(i64) -> Result, String>, + ) -> Result, String> { + let Some((selector, validator)) = presented.split_once('.') else { + return Ok(None); + }; + let Some(row) = self.backend.query_one( + "SELECT id, user_id, validator_hash, pw_bind, expires_at \ + FROM remember_tokens WHERE selector = ?", + &[Value::Text(selector.to_string())], + )? + else { + return Ok(None); + }; + let int_of = |k: &str| row.get(k).and_then(Json::as_f64).map(|f| f as i64); + let (Some(id), Some(user_id), Some(expires_at)) = + (int_of("id"), int_of("user_id"), int_of("expires_at")) + else { + return Ok(None); + }; + let stored = row + .get("validator_hash") + .and_then(Json::as_str) + .unwrap_or(""); + if !constant_time_eq(hash_of(validator).as_bytes(), stored.as_bytes()) { + // Correct selector, wrong validator: either garbage or a copy + // that was already rotated away — revoke the row so a possibly + // stolen token can't be brute-forced in place. + let _ = self.backend.execute( + "DELETE FROM remember_tokens WHERE id = ?", + &[Value::Int(id)], + ); + return Ok(None); + } + if expires_at < now_secs() { + let _ = self.backend.execute( + "DELETE FROM remember_tokens WHERE id = ?", + &[Value::Int(id)], + ); + return Ok(None); + } + let bind = row.get("pw_bind").and_then(Json::as_str).unwrap_or(""); + if current_bind_of(user_id)?.as_deref() != Some(bind) { + let _ = self.backend.execute( + "DELETE FROM remember_tokens WHERE id = ?", + &[Value::Int(id)], + ); + return Ok(None); // password changed since mint + } + // Rotate the validator in place; the old cookie value is now dead. + let fresh = hex(&random_bytes(32)?); + self.backend.execute( + "UPDATE remember_tokens SET validator_hash = ?, last_used_at = ? WHERE id = ?", + &[ + Value::Text(hash_of(&fresh)), + Value::Int(now_secs()), + Value::Int(id), + ], + )?; + Ok(Some((user_id, format!("{selector}.{fresh}")))) + } + + /// Revoke the token behind a presented cookie value (logout on this + /// device). Returns `true` if a row was removed. + pub fn revoke_presented(&self, presented: &str) -> Result { + let Some((selector, _)) = presented.split_once('.') else { + return Ok(false); + }; + Ok(self.backend.execute( + "DELETE FROM remember_tokens WHERE selector = ?", + &[Value::Text(selector.to_string())], + )? > 0) + } + + /// Revoke every remember token a user holds (logout everywhere). + pub fn revoke_all(&self, user_id: i64) -> Result { + self.backend.execute( + "DELETE FROM remember_tokens WHERE user_id = ?", + &[Value::Int(user_id)], + ) + } + + /// The presented cookie value on a request, if any. + pub fn read(&self, req: &Request) -> Option { + req.cookie(&self.cookie) + } + + /// A `Set-Cookie` header value carrying `value` for the token's lifetime. + pub fn cookie_header(&self, value: &str) -> String { + let mut c = format!( + "{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}", + self.cookie, self.ttl + ); + if self.secure { + c.push_str("; Secure"); + } + c + } + + /// A `Set-Cookie` header value that expires the cookie. + pub fn clear_header(&self) -> String { + format!("{}=; Path=/; Max-Age=0", self.cookie) + } +} + +fn hash_of(validator: &str) -> String { + hex(&sha256(validator.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + use sutegi_orm::db::Db; + + fn store() -> Remember { + let r = Remember::new(Db::memory().unwrap()).insecure(); + r.migrate().unwrap(); + r + } + + fn bind_ok(_: i64) -> Result, String> { + Ok(Some("bind".to_string())) + } + + #[test] + fn issue_consume_rotates() { + let r = store(); + let cookie = r.issue(7, "bind").unwrap(); + let (uid, fresh) = r.consume(&cookie, bind_ok).unwrap().unwrap(); + assert_eq!(uid, 7); + assert_ne!(fresh, cookie); + // The old value is dead, the rotated one lives. + assert!(r.consume(&cookie, bind_ok).unwrap().is_none()); + // Old value even revoked the row (theft response) — fresh dies too. + assert!(r.consume(&fresh, bind_ok).unwrap().is_none()); + } + + #[test] + fn rotation_chain_survives_when_only_fresh_is_used() { + let r = store(); + let mut cookie = r.issue(1, "bind").unwrap(); + for _ in 0..3 { + let (uid, fresh) = r.consume(&cookie, bind_ok).unwrap().unwrap(); + assert_eq!(uid, 1); + cookie = fresh; + } + } + + #[test] + fn password_change_kills_token() { + let r = store(); + let cookie = r.issue(1, "old-bind").unwrap(); + let hit = r + .consume(&cookie, |_| Ok(Some("new-bind".to_string()))) + .unwrap(); + assert!(hit.is_none()); + // And the row is gone: even the original bind can't revive it. + assert!(r.consume(&cookie, bind_ok).unwrap().is_none()); + } + + #[test] + fn expiry_and_revocation() { + let r = Remember::new(Db::memory().unwrap()).ttl(1).insecure(); + r.migrate().unwrap(); + // ttl(1) then a backdated row: simulate expiry directly. + let cookie = r.issue(2, "bind").unwrap(); + r.backend + .execute("UPDATE remember_tokens SET expires_at = 1", &[]) + .unwrap(); + assert!(r.consume(&cookie, bind_ok).unwrap().is_none()); + + let c2 = r.issue(2, "bind").unwrap(); + let c3 = r.issue(2, "bind").unwrap(); + assert!(r.revoke_presented(&c2).unwrap()); + assert!(!r.revoke_presented(&c2).unwrap()); + assert_eq!(r.revoke_all(2).unwrap(), 1); + assert!(r.consume(&c3, bind_ok).unwrap().is_none()); + } + + #[test] + fn garbage_cookies_are_none() { + let r = store(); + assert!(r.consume("no-dot", bind_ok).unwrap().is_none()); + assert!(r.consume("", bind_ok).unwrap().is_none()); + assert!(r.consume("dead.beef", bind_ok).unwrap().is_none()); + assert!(!r.revoke_presented("no-dot").unwrap()); + } + + #[test] + fn cookie_headers() { + let r = Remember::new(Db::memory().unwrap()).ttl(60); + let h = r.cookie_header("abc.def"); + assert!(h.starts_with("sutegi_remember=abc.def;")); + assert!(h.contains("HttpOnly") && h.contains("Secure") && h.contains("Max-Age=60")); + assert!(r.clear_header().contains("Max-Age=0")); + let ins = Remember::new(Db::memory().unwrap()) + .insecure() + .cookie_name("r"); + assert!(!ins.cookie_header("v").contains("Secure")); + assert!(ins.cookie_header("v").starts_with("r=v;")); + } +} diff --git a/crates/sutegi-auth/src/throttle.rs b/crates/sutegi-auth/src/throttle.rs new file mode 100644 index 0000000..4bc16a1 --- /dev/null +++ b/crates/sutegi-auth/src/throttle.rs @@ -0,0 +1,202 @@ +//! **Login throttling** — Laravel's `ThrottlesLogins`, DB-backed so every pod +//! counts the same attempts. Fixed window: `max` hits per `per` seconds per +//! key, where the key is whatever you rate — the convention for login is +//! `login:|`. +//! +//! ```ignore +//! let throttle = Throttle::new(db.clone()); // 5 attempts / 60 s +//! let key = format!("login:{email}|{ip}"); +//! if let Some(retry) = throttle.too_many(&key)? { +//! return Err(Error::too_many_requests(format!("retry in {retry}s"))); +//! } +//! match auth.users.authenticate(&email, &pw)? { +//! Some(user) => { throttle.clear(&key)?; /* login */ } +//! None => { throttle.hit(&key)?; /* 401 */ } +//! } +//! ``` + +use crate::users::now_secs; +use sutegi_json::Json; +use sutegi_orm::{Backend, ColType, Column, TableSchema, Value}; + +/// The throttle store, over any ORM [`Backend`]. +pub struct Throttle { + backend: B, + max: i64, + per: i64, +} + +impl Throttle { + /// Laravel's defaults: 5 attempts per 60-second window. + pub fn new(backend: B) -> Throttle { + Throttle { + backend, + max: 5, + per: 60, + } + } + + /// Attempts allowed per window. + pub fn max(mut self, n: i64) -> Throttle { + self.max = n.max(1); + self + } + + /// Window length in seconds. + pub fn per(mut self, secs: i64) -> Throttle { + self.per = secs.max(1); + self + } + + /// Create the `auth_throttle` table and its key index if absent. + pub fn migrate(&self) -> Result<(), String> { + self.backend.migrate( + &TableSchema::new("auth_throttle") + .column(Column::new("id", ColType::Integer).primary()) + .column(Column::new("key", ColType::Text)) + .column(Column::new("attempts", ColType::Integer)) + .column(Column::new("window_start", ColType::Integer)), + )?; + self.backend + .execute( + "CREATE UNIQUE INDEX IF NOT EXISTS auth_throttle_key_unique \ + ON auth_throttle (key)", + &[], + ) + .map(|_| ()) + } + + /// Whether `key` is locked out right now; `Some(secs)` says how long + /// until the window reopens. Read-only — pair with [`hit`](Throttle::hit) + /// on failures and [`clear`](Throttle::clear) on success. + pub fn too_many(&self, key: &str) -> Result, String> { + let Some((attempts, window_start)) = self.row(key)? else { + return Ok(None); + }; + let now = now_secs(); + if attempts >= self.max && now < window_start + self.per { + Ok(Some(window_start + self.per - now)) + } else { + Ok(None) + } + } + + /// Record a failed attempt; returns the attempt count in the current + /// window. The increment is a single atomic `UPDATE`, so concurrent + /// failures across pods all count. + pub fn hit(&self, key: &str) -> Result { + let now = now_secs(); + match self.row(key)? { + None => { + // The unique index backstops the insert race: a concurrent + // first-hit surfaces as a constraint error → count via UPDATE. + if self + .backend + .insert( + "auth_throttle", + &[ + ("key", Value::Text(key.to_string())), + ("attempts", Value::Int(1)), + ("window_start", Value::Int(now)), + ], + "id", + ) + .is_ok() + { + return Ok(1); + } + self.bump(key) + } + Some((_, window_start)) if now >= window_start + self.per => { + // Window elapsed: restart it. + self.backend.execute( + "UPDATE auth_throttle SET attempts = 1, window_start = ? WHERE key = ?", + &[Value::Int(now), Value::Text(key.to_string())], + )?; + Ok(1) + } + Some(_) => self.bump(key), + } + } + + /// Forget `key` (successful login). + pub fn clear(&self, key: &str) -> Result<(), String> { + self.backend + .execute( + "DELETE FROM auth_throttle WHERE key = ?", + &[Value::Text(key.to_string())], + ) + .map(|_| ()) + } + + fn bump(&self, key: &str) -> Result { + self.backend.execute( + "UPDATE auth_throttle SET attempts = attempts + 1 WHERE key = ?", + &[Value::Text(key.to_string())], + )?; + Ok(self.row(key)?.map(|(a, _)| a).unwrap_or(1)) + } + + fn row(&self, key: &str) -> Result, String> { + Ok(self + .backend + .query_one( + "SELECT attempts, window_start FROM auth_throttle WHERE key = ?", + &[Value::Text(key.to_string())], + )? + .and_then(|r| { + let int_of = |k: &str| r.get(k).and_then(Json::as_f64).map(|f| f as i64); + Some((int_of("attempts")?, int_of("window_start")?)) + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sutegi_orm::db::Db; + + fn store(max: i64, per: i64) -> Throttle { + let t = Throttle::new(Db::memory().unwrap()).max(max).per(per); + t.migrate().unwrap(); + t + } + + #[test] + fn locks_after_max_and_reports_retry() { + let t = store(3, 60); + assert_eq!(t.too_many("k").unwrap(), None); + assert_eq!(t.hit("k").unwrap(), 1); + assert_eq!(t.hit("k").unwrap(), 2); + assert_eq!(t.too_many("k").unwrap(), None); // 2 < 3 + assert_eq!(t.hit("k").unwrap(), 3); + let retry = t.too_many("k").unwrap().unwrap(); + assert!(retry > 0 && retry <= 60, "retry_after = {retry}"); + // Other keys are unaffected. + assert_eq!(t.too_many("other").unwrap(), None); + } + + #[test] + fn clear_reopens() { + let t = store(1, 60); + t.hit("k").unwrap(); + assert!(t.too_many("k").unwrap().is_some()); + t.clear("k").unwrap(); + assert_eq!(t.too_many("k").unwrap(), None); + } + + #[test] + fn elapsed_window_restarts() { + let t = store(2, 60); + t.hit("k").unwrap(); + t.hit("k").unwrap(); + assert!(t.too_many("k").unwrap().is_some()); + // Backdate the window past its length: the lock is over and the next + // hit starts a fresh count. + t.backend + .execute("UPDATE auth_throttle SET window_start = 1", &[]) + .unwrap(); + assert_eq!(t.too_many("k").unwrap(), None); + assert_eq!(t.hit("k").unwrap(), 1); + } +} diff --git a/crates/sutegi-auth/src/tokens.rs b/crates/sutegi-auth/src/tokens.rs index 7481033..789eacc 100644 --- a/crates/sutegi-auth/src/tokens.rs +++ b/crates/sutegi-auth/src/tokens.rs @@ -24,6 +24,10 @@ pub struct ApiToken { /// A human label ("ci-deploy", "claude-agent"), for revocation lists. pub name: String, pub created_at: i64, + /// Unix seconds after which the token stops verifying; `0` = never. + pub expires_at: i64, + /// Unix seconds of the last successful verify; `0` = never used. + pub last_used_at: i64, } impl ApiToken { @@ -33,6 +37,8 @@ impl ApiToken { ("user_id", Json::int(self.user_id)), ("name", Json::str(self.name.clone())), ("created_at", Json::int(self.created_at)), + ("expires_at", Json::int(self.expires_at)), + ("last_used_at", Json::int(self.last_used_at)), ]) } } @@ -55,8 +61,18 @@ impl Tokens { .column(Column::new("user_id", ColType::Integer)) .column(Column::new("name", ColType::Text)) .column(Column::new("token_hash", ColType::Text)) - .column(Column::new("created_at", ColType::Integer)), + .column(Column::new("created_at", ColType::Integer)) + .column(Column::new("expires_at", ColType::Integer)) + .column(Column::new("last_used_at", ColType::Integer)), )?; + // Upgrade path for tables created before expiry/usage existed; the + // "duplicate column" error on an already-current table is expected. + for col in ["expires_at", "last_used_at"] { + let _ = self.backend.execute( + &format!("ALTER TABLE api_tokens ADD COLUMN {col} BIGINT NOT NULL DEFAULT 0"), + &[], + ); + } self.backend .execute( "CREATE UNIQUE INDEX IF NOT EXISTS api_tokens_hash_unique ON api_tokens (token_hash)", @@ -65,9 +81,29 @@ impl Tokens { .map(|_| ()) } - /// Mint a token for `user_id`. Returns `(plaintext, record)` — show or - /// deliver the plaintext now; it cannot be recovered later. + /// Mint a non-expiring token for `user_id`. Returns `(plaintext, record)` + /// — show or deliver the plaintext now; it cannot be recovered later. pub fn issue(&self, user_id: i64, name: &str) -> Result<(String, ApiToken), String> { + self.issue_with(user_id, name, 0) + } + + /// Mint a token that stops verifying after `ttl_secs` (Sanctum's token + /// expiration). + pub fn issue_expiring( + &self, + user_id: i64, + name: &str, + ttl_secs: i64, + ) -> Result<(String, ApiToken), String> { + self.issue_with(user_id, name, now_secs() + ttl_secs.max(1)) + } + + fn issue_with( + &self, + user_id: i64, + name: &str, + expires_at: i64, + ) -> Result<(String, ApiToken), String> { let plaintext = format!("{TOKEN_PREFIX}{}", hex(&random_bytes(32)?)); let created_at = now_secs(); let id = self.backend.insert( @@ -77,6 +113,8 @@ impl Tokens { ("name", Value::Text(name.to_string())), ("token_hash", Value::Text(hash_of(&plaintext))), ("created_at", Value::Int(created_at)), + ("expires_at", Value::Int(expires_at)), + ("last_used_at", Value::Int(0)), ], "id", )?; @@ -87,33 +125,48 @@ impl Tokens { user_id, name: name.to_string(), created_at, + expires_at, + last_used_at: 0, }, )) } - /// Resolve a presented token to its owning user id, or `None`. The lookup - /// is by SHA-256 — equality on an unpredictable 256-bit value, so an index - /// probe leaks nothing usable. + /// Resolve a presented token to its owning user id, or `None` (unknown + /// or expired). The lookup is by SHA-256 — equality on an unpredictable + /// 256-bit value, so an index probe leaks nothing usable. A hit stamps + /// `last_used_at` (best-effort). pub fn verify(&self, presented: &str) -> Result, String> { if !presented.starts_with(TOKEN_PREFIX) { return Ok(None); } - Ok(self - .backend - .query_one( - "SELECT user_id FROM api_tokens WHERE token_hash = ?", - &[Value::Text(hash_of(presented))], - )? - .and_then(|r| r.get("user_id").and_then(Json::as_f64)) - .map(|f| f as i64)) + let Some(row) = self.backend.query_one( + "SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?", + &[Value::Text(hash_of(presented))], + )? + else { + return Ok(None); + }; + let int_of = |k: &str| row.get(k).and_then(Json::as_f64).map(|f| f as i64); + let (Some(id), Some(user_id)) = (int_of("id"), int_of("user_id")) else { + return Ok(None); + }; + let expires_at = int_of("expires_at").unwrap_or(0); + if expires_at > 0 && expires_at < now_secs() { + return Ok(None); + } + let _ = self.backend.execute( + "UPDATE api_tokens SET last_used_at = ? WHERE id = ?", + &[Value::Int(now_secs()), Value::Int(id)], + ); + Ok(Some(user_id)) } /// A user's tokens (metadata only), newest first. pub fn list(&self, user_id: i64) -> Result, String> { self.backend .query( - "SELECT id, user_id, name, created_at FROM api_tokens \ - WHERE user_id = ? ORDER BY id DESC", + "SELECT id, user_id, name, created_at, expires_at, last_used_at \ + FROM api_tokens WHERE user_id = ? ORDER BY id DESC", &[Value::Int(user_id)], )? .iter() @@ -158,6 +211,8 @@ fn token_of(row: &Json) -> Result { .unwrap_or_default() .to_string(), created_at: int_of("created_at")?, + expires_at: int_of("expires_at").unwrap_or(0), + last_used_at: int_of("last_used_at").unwrap_or(0), }) } @@ -202,6 +257,31 @@ mod tests { assert!(!stored.contains(&plain[4..20])); } + #[test] + fn expiry_and_last_used() { + let tokens = store(); + let (fresh, rec) = tokens.issue_expiring(3, "short", 3_600).unwrap(); + assert!(rec.expires_at > now_secs()); + assert_eq!(tokens.verify(&fresh).unwrap(), Some(3)); + // A verify stamps last_used_at. + let listed = &tokens.list(3).unwrap()[0]; + assert!(listed.last_used_at > 0); + + // Backdate the expiry: the token stops verifying. + tokens + .backend + .execute( + "UPDATE api_tokens SET expires_at = 1 WHERE id = ?", + &[Value::Int(rec.id)], + ) + .unwrap(); + assert_eq!(tokens.verify(&fresh).unwrap(), None); + + // Non-expiring tokens (expires_at = 0) still verify. + let (forever, _) = tokens.issue(3, "forever").unwrap(); + assert_eq!(tokens.verify(&forever).unwrap(), Some(3)); + } + #[test] fn list_and_revoke_all() { let tokens = store(); diff --git a/crates/sutegi-auth/src/users.rs b/crates/sutegi-auth/src/users.rs index b4257f4..d8667d0 100644 --- a/crates/sutegi-auth/src/users.rs +++ b/crates/sutegi-auth/src/users.rs @@ -6,7 +6,7 @@ //! comparable time on unknown emails so a missing account is not //! distinguishable from a wrong password by timing. -use crate::password::{hash_password_with, verify_password, DEFAULT_ITERATIONS}; +use crate::password::{hash_password_with, needs_rehash, verify_password, DEFAULT_ITERATIONS}; use std::sync::OnceLock; use sutegi_json::Json; use sutegi_orm::{Backend, ColType, Column, TableSchema, Value}; @@ -180,7 +180,19 @@ impl Users { .and_then(Json::as_str) .unwrap_or(""); if verify_password(password, hash) { - Ok(Some(user_of(&row)?)) + let user = user_of(&row)?; + // Work factor raised since this hash was made? Upgrade it + // while we hold the plaintext — best-effort, a rehash + // failure must never fail a valid login. + if needs_rehash(hash, self.iterations) { + if let Ok(fresh) = hash_password_with(password, self.iterations) { + let _ = self.backend.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + &[Value::Text(fresh), Value::Int(user.id)], + ); + } + } + Ok(Some(user)) } else { Ok(None) } @@ -235,6 +247,53 @@ impl Users { } } + /// Replace a user's password **after verifying the current one** — the + /// profile-screen shape (Laravel's `current_password` rule). `Ok(false)` + /// = wrong current password; `Err` is reserved for store failures. + pub fn change_password( + &self, + id: i64, + current: &str, + new_password: &str, + ) -> Result { + let Some(hash) = self.password_hash_of(id)? else { + return Err(format!("no user with id {id}")); + }; + if !verify_password(current, &hash) { + return Ok(false); + } + self.set_password(id, new_password)?; + Ok(true) + } + + /// Change a user's display name. + pub fn set_name(&self, id: i64, name: &str) -> Result<(), String> { + match self.backend.execute( + "UPDATE users SET name = ? WHERE id = ?", + &[Value::Text(name.to_string()), Value::Int(id)], + )? { + 0 => Err(format!("no user with id {id}")), + _ => Ok(()), + } + } + + /// Change a user's email. The new address starts **unverified** + /// (`verified_at` resets to 0 — send a fresh verification link). Fails + /// on a malformed or already-taken address. + pub fn set_email(&self, id: i64, email: &str) -> Result<(), String> { + let email = normalize_email(email)?; + if self.find_by_email(&email)?.is_some_and(|u| u.id != id) { + return Err("email already registered".to_string()); + } + match self.backend.execute( + "UPDATE users SET email = ?, verified_at = 0 WHERE id = ?", + &[Value::Text(email), Value::Int(id)], + )? { + 0 => Err(format!("no user with id {id}")), + _ => Ok(()), + } + } + /// Change a user's role. pub fn set_role(&self, id: i64, role: &str) -> Result<(), String> { match self.backend.execute( @@ -431,6 +490,73 @@ mod tests { assert!(users.set_password(999, "whatever12").is_err()); } + #[test] + fn change_password_requires_current() { + let users = store(); + let u = users.register("a@b.co", "oldpassword").unwrap(); + assert!(!users + .change_password(u.id, "wrong-pass", "newpassword") + .unwrap()); + assert!(users + .authenticate("a@b.co", "oldpassword") + .unwrap() + .is_some()); + assert!(users + .change_password(u.id, "oldpassword", "newpassword") + .unwrap()); + assert!(users + .authenticate("a@b.co", "newpassword") + .unwrap() + .is_some()); + assert!(users.change_password(999, "x", "whatever12").is_err()); + } + + #[test] + fn set_name_and_email_reset_verification() { + let users = store(); + let u = users + .register_with("a@b.co", "password1", "Ana", "user") + .unwrap(); + users.mark_verified(u.id).unwrap(); + assert!(users.find(u.id).unwrap().unwrap().is_verified()); + + users.set_name(u.id, "Ane").unwrap(); + assert_eq!(users.find(u.id).unwrap().unwrap().name, "Ane"); + + // Email change lands normalized and unverified. + users.set_email(u.id, " NEW@B.CO ").unwrap(); + let after = users.find(u.id).unwrap().unwrap(); + assert_eq!(after.email, "new@b.co"); + assert!(!after.is_verified()); + // Setting your own address again is fine; someone else's is not. + users.set_email(u.id, "new@b.co").unwrap(); + let other = users.register("taken@b.co", "password1").unwrap(); + assert!(users.set_email(u.id, "taken@b.co").is_err()); + assert!(users.set_email(other.id, "not-an-email").is_err()); + } + + #[test] + fn login_rehashes_weaker_hashes() { + let users = store(); // iterations(1_000) + let u = users.register("a@b.co", "password1").unwrap(); + // Downgrade the stored hash below the store's work factor. + let weak = hash_password_with("password1", 500).unwrap(); + users + .backend + .execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + &[Value::Text(weak), Value::Int(u.id)], + ) + .unwrap(); + assert!(users.authenticate("a@b.co", "password1").unwrap().is_some()); + let upgraded = users.password_hash_of(u.id).unwrap().unwrap(); + assert!( + upgraded.contains("i=1000"), + "hash was not upgraded: {upgraded}" + ); + assert!(users.authenticate("a@b.co", "password1").unwrap().is_some()); + } + #[test] fn user_json_has_no_hash() { let users = store(); diff --git a/crates/sutegi-channels/Cargo.toml b/crates/sutegi-channels/Cargo.toml index c6a06bd..208ee07 100644 --- a/crates/sutegi-channels/Cargo.toml +++ b/crates/sutegi-channels/Cargo.toml @@ -11,10 +11,10 @@ keywords = ["channels", "websocket", "realtime", "pubsub", "zero-dependency"] categories = ["web-programming", "web-programming::websocket"] [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-http = { path = "../sutegi-http", version = "0.7.0" } -sutegi-ws = { path = "../sutegi-ws", version = "0.7.0" } -sutegi-pubsub = { path = "../sutegi-pubsub", version = "0.7.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-http = { path = "../sutegi-http", version = "0.8.0" } +sutegi-ws = { path = "../sutegi-ws", version = "0.8.0" } +sutegi-pubsub = { path = "../sutegi-pubsub", version = "0.8.0" } [features] # Presence: a per-pod tracker over the Broker seam with cross-pod state sync diff --git a/crates/sutegi-cli/Cargo.toml b/crates/sutegi-cli/Cargo.toml index 12d00fb..82eec44 100644 --- a/crates/sutegi-cli/Cargo.toml +++ b/crates/sutegi-cli/Cargo.toml @@ -13,6 +13,6 @@ name = "sutegi" path = "src/main.rs" [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } # Remote-only REPL (no `orm`): data lives behind the running app's surface. -sutegi-repl = { path = "../sutegi-repl", version = "0.7.0" } +sutegi-repl = { path = "../sutegi-repl", version = "0.8.0" } diff --git a/crates/sutegi-events/Cargo.toml b/crates/sutegi-events/Cargo.toml index 735fbb6..8fe6c64 100644 --- a/crates/sutegi-events/Cargo.toml +++ b/crates/sutegi-events/Cargo.toml @@ -11,12 +11,12 @@ keywords = ["event-sourcing", "cqrs", "events", "projections", "zero-dependency" categories = ["database", "web-programming"] [dependencies] -sutegi-crypto = { path = "../sutegi-crypto", version = "0.7.0" } -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0" } +sutegi-crypto = { path = "../sutegi-crypto", version = "0.8.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0" } [dev-dependencies] -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0", features = ["sqlite", "postgres"] } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0", features = ["sqlite", "postgres"] } [package.metadata.docs.rs] all-features = true diff --git a/crates/sutegi-hexagon/Cargo.toml b/crates/sutegi-hexagon/Cargo.toml index ee024ea..36e3516 100644 --- a/crates/sutegi-hexagon/Cargo.toml +++ b/crates/sutegi-hexagon/Cargo.toml @@ -9,5 +9,5 @@ authors.workspace = true description = "Opinionated hexagonal / clean-architecture primitives for sutegi: AppError, UseCase ports, and adapter glue." [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-web = { path = "../sutegi-web", version = "0.7.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-web = { path = "../sutegi-web", version = "0.8.0" } diff --git a/crates/sutegi-http/Cargo.toml b/crates/sutegi-http/Cargo.toml index 9edd8bc..4f46396 100644 --- a/crates/sutegi-http/Cargo.toml +++ b/crates/sutegi-http/Cargo.toml @@ -2,7 +2,7 @@ name = "sutegi-http" # 0.1.1: server hardening (Limits, panic isolation, timeouts) added after the # 0.1.0 publish. Dependents request "^0.1.0" and resolve up to this. -version = "0.7.0" +version = "0.8.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/sutegi-mail/Cargo.toml b/crates/sutegi-mail/Cargo.toml index 1a9bff1..fa41ef1 100644 --- a/crates/sutegi-mail/Cargo.toml +++ b/crates/sutegi-mail/Cargo.toml @@ -11,6 +11,6 @@ keywords = ["email", "smtp", "mail", "zero-dependency"] categories = ["email", "web-programming"] [dependencies] -sutegi-crypto = { path = "../sutegi-crypto", version = "0.7.0" } -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-template = { path = "../sutegi-template", version = "0.7.0" } +sutegi-crypto = { path = "../sutegi-crypto", version = "0.8.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-template = { path = "../sutegi-template", version = "0.8.0" } diff --git a/crates/sutegi-orm/Cargo.toml b/crates/sutegi-orm/Cargo.toml index a7ecc27..9ed299f 100644 --- a/crates/sutegi-orm/Cargo.toml +++ b/crates/sutegi-orm/Cargo.toml @@ -10,9 +10,9 @@ description = "Zero-dependency SQL query builder, model/schema layer, and migrat [dependencies] rusqlite = { version = "0.40.1", features = ["bundled", "hooks"], optional = true } -sutegi-pg = { path = "../sutegi-pg", version = "0.7.0", optional = true } -sutegi-crypto = { path = "../sutegi-crypto", version = "0.7.0" } -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } +sutegi-pg = { path = "../sutegi-pg", version = "0.8.0", optional = true } +sutegi-crypto = { path = "../sutegi-crypto", version = "0.8.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } # Both execution backends are OFF by default so the zero-dependency core stays # tiny. Enable one to get a runnable layer over the same query builder: diff --git a/crates/sutegi-orm/tests/pg_locks.rs b/crates/sutegi-orm/tests/pg_locks.rs index 371bfa7..7752ec2 100644 --- a/crates/sutegi-orm/tests/pg_locks.rs +++ b/crates/sutegi-orm/tests/pg_locks.rs @@ -86,7 +86,7 @@ fn xact_lock_releases_at_commit() { #[test] fn with_lock_runs_exactly_one_of_two_racers() { - let Some(pg) = db() else { + let Some(_pg) = db() else { eprintln!("skipping: SUTEGI_PG_TEST_URL not set"); return; }; diff --git a/crates/sutegi-pg/Cargo.toml b/crates/sutegi-pg/Cargo.toml index c2b7521..344df96 100644 --- a/crates/sutegi-pg/Cargo.toml +++ b/crates/sutegi-pg/Cargo.toml @@ -9,5 +9,5 @@ authors.workspace = true description = "Zero-dependency, pure-std PostgreSQL wire-protocol (v3) client for sutegi: blocking TCP, SCRAM-SHA-256/MD5/cleartext auth, a small connection pool. No async runtime, no C library." [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-crypto = { path = "../sutegi-crypto", version = "0.7.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-crypto = { path = "../sutegi-crypto", version = "0.8.0" } diff --git a/crates/sutegi-pubsub/Cargo.toml b/crates/sutegi-pubsub/Cargo.toml index 65c8048..ca46f4c 100644 --- a/crates/sutegi-pubsub/Cargo.toml +++ b/crates/sutegi-pubsub/Cargo.toml @@ -11,8 +11,8 @@ keywords = ["pubsub", "broker", "realtime", "zero-dependency"] categories = ["web-programming", "concurrency"] [dependencies] -sutegi-pg = { path = "../sutegi-pg", version = "0.7.0", optional = true } -sutegi-json = { path = "../sutegi-json", version = "0.7.0", optional = true } +sutegi-pg = { path = "../sutegi-pg", version = "0.8.0", optional = true } +sutegi-json = { path = "../sutegi-json", version = "0.8.0", optional = true } [features] # Cross-pod fan-out over PostgreSQL LISTEN/NOTIFY (PgPubSub). Off by default diff --git a/crates/sutegi-queue/Cargo.toml b/crates/sutegi-queue/Cargo.toml index cdf282e..5f45e07 100644 --- a/crates/sutegi-queue/Cargo.toml +++ b/crates/sutegi-queue/Cargo.toml @@ -9,5 +9,5 @@ authors.workspace = true description = "Durable, cross-pod job queue for sutegi, backed by PostgreSQL (via the pure-std sutegi-pg driver): atomic FOR UPDATE SKIP LOCKED claim, visibility-timeout crash recovery, retries, delayed dispatch, dead-letter, introspectable stats." [dependencies] -sutegi-pg = { path = "../sutegi-pg", version = "0.7.0" } -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } +sutegi-pg = { path = "../sutegi-pg", version = "0.8.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } diff --git a/crates/sutegi-repl/Cargo.toml b/crates/sutegi-repl/Cargo.toml index 59697a3..fa82370 100644 --- a/crates/sutegi-repl/Cargo.toml +++ b/crates/sutegi-repl/Cargo.toml @@ -9,10 +9,10 @@ authors.workspace = true description = "Tinker-style interactive REPL for sutegi apps: drive routes, tools, and data in-process or against a running server." [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-http = { path = "../sutegi-http", version = "0.7.0" } -sutegi-web = { path = "../sutegi-web", version = "0.7.0" } -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0", optional = true } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-http = { path = "../sutegi-http", version = "0.8.0" } +sutegi-web = { path = "../sutegi-web", version = "0.8.0" } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0", optional = true } [features] default = [] @@ -21,7 +21,7 @@ default = [] orm = ["dep:sutegi-orm"] [dev-dependencies] -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0", features = ["sqlite"] } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0", features = ["sqlite"] } # The fixture app validates tool args (exercising the REPL's 422 reporting) # and reaches its Db through ToolCtx::db. -sutegi-web = { path = "../sutegi-web", version = "0.7.0", features = ["validate", "orm"] } +sutegi-web = { path = "../sutegi-web", version = "0.8.0", features = ["validate", "orm"] } diff --git a/crates/sutegi-session/Cargo.toml b/crates/sutegi-session/Cargo.toml index 684a7fc..7d4fa13 100644 --- a/crates/sutegi-session/Cargo.toml +++ b/crates/sutegi-session/Cargo.toml @@ -9,8 +9,8 @@ authors.workspace = true description = "Signed-cookie sessions for sutegi (HMAC-SHA256). Opt-in via the `session`/`auth` features." [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-web = { path = "../sutegi-web", version = "0.7.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-web = { path = "../sutegi-web", version = "0.8.0" } # The shared hand-rolled, known-answer-tested primitives (same HMAC-SHA256 the # Postgres driver's SCRAM uses) — this crate is now zero-third-party-dep. -sutegi-crypto = { path = "../sutegi-crypto", version = "0.7.0" } +sutegi-crypto = { path = "../sutegi-crypto", version = "0.8.0" } diff --git a/crates/sutegi-session/src/lib.rs b/crates/sutegi-session/src/lib.rs index 9c1d3ca..5d97dc8 100644 --- a/crates/sutegi-session/src/lib.rs +++ b/crates/sutegi-session/src/lib.rs @@ -17,10 +17,13 @@ use std::collections::BTreeMap; -use sutegi_crypto::{constant_time_eq, from_hex, hex as to_hex, hmac_sha256}; +use sutegi_crypto::{constant_time_eq, from_hex, hex as to_hex, hmac_sha256, random_bytes}; use sutegi_json::Json; use sutegi_web::{Request, Response}; +/// The session key the CSRF token lives under. +const CSRF_KEY: &str = "_csrf"; + /// Session manager: holds the signing secret and cookie policy. pub struct Sessions { secret: Vec, @@ -105,6 +108,12 @@ impl Sessions { /// Attach the signed session as a `Set-Cookie` on the response. pub fn save(&self, session: &Session, resp: Response) -> Response { + resp.with_header("set-cookie", &self.cookie_for(session)) + } + + /// The `Set-Cookie` header value that [`save`](Sessions::save) would + /// attach — for callers that collect cookies before touching a response. + pub fn cookie_for(&self, session: &Session) -> String { let payload = Json::Obj(session.data.clone()).to_string(); let payload_hex = to_hex(payload.as_bytes()); let sig = self.sign(payload.as_bytes()); @@ -118,7 +127,32 @@ impl Sessions { if let Some(age) = self.max_age { cookie.push_str(&format!("; Max-Age={}", age)); } - resp.with_header("set-cookie", &cookie) + cookie + } + + /// Get-or-mint the session's CSRF token (Laravel's `csrf_token()`): + /// 32 bytes of OS randomness, hex-encoded, carried in the signed session. + /// Hand it to your frontend and require it back on mutating requests via + /// [`verify_csrf`](Sessions::verify_csrf). Minting marks the session + /// dirty — save it. + pub fn csrf(&self, session: &mut Session) -> Result { + if let Some(t) = session.get_str(CSRF_KEY) { + return Ok(t.to_string()); + } + let token = to_hex(&random_bytes(32)?); + session.set(CSRF_KEY, Json::str(token.clone())); + Ok(token) + } + + /// Whether `presented` matches the session's CSRF token, in constant + /// time. A session without a token matches nothing. + pub fn verify_csrf(&self, session: &Session, presented: &str) -> bool { + match session.get_str(CSRF_KEY) { + Some(t) if !presented.is_empty() => { + constant_time_eq(t.as_bytes(), presented.as_bytes()) + } + _ => false, + } } /// Expire the session cookie. @@ -303,6 +337,44 @@ mod tests { assert!(sess.is_empty()); } + #[test] + fn csrf_mints_once_and_verifies_constant_time() { + let s = Sessions::new(b"k").insecure(); + let mut sess = s.load(&req_with_cookie("x", "")); + let t1 = s.csrf(&mut sess).unwrap(); + assert_eq!(t1.len(), 64); + assert!(sess.is_dirty()); + // Idempotent within the session. + assert_eq!(s.csrf(&mut sess).unwrap(), t1); + + assert!(s.verify_csrf(&sess, &t1)); + assert!(!s.verify_csrf(&sess, "wrong")); + assert!(!s.verify_csrf(&sess, "")); + // No token in session → nothing verifies. + let empty = s.load(&req_with_cookie("x", "")); + assert!(!s.verify_csrf(&empty, &t1)); + + // And it survives the cookie roundtrip. + let cookie = cookie_value(&s.save(&sess, Response::new(200)), "sutegi_session"); + let reloaded = s.load(&req_with_cookie("sutegi_session", &cookie)); + assert!(s.verify_csrf(&reloaded, &t1)); + } + + #[test] + fn cookie_for_matches_save() { + let s = Sessions::new(b"k"); + let mut sess = s.load(&req_with_cookie("x", "")); + sess.set("a", Json::int(1)); + let via_save = s + .save(&sess, Response::new(200)) + .headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("set-cookie")) + .map(|(_, v)| v.clone()) + .unwrap(); + assert_eq!(via_save, s.cookie_for(&sess)); + } + #[test] fn empty_payload_token_verification_fails_cleanly() { let s = Sessions::new(b"k"); diff --git a/crates/sutegi-storage/Cargo.toml b/crates/sutegi-storage/Cargo.toml index 4c7d651..6f1fbb2 100644 --- a/crates/sutegi-storage/Cargo.toml +++ b/crates/sutegi-storage/Cargo.toml @@ -11,9 +11,9 @@ keywords = ["storage", "s3", "presign", "zero-dependency"] categories = ["web-programming", "filesystem"] [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-crypto = { path = "../sutegi-crypto", version = "0.7.0" } -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0", optional = true } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-crypto = { path = "../sutegi-crypto", version = "0.8.0" } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0", optional = true } [features] # `DbStorage`: blobs in a database table over the ORM's Backend @@ -21,7 +21,7 @@ sutegi-orm = { path = "../sutegi-orm", version = "0.7.0", optional = true } db = ["dep:sutegi-orm"] [dev-dependencies] -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0", features = ["sqlite", "postgres"] } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0", features = ["sqlite", "postgres"] } [package.metadata.docs.rs] all-features = true diff --git a/crates/sutegi-template/Cargo.toml b/crates/sutegi-template/Cargo.toml index d00e812..1aed747 100644 --- a/crates/sutegi-template/Cargo.toml +++ b/crates/sutegi-template/Cargo.toml @@ -11,4 +11,4 @@ keywords = ["template", "blade", "html", "zero-dependency"] categories = ["template-engine", "web-programming"] [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } diff --git a/crates/sutegi-validate/Cargo.toml b/crates/sutegi-validate/Cargo.toml index 8093cda..ced0652 100644 --- a/crates/sutegi-validate/Cargo.toml +++ b/crates/sutegi-validate/Cargo.toml @@ -9,4 +9,4 @@ authors.workspace = true description = "Zero-dependency validation for sutegi: fluent rule sets and a JSON Schema subset validator with structured errors." [dependencies] -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } diff --git a/crates/sutegi-web/Cargo.toml b/crates/sutegi-web/Cargo.toml index 5592b44..c2d6350 100644 --- a/crates/sutegi-web/Cargo.toml +++ b/crates/sutegi-web/Cargo.toml @@ -9,22 +9,22 @@ authors.workspace = true description = "Router, App builder, middleware, extractors, and runtime introspection for sutegi." [dependencies] -sutegi-crypto = { path = "../sutegi-crypto", version = "0.7.0" } -sutegi-http = { path = "../sutegi-http", version = "0.7.0" } -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } +sutegi-crypto = { path = "../sutegi-crypto", version = "0.8.0" } +sutegi-http = { path = "../sutegi-http", version = "0.8.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } # Optional pillars — pulled in only by their feature, so the zero-dep core # (crypto + json + http + web, no features) is unaffected. They power the ergonomic # `Ctx` helpers (`db`/`model`/`validate`/`validated`) and tool-arg validation. -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0", optional = true } -sutegi-validate = { path = "../sutegi-validate", version = "0.7.0", optional = true } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0", optional = true } +sutegi-validate = { path = "../sutegi-validate", version = "0.8.0", optional = true } # Only pulled in by the `graceful` feature, for SIGTERM/SIGINT handling. libc = { version = "0.2", optional = true } # Only pulled in by the `ws` feature: RFC 6455 codec + the connection reactor. -sutegi-ws = { path = "../sutegi-ws", version = "0.7.0", optional = true } +sutegi-ws = { path = "../sutegi-ws", version = "0.8.0", optional = true } # Only pulled in by the `channels` feature: the channel protocol + hub. -sutegi-channels = { path = "../sutegi-channels", version = "0.7.0", optional = true } +sutegi-channels = { path = "../sutegi-channels", version = "0.8.0", optional = true } # Only pulled in by the `actors` feature: actor processes + supervisors. -sutegi-actors = { path = "../sutegi-actors", version = "0.7.0", optional = true } +sutegi-actors = { path = "../sutegi-actors", version = "0.8.0", optional = true } [features] # Install OS signal handlers (SIGTERM/SIGINT) for graceful shutdown in pods. diff --git a/crates/sutegi-ws/Cargo.toml b/crates/sutegi-ws/Cargo.toml index 9be9f9c..f129557 100644 --- a/crates/sutegi-ws/Cargo.toml +++ b/crates/sutegi-ws/Cargo.toml @@ -9,8 +9,8 @@ repository.workspace = true authors.workspace = true [dependencies] -sutegi-crypto = { version = "0.7.0", path = "../sutegi-crypto" } -sutegi-http = { version = "0.7.0", path = "../sutegi-http" } +sutegi-crypto = { version = "0.8.0", path = "../sutegi-crypto" } +sutegi-http = { version = "0.8.0", path = "../sutegi-http" } # The one non-Rust-std ingredient: raw kqueue/epoll/pipe syscalls. Same # posture as sutegi-web's `graceful` feature — libc is a binding, not a # framework, and there is no std API for readiness-based I/O. diff --git a/crates/sutegi/Cargo.toml b/crates/sutegi/Cargo.toml index 434e1a9..8a80209 100644 --- a/crates/sutegi/Cargo.toml +++ b/crates/sutegi/Cargo.toml @@ -13,27 +13,27 @@ categories = ["web-programming::http-server", "web-programming"] [dependencies] # --- core (always compiled): HTTP server, routing, JSON, crypto primitives --- -sutegi-json = { path = "../sutegi-json", version = "0.7.0" } -sutegi-http = { path = "../sutegi-http", version = "0.7.0" } -sutegi-web = { path = "../sutegi-web", version = "0.7.0" } -sutegi-crypto = { path = "../sutegi-crypto", version = "0.7.0" } +sutegi-json = { path = "../sutegi-json", version = "0.8.0" } +sutegi-http = { path = "../sutegi-http", version = "0.8.0" } +sutegi-web = { path = "../sutegi-web", version = "0.8.0" } +sutegi-crypto = { path = "../sutegi-crypto", version = "0.8.0" } # --- optional pillars: pulled in only by their feature --- -sutegi-orm = { path = "../sutegi-orm", version = "0.7.0", optional = true } -sutegi-validate = { path = "../sutegi-validate", version = "0.7.0", optional = true } -sutegi-queue = { path = "../sutegi-queue", version = "0.7.0", optional = true } -sutegi-events = { path = "../sutegi-events", version = "0.7.0", optional = true } -sutegi-hexagon = { path = "../sutegi-hexagon", version = "0.7.0", optional = true } -sutegi-session = { path = "../sutegi-session", version = "0.7.0", optional = true } -sutegi-auth = { path = "../sutegi-auth", version = "0.7.0", optional = true } -sutegi-macros = { path = "../sutegi-macros", version = "0.7.0", optional = true } -sutegi-storage = { path = "../sutegi-storage", version = "0.7.0", optional = true } -sutegi-mail = { path = "../sutegi-mail", version = "0.7.0", optional = true } -sutegi-template = { path = "../sutegi-template", version = "0.7.0", optional = true } -sutegi-repl = { path = "../sutegi-repl", version = "0.7.0", optional = true } -sutegi-ws = { path = "../sutegi-ws", version = "0.7.0", optional = true } -sutegi-pubsub = { path = "../sutegi-pubsub", version = "0.7.0", optional = true } -sutegi-channels = { path = "../sutegi-channels", version = "0.7.0", optional = true } -sutegi-actors = { path = "../sutegi-actors", version = "0.7.0", optional = true } +sutegi-orm = { path = "../sutegi-orm", version = "0.8.0", optional = true } +sutegi-validate = { path = "../sutegi-validate", version = "0.8.0", optional = true } +sutegi-queue = { path = "../sutegi-queue", version = "0.8.0", optional = true } +sutegi-events = { path = "../sutegi-events", version = "0.8.0", optional = true } +sutegi-hexagon = { path = "../sutegi-hexagon", version = "0.8.0", optional = true } +sutegi-session = { path = "../sutegi-session", version = "0.8.0", optional = true } +sutegi-auth = { path = "../sutegi-auth", version = "0.8.0", optional = true } +sutegi-macros = { path = "../sutegi-macros", version = "0.8.0", optional = true } +sutegi-storage = { path = "../sutegi-storage", version = "0.8.0", optional = true } +sutegi-mail = { path = "../sutegi-mail", version = "0.8.0", optional = true } +sutegi-template = { path = "../sutegi-template", version = "0.8.0", optional = true } +sutegi-repl = { path = "../sutegi-repl", version = "0.8.0", optional = true } +sutegi-ws = { path = "../sutegi-ws", version = "0.8.0", optional = true } +sutegi-pubsub = { path = "../sutegi-pubsub", version = "0.8.0", optional = true } +sutegi-channels = { path = "../sutegi-channels", version = "0.8.0", optional = true } +sutegi-actors = { path = "../sutegi-actors", version = "0.8.0", optional = true } [features] # Batteries-included by default; opt out with `default-features = false` and diff --git a/crates/sutegi/src/lib.rs b/crates/sutegi/src/lib.rs index 2cc87ac..346aa7b 100644 --- a/crates/sutegi/src/lib.rs +++ b/crates/sutegi/src/lib.rs @@ -13,8 +13,8 @@ //! | `validate` | sutegi-validate | request / tool validation | //! | `queue` | sutegi-queue (+ sutegi-pg) | durable, cross-pod job queue (Postgres) | //! | `events` | sutegi-events (+ orm) | event sourcing: append-only event store, aggregates, projections | -//! | `session` | sutegi-session | signed-cookie sessions (HMAC-SHA256) | -//! | `auth` | sutegi-auth (+ session/orm) | the user system: passwords, Users, guards, API tokens | +//! | `session` | sutegi-session | signed-cookie sessions (HMAC-SHA256) + CSRF tokens | +//! | `auth` | sutegi-auth (+ session/orm) | the user system: passwords, Users, guards, API tokens, remember-me, login throttling | //! | `template` | sutegi-template | Blade-style template engine (`{{ }}`, `@if`, `@foreach`, `@include`) | //! | `mail` | sutegi-mail (+ template) | Email builder, themed messages, Transport seam, smtp/sendmail/log drivers | //! | `auth-mail` | + sutegi-auth/mail | email-verification + password-reset flows | @@ -484,8 +484,9 @@ pub mod prelude { #[cfg(feature = "auth")] pub use sutegi_auth::{ - hash_password, require_auth, require_role, require_token, token_user, verify_password, - ApiToken, Auth, Tokens, User, Users, + hash_password, needs_rehash, require_auth, require_csrf, require_role, require_token, + require_verified, token_user, verify_password, ApiToken, Auth, Identified, Remember, + Throttle, Tokens, User, Users, }; #[cfg(feature = "template")] diff --git a/examples/auth/src/main.rs b/examples/auth/src/main.rs index 7121f75..5770806 100644 --- a/examples/auth/src/main.rs +++ b/examples/auth/src/main.rs @@ -1,21 +1,23 @@ //! The sutegi user system, end to end: registration, signed-cookie login with -//! server-side expiry, role-gated admin routes, and API tokens for agents. +//! server-side expiry, remember-me revival, login throttling, role-gated +//! admin routes, and API tokens for agents. //! //! ```text //! curl -c /tmp/cj -X POST localhost:8080/register -d '{"email":"root@example.com","password":"password1","name":"Root"}' -//! curl -c /tmp/cj -X POST localhost:8080/login -d '{"email":"root@example.com","password":"password1"}' -//! curl -b /tmp/cj localhost:8080/me +//! curl -c /tmp/cj -X POST localhost:8080/login -d '{"email":"root@example.com","password":"password1","remember":true}' +//! curl -b /tmp/cj -c /tmp/cj localhost:8080/me # revives from the remember cookie if the session lapsed //! curl -b /tmp/cj localhost:8080/admin/users # first user is admin //! curl -b /tmp/cj -X POST localhost:8080/tokens -d '{"name":"my-agent"}' //! curl -H "Authorization: Bearer stg_…" localhost:8080/api/whoami -//! curl -b /tmp/cj -X POST localhost:8080/logout +//! curl -b /tmp/cj -X POST localhost:8080/logout # kills session + remember token //! curl "localhost:8080/verify-email?token=…" # from the emailed link //! curl -X POST localhost:8080/forgot-password -d '{"email":"root@example.com"}' //! curl -X POST localhost:8080/reset-password -d '{"token":"…","password":"newpass99"}' //! ``` //! //! The **first registered user becomes `admin`** (bootstrap convention); -//! everyone after is a plain `user`. +//! everyone after is a plain `user`. Six failed logins in a minute lock the +//! email+IP pair out (429 with a retry hint). use std::sync::Arc; use sutegi::prelude::*; @@ -31,14 +33,16 @@ fn main() -> std::io::Result<()> { users.migrate().expect("migrate users"); let tokens = Arc::new(Tokens::new(db.clone())); tokens.migrate().expect("migrate tokens"); + let remember = Remember::new(db.clone()).insecure(); + remember.migrate().expect("migrate remember tokens"); + let throttle = Arc::new(Throttle::new(db.clone())); // 5 attempts / 60 s + throttle.migrate().expect("migrate throttle"); let secret = std::env::var("SESSION_SECRET") .unwrap_or_else(|_| "dev-only-secret-set-SESSION_SECRET".to_string()); - // `.insecure()` drops the cookie's `Secure` flag for local http:// dev. - let auth = Arc::new(Auth::new( - users, - Sessions::new(secret.as_bytes()).insecure(), - )); + // `.insecure()` drops the cookies' `Secure` flag for local http:// dev. + let auth = + Arc::new(Auth::new(users, Sessions::new(secret.as_bytes()).insecure()).remember(remember)); let (a_reg, a_login, a_logout, a_me, a_tok) = ( auth.clone(), @@ -91,31 +95,68 @@ fn main() -> std::io::Result<()> { Ok::<_, Error>(a_reg.login(c.req, &user, json(201, &user.to_json()))) }, ) - .post("/login", "Log in with email + password.", move |c| { - let body = c.json()?; - let (email, password, _) = credentials(&body)?; - match a_login.users.authenticate(email, password)? { - Some(user) => { - Ok::<_, Error>(a_login.login(c.req, &user, json(200, &user.to_json()))) + .post( + "/login", + "Log in with email + password (\"remember\": true for a 30-day cookie). Throttled.", + move |c| { + let body = c.json()?; + let (email, password, _) = credentials(&body)?; + let ip = c + .req + .peer + .as_deref() + .map(|p| p.rsplit_once(':').map(|(host, _)| host).unwrap_or(p)) + .unwrap_or_default() + .to_string(); + let key = format!("login:{email}|{ip}"); + if let Some(retry) = throttle.too_many(&key)? { + return Ok(json( + 429, + &Json::obj(vec![ + ("error", Json::str("too many attempts")), + ("retry_after", Json::int(retry)), + ]), + )); } - None => Err(Error::unauthorized("bad credentials")), - } - }) + match a_login.users.authenticate(email, password)? { + Some(user) => { + throttle.clear(&key)?; + let resp = json(200, &user.to_json()); + let remember = body.get("remember").and_then(Json::as_bool) == Some(true); + Ok::<_, Error>(if remember { + a_login.login_remembered(c.req, &user, resp)? + } else { + a_login.login(c.req, &user, resp) + }) + } + None => { + throttle.hit(&key)?; + Err(Error::unauthorized("bad credentials")) + } + } + }, + ) .post( "/logout", - "Log out (expires the session cookie).", - move |_c| { + "Log out: expires the session cookie and revokes the remember token.", + move |c| { Ok::<_, Error>( - a_logout.logout(json(200, &Json::obj(vec![("ok", Json::Bool(true))]))), + a_logout + .logout_from(c.req, json(200, &Json::obj(vec![("ok", Json::Bool(true))]))), ) }, ) - .get("/me", "The logged-in user.", move |c| { - match a_me.current(c.req)? { - Some(user) => Ok::<_, Error>(json(200, &user.to_json())), + .get( + "/me", + "The logged-in user (revives a lapsed session from the remember cookie).", + move |c| match a_me.identify(c.req)? { + Some(hit) => { + let resp = json(200, &hit.user.to_json()); + Ok::<_, Error>(hit.attach(resp)) + } None => Err(Error::unauthorized("unauthenticated")), - } - }) + }, + ) .post( "/tokens", "Mint an API token for the logged-in user (plaintext shown once).",