Skip to main content

umbral_auth/
lib.rs

1//! umbral-auth — the built-in authentication plugin.
2//!
3//! The first crate under `plugins/` and the proof of the M7 plugin
4//! contract: a real built-in expressed through `umbral::prelude::Plugin`
5//! with no special-casing inside `umbral-core`. Auth is the most common
6//! Django plugin, so getting it right here also pressure-tests the
7//! contract for the rest.
8//!
9//! ## M9 v1 scope
10//!
11//! - [`AuthUser`] model: the canonical Django-shape User (username,
12//!   email, password hash, `is_active` / `is_staff` / `is_superuser`,
13//!   `date_joined`, `last_login`).
14//! - [`UserModel`] trait: the minimum surface a custom user model must
15//!   satisfy so `AuthPlugin<U>` can swap in any user type. Default impls
16//!   cover the optional flag methods so a minimal custom user struct
17//!   only has to implement the load-bearing four.
18//! - argon2 password hashing via [`hash_password`] / [`verify_password`].
19//! - [`create_user`], [`authenticate`], [`set_password`] helpers.
20//!   `authenticate` and `set_password` are generic over any `U: UserModel`.
21//! - [`AuthPlugin`] registers the user model (which becomes a migration)
22//!   plus the `/auth` routes and management commands. The type parameter
23//!   defaults to [`AuthUser`] so existing apps need no changes.
24//! - [`login_required`] module: `LoginRequired` config, `LoggedIn<U>`
25//!   extractor, `LoginRequiredLayer` middleware, and the
26//!   `login_required()` / `login_required_html()` convenience
27//!   constructors. Django's `@login_required` in two shapes.
28//!
29//! ## Custom user models
30//!
31//! ```ignore
32//! // 1. Declare a custom user struct.
33//! #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
34//! pub struct TenantUser {
35//!     pub id: i64,
36//!     pub username: String,
37//!     pub password_hash: String,
38//!     pub tenant_id: i64,
39//!     pub is_active: bool,
40//! }
41//!
42//! // 2. Implement UserModel (only the four required methods).
43//! impl umbral_auth::UserModel for TenantUser {
44//!     fn id(&self) -> i64               { self.id }
45//!     fn username(&self) -> &str        { &self.username }
46//!     fn password_hash(&self) -> &str   { &self.password_hash }
47//!     fn set_password_hash(&mut self, h: String) { self.password_hash = h; }
48//! }
49//!
50//! // 3. Wire the plugin with your type.
51//! App::builder()
52//!     .plugin(AuthPlugin::<TenantUser>::default())
53//!     .build()?
54//! ```
55//!
56//! ## Deferred (per `docs/specs/outlines/auth-and-sessions.md`)
57//!
58//! - Permissions, groups, the auth-backend chain.
59//! - The `Auth<U>` request extractor + `#[login_required]`
60//!   middleware. Needs `Plugin::middleware()` lifted (M7 deferral).
61//! - Login / logout / password-reset HTTP flows. Needs the full
62//!   `umbral-sessions` session middleware wired end-to-end.
63//! - Periodic session cleanup via `umbral-tasks`.
64
65pub mod auth_routes;
66pub mod bearer_auth;
67pub mod extractors;
68pub mod login_required;
69pub mod password_validation;
70pub mod session_user;
71pub mod throttle;
72pub mod token;
73
74pub use password_validation::{
75    CommonPasswordValidator, MinLengthValidator, NumericPasswordValidator, PasswordContext,
76    PasswordPolicy, PasswordValidator, UserAttributeSimilarityValidator, validate_password,
77};
78
79pub use bearer_auth::{BearerAuthentication, parse_bearer_header};
80pub use extractors::{CurrentIdentity, OptionalIdentity, resolve_identity};
81pub use login_required::{
82    LoggedIn, LoginRequired, LoginRequiredLayer, current_session_user_id,
83    current_session_user_pk, login_required, login_required_html, resolve_user as current_user_as,
84};
85pub use session_user::{
86    OptionalUser, SessionAuthentication, User, current_user, login, login_with_request, logout,
87    user_context_layer,
88};
89pub use throttle::{
90    Throttle, ThrottleConfig, login_throttle_check, login_throttle_clear, register_throttle_check,
91};
92pub use token::{AuthToken, PlaintextToken, TOKEN_PREFIX, digest_token};
93
94use std::marker::PhantomData;
95
96use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
97use argon2::{Algorithm, Argon2, Params, Version, password_hash::rand_core::OsRng};
98use chrono::{DateTime, Utc};
99use serde::{Deserialize, Serialize};
100use umbral::prelude::*;
101
102// =========================================================================
103// UserModel trait
104// =========================================================================
105
106/// The minimum surface a user model must expose so `AuthPlugin<U>` can
107/// operate on it generically.
108///
109/// All four required methods map directly to columns that auth ACTUALLY
110/// reads or writes. Optional flag methods (`is_active`, `is_staff`,
111/// `is_superuser`) have default impls that return the safe defaults so a
112/// minimal custom user struct doesn't have to repeat them.
113///
114/// `AuthUser` implements this trait unchanged, so existing code that
115/// calls the auth helpers directly keeps working.
116///
117/// ## Required methods
118///
119/// | Method | Column | Used by |
120/// |---|---|---|
121/// | `id()` | `id` | `set_password` WHERE clause; session storage |
122/// | `username()` | `username` | `authenticate` SELECT, `createsuperuser` output |
123/// | `password_hash()` | `password_hash` | `authenticate` verify step |
124/// | `set_password_hash()` | `password_hash` | `set_password` in-place update |
125///
126/// ## Default methods
127///
128/// | Method | Default | Used by |
129/// |---|---|---|
130/// | `id_string()` | `self.id().to_string()` | `Identity::user_id`, session row |
131/// | `is_active()` | `true` | `authenticate` active-user gate |
132/// | `is_staff()` | `false` | admin require_staff check |
133/// | `is_superuser()` | `false` | permission gates |
134///
135/// ## Polymorphic primary key
136///
137/// `id()` returns the model's typed primary key via the existing
138/// `Model::PrimaryKey` associated type — the framework no longer
139/// hardcodes `i64`. A custom user model keyed by `uuid::Uuid`
140/// works as-is:
141///
142/// ```ignore
143/// #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize,
144///          umbral::orm::Model)]
145/// pub struct UuidUser {
146///     pub id: uuid::Uuid,
147///     pub username: String,
148///     pub password_hash: String,
149///     pub is_active: bool,
150///     pub is_staff: bool,
151/// }
152/// impl umbral_auth::UserModel for UuidUser {
153///     fn id(&self) -> uuid::Uuid { self.id }
154///     fn username(&self) -> &str { &self.username }
155///     fn password_hash(&self) -> &str { &self.password_hash }
156///     fn set_password_hash(&mut self, h: String) { self.password_hash = h; }
157///     fn is_active(&self) -> bool { self.is_active }
158///     fn is_staff(&self) -> bool { self.is_staff }
159/// }
160/// ```
161///
162/// The session-row text column, [`Identity::user_id`], and the
163/// permissions plugin all speak strings (via `id_string()`); the
164/// ORM-side WHERE clauses use the typed PK directly (via the
165/// `PrimaryKey: Into<sea_query::Value>` bound). Nothing in the
166/// framework parses `id()` back to `i64`.
167pub trait UserModel: Model + Send + Sync + 'static {
168    /// The row's typed primary key. `set_password` uses this in the
169    /// UPDATE WHERE clause; bearer-token / session backends use it
170    /// to filter on `auth_user::ID.eq(user.id())` style predicates.
171    ///
172    /// The return type is `<Self as Model>::PrimaryKey`, which the
173    /// `#[derive(Model)]` macro derives from the `id` field's type
174    /// (`i64`, `uuid::Uuid`, `String`, etc.). All `PrimaryKey`
175    /// types implement `Display`, so [`id_string`](Self::id_string)
176    /// can stringify without an explicit per-impl override.
177    fn id(&self) -> <Self as Model>::PrimaryKey;
178
179    /// The PK as a string. Used by [`umbral_sessions`] (which stores
180    /// `user_id` as text) and by the REST identity contract's
181    /// [`Identity::user_id`](umbral::auth::Identity) (which is
182    /// uniform across user models).
183    ///
184    /// Default uses the typed PK's `Display` impl — override only
185    /// when the stringification needs to differ from `Display`
186    /// (e.g. a base64-encoded ULID).
187    fn id_string(&self) -> String {
188        self.id().to_string()
189    }
190
191    /// The unique login handle. Matched against the username column in
192    /// `authenticate`'s SELECT query.
193    fn username(&self) -> &str;
194
195    /// The argon2 PHC-encoded password hash stored in the DB column.
196    /// `authenticate` reads this, verifies it, and moves on.
197    fn password_hash(&self) -> &str;
198
199    /// Replace the in-memory password hash. Called by `set_password`
200    /// after writing the new hash to the database, so the caller's
201    /// `&mut U` reflects the update without a re-fetch.
202    fn set_password_hash(&mut self, hash: String);
203
204    /// Whether this account is active. `authenticate` rejects inactive
205    /// users with `InvalidCredentials` (same error as wrong password -
206    /// no account enumeration). Default: `true`.
207    fn is_active(&self) -> bool {
208        true
209    }
210
211    /// Whether this account has staff-level access to the admin
212    /// interface. Default: `false`.
213    fn is_staff(&self) -> bool {
214        false
215    }
216
217    /// Whether this account has superuser rights. Default: `false`.
218    fn is_superuser(&self) -> bool {
219        false
220    }
221}
222
223// =========================================================================
224// Built-in AuthUser model
225// =========================================================================
226
227/// The canonical authentication user. `#[derive(Model)]` snake_cases
228/// the struct name into the table name `auth_user`; the M3 derive
229/// doesn't yet accept `#[umbral(table = ...)]` so the snake_case
230/// round-trip is the only way to get a plugin-prefixed table name
231/// until the attribute lands.
232#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
233pub struct AuthUser {
234    pub id: i64,
235    #[umbral(unique)]
236    pub username: String,
237    /// Shown read-only on edit forms; never on create forms (use the
238    /// admin's password field mechanism for changes).
239    #[umbral(noedit, unique)]
240    pub email: String,
241    /// Never shown on any form — password management goes through the
242    /// dedicated Change Password flow in the admin.
243    #[umbral(noform)]
244    pub password_hash: String,
245    pub is_active: bool,
246    pub is_staff: bool,
247    pub is_superuser: bool,
248    pub date_joined: DateTime<Utc>,
249    pub last_login: Option<DateTime<Utc>>,
250}
251
252impl UserModel for AuthUser {
253    // `<AuthUser as Model>::PrimaryKey` is `i64` — the derive picks
254    // it up from the `id: i64` field. Returning `self.id` directly
255    // satisfies `fn id(&self) -> <Self as Model>::PrimaryKey` for
256    // the default AuthUser shape; a custom user model with a
257    // `uuid::Uuid` PK would return `self.id` of that type, and the
258    // default `id_string()` would stringify via `Display` for free.
259    fn id(&self) -> <Self as umbral::orm::Model>::PrimaryKey {
260        self.id
261    }
262
263    fn username(&self) -> &str {
264        &self.username
265    }
266
267    fn password_hash(&self) -> &str {
268        &self.password_hash
269    }
270
271    fn set_password_hash(&mut self, hash: String) {
272        self.password_hash = hash;
273    }
274
275    fn is_active(&self) -> bool {
276        self.is_active
277    }
278
279    fn is_staff(&self) -> bool {
280        self.is_staff
281    }
282
283    fn is_superuser(&self) -> bool {
284        self.is_superuser
285    }
286}
287
288// =========================================================================
289// AuthPlugin<U>
290// =========================================================================
291
292/// The built-in authentication plugin, generic over the user model.
293///
294/// `U` defaults to [`AuthUser`] so `AuthPlugin::default()` continues to
295/// work in all existing code unchanged. Apps that need a custom user type
296/// opt in with one line:
297///
298/// ```ignore
299/// .plugin(AuthPlugin::<CustomUser>::default())
300/// ```
301///
302/// ## `user_model_name`
303///
304/// An optional informational string surfaced in OpenAPI schemas and the
305/// admin nav. Default `None` (resolved from `U::NAME` by the plugin
306/// itself when left empty). Set it explicitly when the type name is
307/// insufficient:
308///
309/// ```ignore
310/// AuthPlugin::<TenantUser>::default().user_model_name("tenant_user")
311/// ```
312#[derive(Debug)]
313pub struct AuthPlugin<U: UserModel = AuthUser> {
314    /// Documentation-only: the human-readable name of the active user
315    /// model. Consumed by admin / OpenAPI when surfacing the user table.
316    /// The actual dispatch is entirely through the type parameter `U`.
317    pub user_model_name: Option<String>,
318    /// When `Some`, mount the four built-in routes (register / login /
319    /// logout / me) under this prefix. `None` skips them — the user
320    /// either doesn't want them or is rolling their own surface. Only
321    /// settable on `AuthPlugin<AuthUser>` (the handlers FK into
322    /// `AuthToken` → `AuthUser`); custom user models bring their own.
323    pub default_routes_prefix: Option<String>,
324    /// When true, wrap the app router with [`user_context_layer`] so
325    /// every template render has `user` in its global context:
326    /// `{ is_authenticated, is_staff, username, ... }`. Opt-in because
327    /// it costs one DB read per request (cookie → session → user); a
328    /// REST-only service has nothing to gain from it. Set via
329    /// [`AuthPlugin::with_user_in_templates`].
330    pub user_in_templates: bool,
331    /// The password-strength policy this plugin installs at boot. `None`
332    /// here is NOT "no validation" — `on_ready` installs
333    /// [`PasswordPolicy::default`] (the full secure set) when this is left
334    /// unset, so the plugin is secure by default. The only way to get an
335    /// empty policy is to call [`AuthPlugin::disable_password_validation`],
336    /// which stores an explicit [`PasswordPolicy::empty`].
337    ///
338    /// Wrapped in a `Mutex` because `Plugin::on_ready` only borrows `&self`
339    /// yet needs to MOVE the policy into the ambient `OnceLock`
340    /// ([`PasswordPolicy`] is not `Clone` — it holds boxed trait objects).
341    /// The mutex lets `on_ready` `.take()` it; the first boot wins.
342    password_policy: std::sync::Mutex<Option<PasswordPolicy>>,
343    /// The login/register rate-limit configuration this plugin installs at
344    /// boot. Secure by default ([`ThrottleConfig::default`]: login 5 / 5 min
345    /// per IP+username, register 10 / hour per IP, `enabled = true`). Builder
346    /// methods ([`AuthPlugin::login_throttle`], [`AuthPlugin::register_throttle`])
347    /// tune the budgets; [`AuthPlugin::disable_throttle`] flips `enabled` off
348    /// as an explicit opt-out. `Copy`, so no `Mutex`/`take` dance is needed —
349    /// `on_ready` reads it directly.
350    throttle_config: throttle::ThrottleConfig,
351    _u: PhantomData<U>,
352}
353
354impl<U: UserModel> Default for AuthPlugin<U> {
355    fn default() -> Self {
356        Self {
357            user_model_name: None,
358            default_routes_prefix: None,
359            user_in_templates: false,
360            // SECURE BY DEFAULT: an unconfigured AuthPlugin enforces the
361            // full validator set. `None` defers to PasswordPolicy::default()
362            // (the secure set) at install time; it does NOT mean "off".
363            password_policy: std::sync::Mutex::new(None),
364            // SECURE BY DEFAULT: throttling is ON for login + register with
365            // the credential-stuffing-resistant budgets above. `disable_throttle`
366            // is the only path that turns it off.
367            throttle_config: throttle::ThrottleConfig::default(),
368            _u: PhantomData,
369        }
370    }
371}
372
373impl<U: UserModel> AuthPlugin<U> {
374    /// Override the informational user-model name shown in admin / OpenAPI.
375    /// Fluent builder method; the return type is `Self` so it chains.
376    pub fn user_model_name(mut self, name: impl Into<String>) -> Self {
377        self.user_model_name = Some(name.into());
378        self
379    }
380
381    /// Mount the [`user_context_layer`] middleware globally so every
382    /// HTML template gets `user` in its render context — anonymous
383    /// requests see `{ is_authenticated: false }`, authenticated
384    /// requests see the full serialized [`AuthUser`] merged with
385    /// `is_authenticated: true`. Lets templates write
386    /// `{% if user.is_staff %}` without the consumer having to thread
387    /// a user value into every handler's context manually.
388    ///
389    /// One DB read per request (cookie → session → user row). Off by
390    /// default because REST-only services have no templates and the
391    /// cost would be pure overhead. Turn it on for HTML-heavy apps:
392    ///
393    /// ```ignore
394    /// AuthPlugin::<AuthUser>::default()
395    ///     .with_default_routes()
396    ///     .with_user_in_templates()   // ← here
397    /// ```
398    ///
399    /// Implemented via [`Plugin::wrap_router`]; the wrapper wraps the
400    /// merged app router (including every other plugin's routes), so
401    /// admin / REST / playground / your own handlers all see the
402    /// populated context with one builder call.
403    pub fn with_user_in_templates(mut self) -> Self {
404        self.user_in_templates = true;
405        self
406    }
407
408    /// Replace the default password-strength policy with a custom one.
409    /// The full [`PasswordPolicy`] you pass becomes the active set at boot;
410    /// the Django-default validators are NOT merged in. Build the policy
411    /// you want from scratch:
412    ///
413    /// ```ignore
414    /// use umbral_auth::{AuthPlugin, PasswordPolicy, MinLengthValidator, CommonPasswordValidator};
415    /// AuthPlugin::<AuthUser>::default().password_validators(
416    ///     PasswordPolicy::empty()
417    ///         .with(Box::new(MinLengthValidator(12)))
418    ///         .with(Box::new(CommonPasswordValidator)),
419    /// )
420    /// ```
421    pub fn password_validators(mut self, policy: PasswordPolicy) -> Self {
422        self.password_policy = std::sync::Mutex::new(Some(policy));
423        self
424    }
425
426    /// Convenience: keep the four default validators but change the minimum
427    /// password length. Equivalent to building a [`PasswordPolicy`] with a
428    /// [`MinLengthValidator`] of `n` plus the other three defaults.
429    pub fn min_password_length(self, n: usize) -> Self {
430        self.password_validators(PasswordPolicy::new(vec![
431            Box::new(MinLengthValidator(n)),
432            Box::new(CommonPasswordValidator),
433            Box::new(NumericPasswordValidator),
434            Box::new(UserAttributeSimilarityValidator::default()),
435        ]))
436    }
437
438    /// Explicit opt-OUT: install an empty policy so NO password validation
439    /// runs. Secure-by-default means an app that genuinely wants to accept
440    /// any password — a throwaway demo, a migration importing legacy hashes
441    /// with externally-validated plaintext — has to ask for it by name.
442    /// Don't reach for this to silence a failing test; fix the fixture's
443    /// password instead.
444    pub fn disable_password_validation(mut self) -> Self {
445        self.password_policy = std::sync::Mutex::new(Some(PasswordPolicy::empty()));
446        self
447    }
448
449    /// Tune the login rate limit: `max` failed-or-not attempts per trailing
450    /// `window`, keyed per IP + username. The default is 5 / 5 min — a budget
451    /// that stops credential-stuffing dead while leaving room for a human who
452    /// fat-fingers their password a couple of times (a successful login also
453    /// clears the counter). Lower it for a high-security surface; raise it for
454    /// a shared-NAT office where many users hit login from one IP.
455    ///
456    /// ```ignore
457    /// AuthPlugin::<AuthUser>::default().login_throttle(10, Duration::from_secs(300))
458    /// ```
459    pub fn login_throttle(mut self, max: usize, window: std::time::Duration) -> Self {
460        self.throttle_config.login_max = max;
461        self.throttle_config.login_window = window;
462        self
463    }
464
465    /// Tune the register rate limit: `max` account-creation attempts per
466    /// trailing `window`, keyed per IP. The default is 10 / hour, which brakes
467    /// mass automated signups without blocking a legitimate burst from one
468    /// office.
469    pub fn register_throttle(mut self, max: usize, window: std::time::Duration) -> Self {
470        self.throttle_config.register_max = max;
471        self.throttle_config.register_window = window;
472        self
473    }
474
475    /// Explicit opt-OUT: turn login + register throttling OFF entirely.
476    /// Secure-by-default means an app that genuinely wants no rate limit — a
477    /// load test, an internal tool behind its own gateway limiter — has to ask
478    /// for it by name. Don't reach for this to silence a throttled test; use a
479    /// distinct IP/username per attempt or a generous `login_throttle` instead.
480    pub fn disable_throttle(mut self) -> Self {
481        self.throttle_config.enabled = false;
482        self
483    }
484}
485
486// =========================================================================
487// Default route opt-in. Only exposed on AuthPlugin<AuthUser> because the
488// handlers FK into AuthUser via AuthToken. Custom user models would need a
489// different token model + different handlers; they bring their own surface.
490// The concrete impl block (no <U>) is the compile-time witness: calling
491// `.with_default_routes()` on `AuthPlugin::<CustomUser>` is an error at
492// the call site, not a silent no-op at runtime.
493// =========================================================================
494impl AuthPlugin<AuthUser> {
495    /// Mount the built-in `/api/auth/{register,login,logout,me}`
496    /// surface. Same handlers that lived in the derive-demo example
497    /// app, promoted to the framework so every app gets them with one
498    /// line. JSON-only; UNIQUE-violation → 409; login returns both a
499    /// Set-Cookie and a bearer token in one response so browsers and
500    /// CLI clients share an endpoint.
501    pub fn with_default_routes(mut self) -> Self {
502        self.default_routes_prefix = Some("/api/auth".to_string());
503        self
504    }
505
506    /// Same as [`Self::with_default_routes`] but the prefix is yours
507    /// to pick. Useful when `/api/auth` collides with an existing
508    /// surface or you want versioning (`/v1/auth`).
509    pub fn with_default_routes_at(mut self, prefix: impl Into<String>) -> Self {
510        self.default_routes_prefix = Some(prefix.into());
511        self
512    }
513}
514
515impl<U: UserModel> Plugin for AuthPlugin<U> {
516    fn name(&self) -> &'static str {
517        "auth"
518    }
519
520    fn models(&self) -> Vec<umbral::migrate::ModelMeta> {
521        // AuthToken FKs against AuthUser specifically (FK target is
522        // a concrete `Model` type, not a `UserModel`). Apps wiring
523        // `AuthPlugin::<CustomUser>` get the user table migrated but
524        // NOT the token table — they bring their own token model
525        // and their own bearer-auth backend.
526        let mut models = vec![umbral::migrate::ModelMeta::for_::<U>()];
527        if std::any::TypeId::of::<U>() == std::any::TypeId::of::<AuthUser>() {
528            models.push(umbral::migrate::ModelMeta::for_::<AuthToken>());
529        }
530        models
531    }
532
533    fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> {
534        vec![Box::new(CreateSuperuserCommand)]
535    }
536
537    fn routes(&self) -> umbral::web::Router {
538        // `default_routes_prefix` is only ever Some when U = AuthUser
539        // (the only impl block that sets it is `impl AuthPlugin<AuthUser>`).
540        // So the prefix-guarded branch is dead code for any custom user
541        // model — both at compile time (the builder method isn't
542        // visible) and at runtime (the field stays None).
543        match &self.default_routes_prefix {
544            Some(prefix) => auth_routes::build_router(prefix),
545            None => umbral::web::Router::new(),
546        }
547    }
548
549    fn route_paths(&self) -> Vec<umbral::routes::RouteSpec> {
550        match &self.default_routes_prefix {
551            Some(prefix) => auth_routes::declared_routes(prefix),
552            None => Vec::new(),
553        }
554    }
555
556    fn openapi_paths(&self) -> Vec<(String, serde_json::Value)> {
557        match &self.default_routes_prefix {
558            Some(prefix) => auth_routes::openapi_paths(prefix),
559            None => Vec::new(),
560        }
561    }
562
563    /// Mount [`user_context_layer`] on the full merged router when the
564    /// `user_in_templates` flag is on (see
565    /// [`AuthPlugin::with_user_in_templates`]). The layer reads the
566    /// session cookie, hydrates the [`AuthUser`], and pushes a
567    /// `serde_json` representation into [`umbral::templates::CURRENT_USER`]
568    /// for the duration of the request — every template render
569    /// downstream gets `user` in its global context with no per-handler
570    /// plumbing.
571    ///
572    /// Off by default — see the builder method's docstring for the
573    /// "why" (one DB read per request, pointless for REST-only apps).
574    fn wrap_router(&self, router: umbral::web::Router) -> umbral::web::Router {
575        if self.user_in_templates {
576            router.layer(axum::middleware::from_fn(user_context_layer))
577        } else {
578            router
579        }
580    }
581
582    /// Seal the password-strength policy into the ambient `OnceLock` so the
583    /// free-function helpers (`create_user`, `set_password`) can read it
584    /// without a handle to `Self`. Mirrors the sessions plugin's
585    /// `SLIDING_EXPIRY_ENABLED` install.
586    ///
587    /// A `None` configured policy means "use the secure default" — NOT
588    /// "off" — so we install [`PasswordPolicy::default`] in that case.
589    /// `disable_password_validation` is the only path that installs an
590    /// empty policy. The install is idempotent (first boot wins), matching
591    /// the ambient-pool contract.
592    fn on_ready(&self, _ctx: &umbral::plugin::AppContext) -> Result<(), umbral::plugin::PluginError> {
593        let policy = self
594            .password_policy
595            .lock()
596            .ok()
597            .and_then(|mut guard| guard.take())
598            .unwrap_or_default();
599        password_validation::install_policy(policy);
600        // Install the rate limiter the same way: the route handlers are free
601        // functions, so they read the limiter ambiently via the `throttle`
602        // free helpers. First boot wins (idempotent set), matching the
603        // password-policy / ambient-pool contract.
604        throttle::install(throttle::AuthThrottle::from_config(self.throttle_config));
605        Ok(())
606    }
607}
608
609// =========================================================================
610// AuthError
611// =========================================================================
612
613/// Errors the auth helpers can produce. Kept narrow at M9 v1 so the
614/// surface is easy to handle in one match arm.
615#[derive(Debug)]
616pub enum AuthError {
617    /// argon2 produced or failed to parse a password hash. Carries the
618    /// raw error so the diagnostic includes argon2's own message.
619    PasswordHash(argon2::password_hash::Error),
620    /// sqlx error executing one of the helper queries.
621    Sqlx(sqlx::Error),
622    /// ORM write error — `create`, `update_values`, etc.
623    Write(umbral::orm::write::WriteError),
624    /// `authenticate` was called with credentials that don't match any
625    /// active user. Returned for both "no such user" and "wrong
626    /// password" so a caller can't tell which from the error alone.
627    InvalidCredentials,
628    /// The plaintext password failed one or more password-strength
629    /// validators (see [`crate::password_validation`]). Carries every
630    /// human-readable reason so the route / form can show the full list.
631    ///
632    /// This is NOT produced by the low-level creation helpers anymore
633    /// (`create_user` / `create_user_with_flags` / `create_superuser` /
634    /// `set_password` are all Django-parity and do not validate). It is
635    /// constructed at the **registration boundary** — the `register` route
636    /// calls [`crate::validate_password`] up front and wraps any failure in
637    /// this variant, which the route layer then maps to 400. A custom signup
638    /// flow that wants the same behaviour follows the same pattern.
639    WeakPassword(Vec<String>),
640    /// A blocking task offloaded to the tokio blocking pool (argon2
641    /// hashing / verification via [`hash_password_async`] /
642    /// [`verify_password_async`]) failed to join — i.e. the task panicked
643    /// or was cancelled. Carries the `JoinError`'s message. A panic in the
644    /// hash worker is a real error, surfaced rather than swallowed.
645    Runtime(String),
646}
647
648impl std::fmt::Display for AuthError {
649    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650        match self {
651            AuthError::PasswordHash(e) => write!(f, "umbral-auth: password hash: {e}"),
652            AuthError::Sqlx(e) => write!(f, "umbral-auth: sqlx: {e}"),
653            AuthError::Write(e) => write!(f, "umbral-auth: write: {e:?}"),
654            AuthError::InvalidCredentials => write!(f, "umbral-auth: invalid credentials"),
655            AuthError::WeakPassword(reasons) => {
656                write!(f, "umbral-auth: password rejected: {}", reasons.join(" "))
657            }
658            AuthError::Runtime(msg) => write!(f, "umbral-auth: blocking task failed: {msg}"),
659        }
660    }
661}
662
663impl std::error::Error for AuthError {}
664
665impl From<argon2::password_hash::Error> for AuthError {
666    fn from(e: argon2::password_hash::Error) -> Self {
667        Self::PasswordHash(e)
668    }
669}
670
671impl From<sqlx::Error> for AuthError {
672    fn from(e: sqlx::Error) -> Self {
673        Self::Sqlx(e)
674    }
675}
676
677impl From<umbral::orm::write::WriteError> for AuthError {
678    fn from(e: umbral::orm::write::WriteError) -> Self {
679        Self::Write(e)
680    }
681}
682
683// =========================================================================
684// Password helpers - pure, no DB.
685// =========================================================================
686
687/// Hash a plaintext password with argon2's framework-chosen
688/// parameters. Returns the PHC-encoded string ready to store in
689/// the password_hash column. The hash is self-describing so future
690/// parameter upgrades stay transparent: a verified hash with old
691/// parameters can be re-hashed on next login.
692pub fn hash_password(plaintext: &str) -> Result<String, AuthError> {
693    let salt = SaltString::generate(&mut OsRng);
694    let hash = password_hasher()
695        .hash_password(plaintext.as_bytes(), &salt)?
696        .to_string();
697    Ok(hash)
698}
699
700/// Verify a plaintext password against an argon2 PHC-encoded hash.
701/// Returns `Ok(true)` on match, `Ok(false)` on mismatch, and an error
702/// only when the hash itself is malformed. Callers that just want a
703/// bool can use `.unwrap_or(false)`.
704pub fn verify_password(plaintext: &str, hash: &str) -> Result<bool, AuthError> {
705    let parsed = PasswordHash::new(hash)?;
706    match password_hasher().verify_password(plaintext.as_bytes(), &parsed) {
707        Ok(()) => Ok(true),
708        Err(argon2::password_hash::Error::Password) => Ok(false),
709        Err(e) => Err(AuthError::PasswordHash(e)),
710    }
711}
712
713/// Async wrapper around [`hash_password`] that runs the CPU-bound argon2
714/// work on tokio's blocking pool via `spawn_blocking`. argon2id with the
715/// framework parameters takes ~100ms of CPU; calling it directly from a
716/// request handler pins an async worker thread for that whole time, so a
717/// login/registration burst starves the runtime and HTTP/1.1 connections
718/// hang. Offloading keeps the async workers free to drive other tasks.
719/// **Async request handlers must use this**; the sync [`hash_password`]
720/// remains for non-async / CLI / test callers.
721pub async fn hash_password_async(plaintext: &str) -> Result<String, AuthError> {
722    let p = plaintext.to_owned();
723    tokio::task::spawn_blocking(move || hash_password(&p))
724        .await
725        .map_err(|e| AuthError::Runtime(e.to_string()))?
726}
727
728/// Async wrapper around [`verify_password`] that runs the CPU-bound argon2
729/// verification on tokio's blocking pool via `spawn_blocking`. See
730/// [`hash_password_async`] for the starvation rationale. **Async request
731/// handlers must use this**; the sync [`verify_password`] remains for
732/// non-async / CLI / test callers.
733pub async fn verify_password_async(plaintext: &str, hash: &str) -> Result<bool, AuthError> {
734    let p = plaintext.to_owned();
735    let h = hash.to_owned();
736    tokio::task::spawn_blocking(move || verify_password(&p, &h))
737        .await
738        .map_err(|e| AuthError::Runtime(e.to_string()))?
739}
740
741fn password_hasher() -> Argon2<'static> {
742    Argon2::new(
743        Algorithm::Argon2id,
744        Version::V0x13,
745        Params::new(19_456, 2, 1, None).expect("hard-coded argon2 params are valid"),
746    )
747}
748
749// =========================================================================
750// AuthUser-specific creation helpers.
751//
752// These functions are intentionally tied to `AuthUser` because they
753// construct the struct from a fixed set of columns. A custom user model
754// that wants equivalent creation helpers should provide its own, using
755// `hash_password` for the password column. See the docs for the
756// recommended pattern.
757// =========================================================================
758
759/// Create a new active user with the given username, email, and
760/// plaintext password. The password is hashed before insert; the
761/// plaintext never touches the database. `date_joined` is set to
762/// `Utc::now()`; `last_login` is `None`; `is_active = true`,
763/// `is_staff = false`, `is_superuser = false`.
764pub async fn create_user(
765    username: &str,
766    email: &str,
767    plaintext: &str,
768) -> Result<AuthUser, AuthError> {
769    create_user_with_flags(username, email, plaintext, false, false).await
770}
771
772/// Create a superuser - `is_staff = true`, `is_superuser = true`,
773/// `is_active = true`. Used by the `createsuperuser` management
774/// command and available directly for tests / seed scripts.
775pub async fn create_superuser(
776    username: &str,
777    email: &str,
778    plaintext: &str,
779) -> Result<AuthUser, AuthError> {
780    // Low-level, like every other creation helper: it inserts a row and
781    // does NOT run the password-strength policy. This is Django parity —
782    // `User.objects.create_superuser()` doesn't validate either; only the
783    // registration boundary (the `register` route) and any custom signup
784    // form do. A trusted operator path (the `createsuperuser` command, a
785    // seed script, a test) chooses the password deliberately, so there's
786    // nothing to gate here.
787    insert_user(username, email, plaintext, true, true).await
788}
789
790/// Insert a new user with arbitrary `is_staff` / `is_superuser`
791/// flags. Used by `create_user` (flags = false, false) and
792/// `create_superuser` (flags = true, true); exposed publicly so
793/// custom seed paths can pick a specific shape (e.g. a staff-but-
794/// not-superuser editor account).
795pub async fn create_user_with_flags(
796    username: &str,
797    email: &str,
798    plaintext: &str,
799    is_staff: bool,
800    is_superuser: bool,
801) -> Result<AuthUser, AuthError> {
802    insert_user(username, email, plaintext, is_staff, is_superuser).await
803}
804
805/// The shared insert path behind [`create_user`], [`create_user_with_flags`]
806/// and [`create_superuser`].
807///
808/// This is the **low-level** creation primitive: it hashes the plaintext and
809/// writes the row, but it does NOT run the password-strength policy. That's
810/// deliberate Django parity — `User.objects.create_user()` doesn't validate;
811/// the registration boundary does (in umbral, the `register` route, which calls
812/// [`validate_password`] itself before reaching here). Keeping validation out
813/// of the insert path means seed scripts, bulk imports, and the workspace test
814/// suite can create users with deliberately-chosen passwords without tripping
815/// the policy. An untrusted signup surface must gate on `validate_password`
816/// up front; the helper trusts its caller.
817async fn insert_user(
818    username: &str,
819    email: &str,
820    plaintext: &str,
821    is_staff: bool,
822    is_superuser: bool,
823) -> Result<AuthUser, AuthError> {
824    let now = chrono::Utc::now();
825    let hash = hash_password_async(plaintext).await?;
826    let row = AuthUser::objects()
827        .create(AuthUser {
828            id: 0,
829            username: username.to_string(),
830            email: email.to_string(),
831            password_hash: hash,
832            is_active: true,
833            is_staff,
834            is_superuser,
835            date_joined: now,
836            last_login: None,
837        })
838        .await?;
839    Ok(row)
840}
841
842// =========================================================================
843// Generic auth helpers - work against any UserModel.
844// =========================================================================
845
846/// Verify a username + plaintext password against the user table for
847/// user model `U`. Returns the user on success; returns
848/// `AuthError::InvalidCredentials` for both "no such user" and "wrong
849/// password" (the same shape, so a caller can't enumerate accounts).
850///
851/// The query uses `U::TABLE` for the table name. The WHERE clause
852/// filters on `username = ?` and `is_active = 1` (the standard column
853/// name for the active flag). Custom models that store the active flag
854/// under a different column name should filter directly and call
855/// `verify_password` themselves.
856///
857/// Does not update `last_login`; that is the login-flow's job once the
858/// HTTP layer is wired end-to-end.
859pub async fn authenticate<U>(username: &str, plaintext: &str) -> Result<U, AuthError>
860where
861    U: UserModel
862        + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
863        + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
864        + umbral::orm::HydrateRelated
865        + Unpin,
866{
867    let user: Option<U> = umbral::orm::Manager::<U>::default()
868        .filter(
869            umbral::orm::Predicate::<U>::col_eq("username", username)
870                & umbral::orm::Predicate::<U>::col_eq("is_active", true),
871        )
872        .first()
873        .await?;
874
875    let Some(user) = user else {
876        return Err(AuthError::InvalidCredentials);
877    };
878
879    // Defence-in-depth: also check the trait method so custom types
880    // that compute is_active dynamically (e.g. checking a TTL field)
881    // are still respected even if the SQL filter passed.
882    if !user.is_active() {
883        return Err(AuthError::InvalidCredentials);
884    }
885
886    if verify_password_async(plaintext, user.password_hash()).await? {
887        Ok(user)
888    } else {
889        Err(AuthError::InvalidCredentials)
890    }
891}
892
893/// Replace a user's password with a fresh hash of the given plaintext.
894/// Writes through to the database using `U::TABLE`. `user.password_hash`
895/// is updated in place on success so the caller can keep using the same
896/// value.
897pub async fn set_password<U>(user: &mut U, plaintext: &str) -> Result<(), AuthError>
898where
899    U: UserModel,
900{
901    // Low-level, like `create_user`: this rotates the stored hash and does
902    // NOT run the password-strength policy. Validation belongs at the
903    // boundary — a password-change route or form should call
904    // `validate_password` (with whatever user context it has) BEFORE invoking
905    // `set_password`, exactly as the `register` route gates `create_user`.
906    // Keeping the helper non-validating matches Django's `set_password`, which
907    // is a pure setter; the form is what validates.
908    let hash = hash_password_async(plaintext).await?;
909    let mut patch = serde_json::Map::new();
910    patch.insert(
911        "password_hash".to_string(),
912        serde_json::Value::String(hash.clone()),
913    );
914    umbral::orm::Manager::<U>::default()
915        .filter(umbral::orm::Predicate::<U>::col_eq("id", user.id()))
916        .update_values(patch)
917        .await?;
918    user.set_password_hash(hash);
919    Ok(())
920}
921
922// =========================================================================
923// Management command: createsuperuser
924// =========================================================================
925
926/// `createsuperuser` - Django's interactive superuser creation,
927/// dispatched via `cargo run -- createsuperuser` from any umbral
928/// project that registers [`AuthPlugin`].
929///
930/// Prompts for username, email, and password (the password input
931/// is read without terminal echo via `rpassword`). The new user
932/// lands with `is_active = true`, `is_staff = true`, `is_superuser =
933/// true` - the standard Django shape for the bootstrap admin account.
934///
935/// Flags:
936///
937/// - `--username <name>` - skip the username prompt.
938/// - `--email <addr>` - skip the email prompt.
939/// - `--noinput` - fail if any required value is missing instead of
940///   prompting. Useful in CI / containers / declarative seed paths.
941///   Reads password from `UMBRAL_SUPERUSER_PASSWORD` when set.
942#[derive(Debug, Default)]
943pub struct CreateSuperuserCommand;
944
945#[async_trait::async_trait]
946impl umbral::cli::PluginCommand for CreateSuperuserCommand {
947    fn command(&self) -> clap::Command {
948        clap::Command::new("createsuperuser")
949            .about("Create a superuser account (is_staff = is_superuser = true)")
950            .arg(
951                clap::Arg::new("username")
952                    .long("username")
953                    .help("Skip the interactive username prompt")
954                    .value_name("NAME"),
955            )
956            .arg(
957                clap::Arg::new("email")
958                    .long("email")
959                    .help("Skip the interactive email prompt")
960                    .value_name("ADDR"),
961            )
962            .arg(
963                clap::Arg::new("noinput")
964                    .long("noinput")
965                    .help(
966                        "Fail rather than prompt for any missing value. \
967                         Reads password from UMBRAL_SUPERUSER_PASSWORD env var.",
968                    )
969                    .action(clap::ArgAction::SetTrue),
970            )
971    }
972
973    async fn run(&self, matches: &clap::ArgMatches) -> Result<(), umbral::cli::CliError> {
974        let noinput = matches.get_flag("noinput");
975        let username = resolve_or_prompt(
976            matches.get_one::<String>("username").cloned(),
977            "Username",
978            noinput,
979            None,
980        )?;
981        let email = resolve_or_prompt(
982            matches.get_one::<String>("email").cloned(),
983            "Email",
984            noinput,
985            None,
986        )?;
987        let password = resolve_password(noinput)?;
988
989        let user = create_superuser(&username, &email, &password)
990            .await
991            .map_err(|e| -> umbral::cli::CliError { Box::new(e) })?;
992        println!(
993            "Created superuser `{}` (id = {}) - is_staff = true, is_superuser = true",
994            user.username, user.id,
995        );
996        Ok(())
997    }
998}
999
1000/// Get a value from the CLI flag, the env var, or the interactive
1001/// prompt. The `noinput` flag fails the CLI call rather than
1002/// prompting when no value is available.
1003fn resolve_or_prompt(
1004    cli_value: Option<String>,
1005    label: &str,
1006    noinput: bool,
1007    env_var: Option<&str>,
1008) -> Result<String, umbral::cli::CliError> {
1009    if let Some(v) = cli_value
1010        && !v.is_empty()
1011    {
1012        return Ok(v);
1013    }
1014    if let Some(key) = env_var
1015        && let Ok(v) = std::env::var(key)
1016        && !v.is_empty()
1017    {
1018        return Ok(v);
1019    }
1020    if noinput {
1021        return Err(
1022            format!("umbral createsuperuser: {label} not provided and --noinput is set").into(),
1023        );
1024    }
1025    print!("{label}: ");
1026    use std::io::Write;
1027    std::io::stdout().flush().ok();
1028    let mut s = String::new();
1029    std::io::stdin().read_line(&mut s)?;
1030    let v = s.trim().to_string();
1031    if v.is_empty() {
1032        return Err(format!("umbral createsuperuser: {label} cannot be empty").into());
1033    }
1034    Ok(v)
1035}
1036
1037/// Get the password - env var -> confirm-prompt with no-echo. Refuses
1038/// to proceed when the two confirmation entries don't match.
1039fn resolve_password(noinput: bool) -> Result<String, umbral::cli::CliError> {
1040    if let Ok(v) = std::env::var("UMBRAL_SUPERUSER_PASSWORD")
1041        && !v.is_empty()
1042    {
1043        return Ok(v);
1044    }
1045    if noinput {
1046        return Err(
1047            "umbral createsuperuser: password not provided (set UMBRAL_SUPERUSER_PASSWORD) \
1048             and --noinput is set"
1049                .into(),
1050        );
1051    }
1052    let first = rpassword::prompt_password("Password: ")?;
1053    if first.is_empty() {
1054        return Err("umbral createsuperuser: password cannot be empty".into());
1055    }
1056    let second = rpassword::prompt_password("Password (again): ")?;
1057    if first != second {
1058        return Err("umbral createsuperuser: passwords do not match".into());
1059    }
1060    Ok(first)
1061}