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//! 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 User model (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. A login-required gate 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 challenge;
68pub mod extractors;
69pub mod form_routes;
70pub mod login_required;
71pub mod mailer;
72pub mod password_validation;
73pub mod session_user;
74pub mod throttle;
75pub mod token;
76
77pub use mailer::{AuthMailError, AuthMailer, ConsoleMailer, MailKind, OutgoingMail};
78pub use password_validation::{
79    CommonPasswordValidator, MinLengthValidator, NumericPasswordValidator, PasswordContext,
80    PasswordPolicy, PasswordValidator, UserAttributeSimilarityValidator, validate_password,
81};
82
83pub use bearer_auth::{BearerAuthentication, parse_bearer_header};
84pub use challenge::{
85    AuthChallenge, change_password, reset_password, start_email_verification, start_password_reset,
86    verify_email,
87};
88pub use extractors::{
89    CurrentIdentity, OptionalIdentity, RequireAuth, RequireStaff, resolve_identity,
90};
91pub use login_required::{
92    LoggedIn, LoginRequired, LoginRequiredLayer, current_session_user_id, current_session_user_pk,
93    login_required, login_required_html, resolve_user as current_user_as,
94};
95pub use session_user::{
96    OptionalUser, SessionAuthentication, User, current_user, db_session_var_layer, login,
97    login_with_request, user_context_layer,
98};
99pub use throttle::{
100    Throttle, ThrottleConfig, email_action_throttle_check, login_throttle_check,
101    login_throttle_clear, register_throttle_check,
102};
103pub use token::{AuthToken, PlaintextToken, TOKEN_PREFIX, digest_token};
104
105/// Test shim: thin wrapper over `auth_routes::openapi_paths` so test binaries
106/// (which can't reach into `pub(crate)`) can assert the full path list.
107#[doc(hidden)]
108pub fn auth_routes_openapi_for_test(prefix: &str) -> Vec<(String, serde_json::Value)> {
109    auth_routes::openapi_paths(prefix)
110}
111
112use std::marker::PhantomData;
113
114use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
115use argon2::{Algorithm, Argon2, Params, Version, password_hash::rand_core::OsRng};
116use chrono::{DateTime, Utc};
117use serde::{Deserialize, Serialize};
118use umbral::prelude::*;
119
120// =========================================================================
121// UserModel trait
122// =========================================================================
123
124/// The minimum surface a user model must expose so `AuthPlugin<U>` can
125/// operate on it generically.
126///
127/// All four required methods map directly to columns that auth ACTUALLY
128/// reads or writes. Optional flag methods (`is_active`, `is_staff`,
129/// `is_superuser`) have default impls that return the safe defaults so a
130/// minimal custom user struct doesn't have to repeat them.
131///
132/// `AuthUser` implements this trait unchanged, so existing code that
133/// calls the auth helpers directly keeps working.
134///
135/// ## Required methods
136///
137/// | Method | Column | Used by |
138/// |---|---|---|
139/// | `id()` | `id` | `set_password` WHERE clause; session storage |
140/// | `username()` | `username` | `authenticate` SELECT, `createsuperuser` output |
141/// | `password_hash()` | `password_hash` | `authenticate` verify step |
142/// | `set_password_hash()` | `password_hash` | `set_password` in-place update |
143///
144/// ## Default methods
145///
146/// | Method | Default | Used by |
147/// |---|---|---|
148/// | `id_string()` | `self.id().to_string()` | `Identity::user_id`, session row |
149/// | `is_active()` | `true` | `authenticate` active-user gate |
150/// | `is_staff()` | `false` | admin require_staff check |
151/// | `is_superuser()` | `false` | permission gates |
152///
153/// ## Polymorphic primary key
154///
155/// `id()` returns the model's typed primary key via the existing
156/// `Model::PrimaryKey` associated type — the framework no longer
157/// hardcodes `i64`. A custom user model keyed by `uuid::Uuid`
158/// works as-is:
159///
160/// ```ignore
161/// #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize,
162///          umbral::orm::Model)]
163/// pub struct UuidUser {
164///     pub id: uuid::Uuid,
165///     pub username: String,
166///     pub password_hash: String,
167///     pub is_active: bool,
168///     pub is_staff: bool,
169/// }
170/// impl umbral_auth::UserModel for UuidUser {
171///     fn id(&self) -> uuid::Uuid { self.id }
172///     fn username(&self) -> &str { &self.username }
173///     fn password_hash(&self) -> &str { &self.password_hash }
174///     fn set_password_hash(&mut self, h: String) { self.password_hash = h; }
175///     fn is_active(&self) -> bool { self.is_active }
176///     fn is_staff(&self) -> bool { self.is_staff }
177/// }
178/// ```
179///
180/// The session-row text column, [`Identity::user_id`], and the
181/// permissions plugin all speak strings (via `id_string()`); the
182/// ORM-side WHERE clauses use the typed PK directly (via the
183/// `PrimaryKey: Into<sea_query::Value>` bound). Nothing in the
184/// framework parses `id()` back to `i64`.
185pub trait UserModel: Model + Send + Sync + 'static {
186    /// The row's typed primary key. `set_password` uses this in the
187    /// UPDATE WHERE clause; bearer-token / session backends use it
188    /// to filter on `auth_user::ID.eq(user.id())` style predicates.
189    ///
190    /// The return type is `<Self as Model>::PrimaryKey`, which the
191    /// `#[derive(Model)]` macro derives from the `id` field's type
192    /// (`i64`, `uuid::Uuid`, `String`, etc.). All `PrimaryKey`
193    /// types implement `Display`, so [`id_string`](Self::id_string)
194    /// can stringify without an explicit per-impl override.
195    fn id(&self) -> <Self as Model>::PrimaryKey;
196
197    /// The PK as a string. Used by [`umbral_sessions`] (which stores
198    /// `user_id` as text) and by the REST identity contract's
199    /// [`Identity::user_id`](umbral::auth::Identity) (which is
200    /// uniform across user models).
201    ///
202    /// Default uses the typed PK's `Display` impl — override only
203    /// when the stringification needs to differ from `Display`
204    /// (e.g. a base64-encoded ULID).
205    fn id_string(&self) -> String {
206        self.id().to_string()
207    }
208
209    /// The unique login handle. Matched against the username column in
210    /// `authenticate`'s SELECT query.
211    fn username(&self) -> &str;
212
213    /// The columns a login identifier is matched against in [`authenticate`],
214    /// OR-combined — so a user can sign in with any of them. Default is
215    /// `["username"]` (username-only, the historical behavior). A model with an
216    /// `email` column overrides this to `["username", "email"]` so either
217    /// works. Every listed column must exist on the table and hold a value the
218    /// identifier is normalized to match (see [`normalize_username`]); the
219    /// built-in `AuthUser` stores both `username` and `email` trimmed +
220    /// lowercased, so a case-insensitive login lands on the right row.
221    fn login_columns() -> &'static [&'static str] {
222        &["username"]
223    }
224
225    /// The argon2 PHC-encoded password hash stored in the DB column.
226    /// `authenticate` reads this, verifies it, and moves on.
227    fn password_hash(&self) -> &str;
228
229    /// Replace the in-memory password hash. Called by `set_password`
230    /// after writing the new hash to the database, so the caller's
231    /// `&mut U` reflects the update without a re-fetch.
232    fn set_password_hash(&mut self, hash: String);
233
234    /// Whether this account is active. `authenticate` rejects inactive
235    /// users with `InvalidCredentials` (same error as wrong password -
236    /// no account enumeration). Default: `true`.
237    fn is_active(&self) -> bool {
238        true
239    }
240
241    /// Whether this account has staff-level access to the admin
242    /// interface. Default: `false`.
243    fn is_staff(&self) -> bool {
244        false
245    }
246
247    /// Whether this account has superuser rights. Default: `false`.
248    fn is_superuser(&self) -> bool {
249        false
250    }
251}
252
253// =========================================================================
254// Built-in AuthUser model
255// =========================================================================
256
257/// The canonical authentication user. `#[derive(Model)]` snake_cases
258/// the struct name into the table name `auth_user`; the M3 derive
259/// doesn't yet accept `#[umbral(table = ...)]` so the snake_case
260/// round-trip is the only way to get a plugin-prefixed table name
261/// until the attribute lands.
262#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
263pub struct AuthUser {
264    pub id: i64,
265    /// `trim` + `lowercase` (gaps3 #34) canonicalize the username on the
266    /// dynamic write path (admin form-submit, REST create/update) so it's
267    /// case-insensitively unique there too — the typed `create_user` path
268    /// normalizes explicitly via `normalize_username` (gaps3 #33). Together
269    /// they close every write surface.
270    #[umbral(
271        unique,
272        trim,
273        lowercase,
274        help = "Unique login name; stored trimmed and lowercased."
275    )]
276    pub username: String,
277    /// Shown read-only on edit forms; never on create forms (use the
278    /// admin's password field mechanism for changes). `trim` + `lowercase`
279    /// canonicalize on the dynamic write path — see `username`.
280    /// `email` marks the column with the `email` text format, so every
281    /// dynamic write path (admin forms, REST resources) rejects a
282    /// malformed address via the ORM's single-source validator. The typed
283    /// `create_user` path stays non-validating by design — its callers
284    /// (the register route, `createsuperuser`) validate at their own
285    /// untrusted boundary.
286    #[umbral(
287        noedit,
288        unique,
289        trim,
290        lowercase,
291        email,
292        help = "Unique email address; also accepted as the login identifier."
293    )]
294    pub email: String,
295    /// Never shown on any form — password management goes through the
296    /// dedicated Change Password flow in the admin. `signal_skip` keeps the
297    /// hash out of every ORM signal payload (audit_2 core-app-config #10), so
298    /// an audit-log subscriber can't copy password hashes into its logs.
299    #[umbral(noform, signal_skip)]
300    pub password_hash: String,
301    #[umbral(help = "Inactive users cannot log in; deactivate instead of deleting.")]
302    pub is_active: bool,
303    /// Staff flag — grants admin-site access. Privileged: the untrusted JSON
304    /// write path (REST create/update, admin form-submit) refuses to set it
305    /// unless the caller authorizes it via `DynQuerySet::allow_privileged`
306    /// (audit_2 H3). Prevents a self-service `POST /users {is_staff: true}`
307    /// privilege escalation. An admin acting as a superuser still toggles it.
308    /// `default = "false"` so a create that had the field stripped fills the
309    /// safe value at the DB rather than tripping NOT NULL.
310    #[umbral(privileged, default = "false", help = "Grants admin-site access.")]
311    pub is_staff: bool,
312    /// Superuser flag — full authority. Privileged for the same reason as
313    /// `is_staff`; this is the field a mass-assignment attack most wants.
314    #[umbral(
315        privileged,
316        default = "false",
317        help = "Full authority: every permission, implicitly."
318    )]
319    pub is_superuser: bool,
320    #[umbral(help = "Set when the account is created.")]
321    pub date_joined: DateTime<Utc>,
322    #[umbral(help = "Stamped on every successful login; NULL until the first one.")]
323    pub last_login: Option<DateTime<Utc>>,
324    /// When this user's email was verified, NULL until they complete the
325    /// verification flow. Tracked always; only enforced when the plugin is
326    /// built with `require_verified_email()`.
327    #[umbral(help = "When the email was verified; NULL until the verification flow completes.")]
328    pub email_verified_at: Option<DateTime<Utc>>,
329}
330
331impl UserModel for AuthUser {
332    // `<AuthUser as Model>::PrimaryKey` is `i64` — the derive picks
333    // it up from the `id: i64` field. Returning `self.id` directly
334    // satisfies `fn id(&self) -> <Self as Model>::PrimaryKey` for
335    // the default AuthUser shape; a custom user model with a
336    // `uuid::Uuid` PK would return `self.id` of that type, and the
337    // default `id_string()` would stringify via `Display` for free.
338    fn id(&self) -> <Self as umbral::orm::Model>::PrimaryKey {
339        self.id
340    }
341
342    fn username(&self) -> &str {
343        &self.username
344    }
345
346    /// `AuthUser` accepts either the username or the email as the login
347    /// identifier — both columns are UNIQUE and stored trimmed + lowercased,
348    /// so a case-insensitive match lands on exactly one row.
349    fn login_columns() -> &'static [&'static str] {
350        &["username", "email"]
351    }
352
353    fn password_hash(&self) -> &str {
354        &self.password_hash
355    }
356
357    fn set_password_hash(&mut self, hash: String) {
358        self.password_hash = hash;
359    }
360
361    fn is_active(&self) -> bool {
362        self.is_active
363    }
364
365    fn is_staff(&self) -> bool {
366        self.is_staff
367    }
368
369    fn is_superuser(&self) -> bool {
370        self.is_superuser
371    }
372}
373
374// =========================================================================
375// AuthPlugin<U>
376// =========================================================================
377
378/// A `Mutex`-wrapped optional mailer slot that implements `Debug` manually so
379/// `#[derive(Debug)]` on `AuthPlugin` keeps working even though
380/// `Arc<dyn AuthMailer>` is not `Debug`.
381struct MailerSlot(std::sync::Mutex<Option<std::sync::Arc<dyn mailer::AuthMailer>>>);
382impl std::fmt::Debug for MailerSlot {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        f.write_str("MailerSlot(..)")
385    }
386}
387
388/// The built-in authentication plugin, generic over the user model.
389///
390/// `U` defaults to [`AuthUser`] so `AuthPlugin::default()` continues to
391/// work in all existing code unchanged. Apps that need a custom user type
392/// opt in with one line:
393///
394/// ```ignore
395/// .plugin(AuthPlugin::<CustomUser>::default())
396/// ```
397///
398/// ## `user_model_name`
399///
400/// An optional informational string surfaced in OpenAPI schemas and the
401/// admin nav. Default `None` (resolved from `U::NAME` by the plugin
402/// itself when left empty). Set it explicitly when the type name is
403/// insufficient:
404///
405/// ```ignore
406/// AuthPlugin::<TenantUser>::default().user_model_name("tenant_user")
407/// ```
408#[derive(Debug)]
409pub struct AuthPlugin<U: UserModel = AuthUser> {
410    /// Documentation-only: the human-readable name of the active user
411    /// model. Consumed by admin / OpenAPI when surfacing the user table.
412    /// The actual dispatch is entirely through the type parameter `U`.
413    pub user_model_name: Option<String>,
414    /// When `Some`, mount the four built-in routes (register / login /
415    /// logout / me) under this prefix. `None` skips them — the user
416    /// either doesn't want them or is rolling their own surface. Only
417    /// settable on `AuthPlugin<AuthUser>` (the handlers FK into
418    /// `AuthToken` → `AuthUser`); custom user models bring their own.
419    pub default_routes_prefix: Option<String>,
420    /// When `Some`, mount the 7 POST form-action routes (login, logout,
421    /// signup, verify-email, resend, password-forgot, password-reset)
422    /// under this prefix. Default `None` — opt in via
423    /// [`AuthPlugin::with_form_routes`] / [`AuthPlugin::with_form_routes_at`].
424    /// Only settable on `AuthPlugin<AuthUser>`.
425    pub form_routes_prefix: Option<String>,
426    /// When true, wrap the app router with [`user_context_layer`] so
427    /// every template render has `user` in its global context:
428    /// `{ is_authenticated, is_staff, username, ... }`. Opt-in because
429    /// it costs one DB read per request (cookie → session → user); a
430    /// REST-only service has nothing to gain from it. Set via
431    /// [`AuthPlugin::with_user_in_templates`].
432    pub user_in_templates: bool,
433    /// Reverse-FK child tables to expand as one-to-many lists in the
434    /// template `user` context (features #84). Each entry is a child
435    /// table name recorded by [`AuthPlugin::expand_list`]; the layer
436    /// injects up to a capped number of that child's rows (those whose
437    /// FK points at the user) under `user.<table>_set`. Empty by default
438    /// — opt-in, because a child list is an unbounded per-request query.
439    /// Only meaningful alongside [`Self::with_user_in_templates`].
440    pub expand_lists: Vec<String>,
441    /// When `Some(name)`, publish the authenticated user's id to the database
442    /// connection as the Postgres session variable `name` on every request, so
443    /// a row-level-security policy can read it via `current_setting(name)`.
444    /// `None` (the default) mounts no layer at all. Set via
445    /// [`AuthPlugin::with_db_session_var`]. gaps3 #45.
446    pub db_session_var: Option<String>,
447    /// The password-strength policy this plugin installs at boot. `None`
448    /// here is NOT "no validation" — `on_ready` installs
449    /// [`PasswordPolicy::default`] (the full secure set) when this is left
450    /// unset, so the plugin is secure by default. The only way to get an
451    /// empty policy is to call [`AuthPlugin::disable_password_validation`],
452    /// which stores an explicit [`PasswordPolicy::empty`].
453    ///
454    /// Wrapped in a `Mutex` because `Plugin::on_ready` only borrows `&self`
455    /// yet needs to MOVE the policy into the ambient `OnceLock`
456    /// ([`PasswordPolicy`] is not `Clone` — it holds boxed trait objects).
457    /// The mutex lets `on_ready` `.take()` it; the first boot wins.
458    password_policy: std::sync::Mutex<Option<PasswordPolicy>>,
459    /// The login/register rate-limit configuration this plugin installs at
460    /// boot. Secure by default ([`ThrottleConfig::default`]: login 5 / 5 min
461    /// per IP+username, register 10 / hour per IP, `enabled = true`). Builder
462    /// methods ([`AuthPlugin::login_throttle`], [`AuthPlugin::register_throttle`])
463    /// tune the budgets; [`AuthPlugin::disable_throttle`] flips `enabled` off
464    /// as an explicit opt-out. `Copy`, so no `Mutex`/`take` dance is needed —
465    /// `on_ready` reads it directly.
466    throttle_config: throttle::ThrottleConfig,
467    /// The mailer sealed into the ambient `OnceLock` on `on_ready`. Wrapped
468    /// in a `Mutex` (via `MailerSlot`) so `on_ready`'s `&self` can `.take()`
469    /// the value. First boot wins; subsequent calls are no-ops.
470    mailer: MailerSlot,
471    /// When `true`, the `register` route auto-sends a verification code and the
472    /// `login` route returns 403 until `email_verified_at` is stamped. Off by
473    /// default — the column is tracked and the endpoints exist regardless; only
474    /// the enforcement gate is toggled here. Set via
475    /// [`AuthPlugin::require_verified_email`] (available on
476    /// `AuthPlugin<AuthUser>` only, since it gates the built-in routes).
477    require_verified: bool,
478    /// Optional override for the argon2 concurrency cap (audit_2 plugin-auth
479    /// #4). `None` uses the framework default — machine parallelism (min 2),
480    /// or the `UMBRAL_AUTH_HASH_CONCURRENCY` env var. Sealed at `on_ready`.
481    hash_concurrency: Option<usize>,
482    _u: PhantomData<U>,
483}
484
485impl<U: UserModel> Default for AuthPlugin<U> {
486    fn default() -> Self {
487        Self {
488            user_model_name: None,
489            default_routes_prefix: None,
490            form_routes_prefix: None,
491            user_in_templates: false,
492            expand_lists: Vec::new(),
493            db_session_var: None,
494            // SECURE BY DEFAULT: an unconfigured AuthPlugin enforces the
495            // full validator set. `None` defers to PasswordPolicy::default()
496            // (the secure set) at install time; it does NOT mean "off".
497            password_policy: std::sync::Mutex::new(None),
498            // SECURE BY DEFAULT: throttling is ON for login + register with
499            // the credential-stuffing-resistant budgets above. `disable_throttle`
500            // is the only path that turns it off.
501            throttle_config: throttle::ThrottleConfig::default(),
502            mailer: MailerSlot(std::sync::Mutex::new(None)),
503            require_verified: false,
504            hash_concurrency: None,
505            _u: PhantomData,
506        }
507    }
508}
509
510impl<U: UserModel> AuthPlugin<U> {
511    /// Override the informational user-model name shown in admin / OpenAPI.
512    /// Fluent builder method; the return type is `Self` so it chains.
513    pub fn user_model_name(mut self, name: impl Into<String>) -> Self {
514        self.user_model_name = Some(name.into());
515        self
516    }
517
518    /// Mount the [`user_context_layer`] middleware globally so every
519    /// HTML template gets `user` in its render context — anonymous
520    /// requests see `{ is_authenticated: false }`, authenticated
521    /// requests see the full serialized [`AuthUser`] merged with
522    /// `is_authenticated: true`. Lets templates write
523    /// `{% if user.is_staff %}` without the consumer having to thread
524    /// a user value into every handler's context manually.
525    ///
526    /// One DB read per request (cookie → session → user row). Off by
527    /// default because REST-only services have no templates and the
528    /// cost would be pure overhead. Turn it on for HTML-heavy apps:
529    ///
530    /// ```ignore
531    /// AuthPlugin::<AuthUser>::default()
532    ///     .with_default_routes()
533    ///     .with_user_in_templates()   // ← here
534    /// ```
535    ///
536    /// Implemented via [`Plugin::wrap_router`]; the wrapper wraps the
537    /// merged app router (including every other plugin's routes), so
538    /// admin / REST / playground / your own handlers all see the
539    /// populated context with one builder call.
540    pub fn with_user_in_templates(mut self) -> Self {
541        self.user_in_templates = true;
542        self
543    }
544
545    /// Expand a reverse-FK **one-to-many list** into the template `user`
546    /// context (features #84). Declare each child model you want to walk;
547    /// the layer injects up to a capped number of that child's rows (the
548    /// ones whose foreign key points at the user) under `user.<table>_set`:
549    ///
550    /// ```ignore
551    /// AuthPlugin::<AuthUser>::default()
552    ///     .with_user_in_templates()
553    ///     .expand_list::<Order>()   // → `{% for o in user.order_set %}`
554    /// ```
555    ///
556    /// Opt-in and per-relation *by design*: a child list is an unbounded
557    /// per-request query, so it is never auto-injected — you name the ones
558    /// worth the cost. The list is `LIMIT`-ed (see the layer's cap) and its
559    /// items are the flat child rows (not further expanded); need more, or
560    /// a filtered/ordered slice, and you resolve it in the handler instead.
561    /// Only takes effect together with [`Self::with_user_in_templates`].
562    pub fn expand_list<M: umbral::orm::Model>(mut self) -> Self {
563        let table = M::table_name().to_string();
564        if !self.expand_lists.contains(&table) {
565            self.expand_lists.push(table);
566        }
567        self
568    }
569
570    /// Publish the authenticated user's id to the database connection as a
571    /// Postgres session variable, so a row-level-security policy can read it.
572    ///
573    /// This is the wiring that makes `umbral-rls` usable. RLS is the only
574    /// permission layer in umbral that cannot be bypassed by application code —
575    /// the database itself refuses the row — and a policy expresses "who is
576    /// asking?" as `current_setting('app.user_id')`. Something has to set that.
577    ///
578    /// ```ignore
579    /// App::builder()
580    ///     .plugin(SessionsPlugin::default())
581    ///     .plugin(AuthPlugin::<AuthUser>::default().with_db_session_var("app.user_id"))
582    ///     .plugin(RlsPlugin::new().policy(
583    ///         "post", "own_rows", Action::All,
584    ///         "user_id = NULLIF(current_setting('app.user_id'), '')::bigint",
585    ///     ))
586    /// ```
587    ///
588    /// The variable is set on **every** request, to the empty string when the
589    /// caller is anonymous. That is deliberate: Postgres raises
590    /// `unrecognized configuration parameter` when `current_setting` names a GUC
591    /// that was never set on the connection, so skipping it for logged-out users
592    /// would turn each of their requests into a 500 instead of a clean "you see
593    /// no rows". Write policies against `NULLIF(current_setting(...), '')`.
594    ///
595    /// Identity comes from the session, never from a client-supplied header, and
596    /// a deactivated account resolves to anonymous (the lookup filters on
597    /// `is_active`).
598    ///
599    /// **Costs one session + one user read per request**, and unlike
600    /// [`Self::with_user_in_templates`] it cannot be lazy: the value has to be on
601    /// the connection before the handler's first query, not after something asks
602    /// for it. Off by default for that reason.
603    ///
604    /// **Do not enable RLS on `auth_user` or `session`.** This layer reads them
605    /// to discover who the caller is, before any variable has been set.
606    pub fn with_db_session_var(mut self, name: impl Into<String>) -> Self {
607        self.db_session_var = Some(name.into());
608        self
609    }
610
611    /// Replace the default password-strength policy with a custom one.
612    /// The full [`PasswordPolicy`] you pass becomes the active set at boot;
613    /// the default validators are NOT merged in. Build the policy
614    /// you want from scratch:
615    ///
616    /// ```ignore
617    /// use umbral_auth::{AuthPlugin, PasswordPolicy, MinLengthValidator, CommonPasswordValidator};
618    /// AuthPlugin::<AuthUser>::default().password_validators(
619    ///     PasswordPolicy::empty()
620    ///         .with(Box::new(MinLengthValidator(12)))
621    ///         .with(Box::new(CommonPasswordValidator)),
622    /// )
623    /// ```
624    pub fn password_validators(mut self, policy: PasswordPolicy) -> Self {
625        self.password_policy = std::sync::Mutex::new(Some(policy));
626        self
627    }
628
629    /// Convenience: keep the four default validators but change the minimum
630    /// password length. Equivalent to building a [`PasswordPolicy`] with a
631    /// [`MinLengthValidator`] of `n` plus the other three defaults.
632    pub fn min_password_length(self, n: usize) -> Self {
633        self.password_validators(PasswordPolicy::new(vec![
634            Box::new(MinLengthValidator(n)),
635            Box::new(CommonPasswordValidator),
636            Box::new(NumericPasswordValidator),
637            Box::new(UserAttributeSimilarityValidator::default()),
638        ]))
639    }
640
641    /// Explicit opt-OUT: install an empty policy so NO password validation
642    /// runs. Secure-by-default means an app that genuinely wants to accept
643    /// any password — a throwaway demo, a migration importing legacy hashes
644    /// with externally-validated plaintext — has to ask for it by name.
645    /// Don't reach for this to silence a failing test; fix the fixture's
646    /// password instead.
647    pub fn disable_password_validation(mut self) -> Self {
648        self.password_policy = std::sync::Mutex::new(Some(PasswordPolicy::empty()));
649        self
650    }
651
652    /// Tune the login rate limit: `max` failed-or-not attempts per trailing
653    /// `window`, keyed per IP + username. The default is 5 / 5 min — a budget
654    /// that stops credential-stuffing dead while leaving room for a human who
655    /// fat-fingers their password a couple of times (a successful login also
656    /// clears the counter). Lower it for a high-security surface; raise it for
657    /// a shared-NAT office where many users hit login from one IP.
658    ///
659    /// ```ignore
660    /// AuthPlugin::<AuthUser>::default().login_throttle(10, Duration::from_secs(300))
661    /// ```
662    pub fn login_throttle(mut self, max: usize, window: std::time::Duration) -> Self {
663        self.throttle_config.login_max = max;
664        self.throttle_config.login_window = window;
665        self
666    }
667
668    /// Tune the register rate limit: `max` account-creation attempts per
669    /// trailing `window`, keyed per IP. The default is 10 / hour, which brakes
670    /// mass automated signups without blocking a legitimate burst from one
671    /// office.
672    pub fn register_throttle(mut self, max: usize, window: std::time::Duration) -> Self {
673        self.throttle_config.register_max = max;
674        self.throttle_config.register_window = window;
675        self
676    }
677
678    /// Tune the email-action rate limit: `max` attempts per trailing `window`,
679    /// keyed per IP + email. Covers verify-email, resend-verification, and
680    /// password-forgot. The default is 5 / hour — enough for a user who needs
681    /// a couple of resends, but low enough to stop email-bombing / online
682    /// code-guessing scripts dead.
683    pub fn email_action_throttle(mut self, max: usize, window: std::time::Duration) -> Self {
684        self.throttle_config.email_action_max = max;
685        self.throttle_config.email_action_window = window;
686        self
687    }
688
689    /// Explicit opt-OUT: turn login, register, and email-action throttling OFF
690    /// entirely. Secure-by-default means an app that genuinely wants no rate
691    /// limit — a load test, an internal tool behind its own gateway limiter —
692    /// has to ask for it by name. Don't reach for this to silence a throttled
693    /// test; use a distinct IP/username per attempt or generous budget methods
694    /// instead.
695    pub fn disable_throttle(mut self) -> Self {
696        self.throttle_config.enabled = false;
697        self
698    }
699
700    /// Cap how many argon2 hash/verify operations may run concurrently
701    /// (audit_2 plugin-auth #4). Each argon2id op allocates ~19 MiB and pins a
702    /// CPU, so without a bound a login/register/reset flood can spawn hundreds
703    /// at once and OOM the process. The default is the machine's parallelism
704    /// (min 2) — more concurrent hashes than cores only thrashes and multiplies
705    /// peak memory. Requests past `cap × 8` in-flight (running + waiting) are
706    /// shed with HTTP 503 so clients back off. Override only if you have a
707    /// specific reason (e.g. reserving cores for request handling).
708    ///
709    /// `UMBRAL_AUTH_HASH_CONCURRENCY` overrides this at runtime; a `0` here is
710    /// ignored (the default applies).
711    pub fn hash_concurrency(mut self, cap: usize) -> Self {
712        self.hash_concurrency = Some(cap);
713        self
714    }
715
716    /// Wire the mailer used by the verification + password-reset flows.
717    /// Pass a type implementing [`AuthMailer`] or an async closure
718    /// `|mail| async { ... }`. Unset → [`ConsoleMailer`] (stderr in dev).
719    ///
720    /// ```ignore
721    /// AuthPlugin::<AuthUser>::default().mailer(|m: OutgoingMail| async move {
722    ///     umbral_email::send(&umbral_email::EmailMessage::new(m.subject, vec![m.to])
723    ///         .html_body(m.html).text_body(m.text)).await
724    ///         .map(|_| ()).map_err(|e| AuthMailError::Send(e.to_string()))
725    /// })
726    /// ```
727    pub fn mailer(self, m: impl mailer::AuthMailer + 'static) -> Self {
728        *self.mailer.0.lock().expect("mailer slot poisoned") = Some(std::sync::Arc::new(m));
729        self
730    }
731
732    /// Resolve the JSON route prefix.
733    ///
734    /// Returns `None` when `with_default_routes[_at]` was not called (no
735    /// routes mounted). When the stored value equals `JSON_PREFIX_SENTINEL`
736    /// (set by `with_default_routes()`), returns `{api_base()}/auth` —
737    /// resolved at call-time, after `App::build` has had a chance to set the
738    /// base. A literal prefix stored by `with_default_routes_at` is returned
739    /// as-is.
740    ///
741    /// Private: called from the `Plugin` trait impl (`routes`,
742    /// `route_paths`, `openapi_paths`). Not part of the public API.
743    fn json_prefix(&self) -> Option<String> {
744        self.default_routes_prefix.as_ref().map(|p| {
745            if p == JSON_PREFIX_SENTINEL {
746                format!("{}/auth", umbral::web::api_base())
747            } else {
748                p.clone()
749            }
750        })
751    }
752}
753
754// =========================================================================
755// Default route opt-in. Only exposed on AuthPlugin<AuthUser> because the
756// handlers FK into AuthUser via AuthToken. Custom user models would need a
757// different token model + different handlers; they bring their own surface.
758// The concrete impl block (no <U>) is the compile-time witness: calling
759// `.with_default_routes()` on `AuthPlugin::<CustomUser>` is an error at
760// the call site, not a silent no-op at runtime.
761// =========================================================================
762
763// =========================================================================
764// Ambient require_verified seal — mirrors the password policy / mailer pattern.
765// =========================================================================
766
767/// Process-global flag set once in `on_ready`. Handlers read it as a free
768/// function so they don't need a handle to `AuthPlugin<U>`.
769static REQUIRE_VERIFIED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
770
771/// Whether the `require_verified_email()` builder was called on the active
772/// `AuthPlugin`. `false` until `on_ready` seals it; `false` as the fallback
773/// if `on_ready` was somehow skipped (should never happen in a well-formed
774/// `App::build`, but safe-default matters here — off = permissive).
775pub(crate) fn verified_email_required() -> bool {
776    *REQUIRE_VERIFIED.get().unwrap_or(&false)
777}
778
779/// Stored by `with_default_routes()` so the JSON prefix can be resolved at
780/// build time (when `api_base()` is already set by `App::build`) rather than
781/// when the builder method is called (before `App::build` has set the base).
782/// An internal null-byte sentinel that no real path can equal.
783const JSON_PREFIX_SENTINEL: &str = "\0auto-api-base\0";
784
785impl AuthPlugin<AuthUser> {
786    /// The standard auth plugin over the built-in [`AuthUser`] — no
787    /// turbofish (gaps4 #45).
788    ///
789    /// `AuthPlugin::<AuthUser>::default()` was written out in every app
790    /// because Rust's default type parameters don't participate in
791    /// fn-call inference: `AuthPlugin::default()` is "type annotations
792    /// needed" even though `AuthUser` is the declared default. `new` is
793    /// defined ONLY on `AuthPlugin<AuthUser>`, so `AuthPlugin::new()`
794    /// resolves the parameter by having exactly one candidate:
795    ///
796    /// ```ignore
797    /// .plugin(AuthPlugin::new().with_default_routes())
798    /// ```
799    ///
800    /// Custom user models keep the explicit form:
801    /// `AuthPlugin::<TenantUser>::default()`.
802    pub fn new() -> Self {
803        Self::default()
804    }
805
806    /// Mount the built-in `/api/auth/{register,login,logout,me,…}`
807    /// surface. Same handlers that lived in the derive-demo example
808    /// app, promoted to the framework so every app gets them with one
809    /// line. JSON-only; UNIQUE-violation → 409; login returns both a
810    /// Set-Cookie and a bearer token in one response so browsers and
811    /// CLI clients share an endpoint.
812    ///
813    /// The prefix resolves at build time: `{api_base()}/auth`, so it
814    /// follows whatever base the REST plugin set (default `/api/auth`).
815    /// Use [`Self::with_default_routes_at`] to fix a literal prefix.
816    pub fn with_default_routes(mut self) -> Self {
817        // Store the sentinel; `json_prefix()` resolves it at call-time
818        // (which is during `App::build` → `Plugin::routes`), after the
819        // REST plugin has had a chance to call `set_api_base`.
820        self.default_routes_prefix = Some(JSON_PREFIX_SENTINEL.to_string());
821        self
822    }
823
824    /// Same as [`Self::with_default_routes`] but the prefix is yours
825    /// to pick. Useful when `/api/auth` collides with an existing
826    /// surface or you want versioning (`/v1/auth`).
827    pub fn with_default_routes_at(mut self, prefix: impl Into<String>) -> Self {
828        self.default_routes_prefix = Some(prefix.into());
829        self
830    }
831
832    /// Block login until the user's `email_verified_at` column is stamped, and
833    /// auto-send a verification code immediately on `register`. Off by default
834    /// — the `email_verified_at` column is always tracked and the
835    /// `/verify-email` + `/resend-verification` endpoints are always mounted;
836    /// this flag only controls enforcement:
837    ///
838    /// - **register**: after a successful `create_user`, fires
839    ///   `start_email_verification` best-effort (a mail failure does NOT fail
840    ///   registration; it is logged at `warn` level). The `201` response is
841    ///   unchanged.
842    /// - **login**: after `authenticate` succeeds and before minting the
843    ///   bearer token / session, checks `email_verified_at IS NULL`; returns
844    ///   `403 {error: "email_not_verified"}` if so.
845    ///
846    /// Available only on `AuthPlugin<AuthUser>` because enforcement is
847    /// implemented inside the built-in handlers (which are `AuthUser`-only).
848    /// Custom user models bring their own routes and their own enforcement.
849    ///
850    /// Requires a working mailer in production — wire
851    /// [`AuthPlugin::mailer`] alongside this builder, or users won't receive
852    /// the verification code and will be permanently locked out:
853    ///
854    /// ```ignore
855    /// AuthPlugin::<AuthUser>::default()
856    ///     .with_default_routes()
857    ///     .mailer(my_smtp_mailer)
858    ///     .require_verified_email()
859    /// ```
860    pub fn require_verified_email(mut self) -> Self {
861        self.require_verified = true;
862        self
863    }
864
865    /// Mount the 7 POST form-action auth routes (login, logout, signup,
866    /// verify-email, resend, password-forgot, password-reset) under the
867    /// default `/auth` prefix.
868    ///
869    /// These are the form-action **endpoints** that developer-written HTML
870    /// forms POST to: `<form method="POST" action="/auth/login">`. The
871    /// framework never ships the pages themselves — the developer writes
872    /// those with their own brand and design.
873    ///
874    /// Each handler receives a form-encoded body, runs the same auth logic
875    /// as the JSON surface (including throttle and enumeration-safe guards),
876    /// sets a flash message via the session, then returns a 303 redirect.
877    ///
878    /// Use [`Self::with_form_routes_at`] to mount under a custom prefix.
879    pub fn with_form_routes(mut self) -> Self {
880        self.form_routes_prefix = Some("/auth".into());
881        self
882    }
883
884    /// Same as [`Self::with_form_routes`] but you choose the prefix.
885    ///
886    /// ```ignore
887    /// AuthPlugin::<AuthUser>::default().with_form_routes_at("/accounts")
888    /// ```
889    pub fn with_form_routes_at(mut self, prefix: impl Into<String>) -> Self {
890        self.form_routes_prefix = Some(prefix.into());
891        self
892    }
893}
894
895// The extra bounds beyond `UserModel` are what `resolve_user::<U>` needs to load
896// the row — the same set `LoggedIn<U>` already requires. They are stated here so
897// `wrap_router` can mount `db_session_var_layer::<U>` (gaps3 #45). Any user model
898// that couldn't satisfy them was already unusable with the `LoggedIn` extractor.
899impl<U> Plugin for AuthPlugin<U>
900where
901    U: UserModel
902        + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
903        + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
904        + umbral::orm::HydrateRelated
905        + Unpin
906        + Send,
907    <U as umbral::orm::Model>::PrimaryKey: std::str::FromStr,
908{
909    fn name(&self) -> &'static str {
910        "auth"
911    }
912
913    fn models(&self) -> Vec<umbral::migrate::ModelMeta> {
914        // AuthToken FKs against AuthUser specifically (FK target is
915        // a concrete `Model` type, not a `UserModel`). Apps wiring
916        // `AuthPlugin::<CustomUser>` get the user table migrated but
917        // NOT the token table — they bring their own token model
918        // and their own bearer-auth backend.
919        let mut models = vec![umbral::migrate::ModelMeta::for_::<U>()];
920        if std::any::TypeId::of::<U>() == std::any::TypeId::of::<AuthUser>() {
921            models.push(umbral::migrate::ModelMeta::for_::<AuthToken>());
922            models.push(umbral::migrate::ModelMeta::for_::<AuthChallenge>());
923        }
924        models
925    }
926
927    fn templates_dirs(&self) -> Vec<std::path::PathBuf> {
928        // The auth plugin ships its own templates (email bodies, future
929        // HTML auth forms). They live under `plugins/umbral-auth/templates/`
930        // in the repo, and `CARGO_MANIFEST_DIR` resolves to that crate root
931        // at compile time so the path stays correct regardless of where the
932        // binary is invoked from.
933        vec![std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates")]
934    }
935
936    fn commands(&self) -> Vec<Box<dyn umbral::cli::PluginCommand>> {
937        vec![
938            Box::new(CreateSuperuserCommand),
939            // Generic over the plugin's own user model `U`, so an
940            // `AuthPlugin<MyUser>` neutralizes *its* users, not `AuthUser`.
941            Box::new(ResetForeignPasswordsCommand::<U>::default()),
942        ]
943    }
944
945    fn routes(&self) -> umbral::web::Router {
946        // `default_routes_prefix` is only ever Some when U = AuthUser
947        // (the only impl block that sets it is `impl AuthPlugin<AuthUser>`).
948        // So the prefix-guarded branch is dead code for any custom user
949        // model — both at compile time (the builder method isn't
950        // visible) and at runtime (the field stays None).
951        //
952        // `json_prefix()` resolves the sentinel stored by `with_default_routes()`
953        // to `{api_base()}/auth` at build time, after `App::build` has
954        // had a chance to set the REST base path.
955        let mut r = match self.json_prefix() {
956            Some(prefix) => auth_routes::build_router(&prefix),
957            None => umbral::web::Router::new(),
958        };
959        if let Some(p) = &self.form_routes_prefix {
960            r = r.merge(form_routes::build_router(p));
961        }
962        r
963    }
964
965    fn route_paths(&self) -> Vec<umbral::routes::RouteSpec> {
966        let mut paths = match self.json_prefix() {
967            Some(prefix) => auth_routes::declared_routes(&prefix),
968            None => Vec::new(),
969        };
970        if let Some(p) = &self.form_routes_prefix {
971            paths.extend(form_routes::declared_routes(p));
972        }
973        paths
974    }
975
976    fn openapi_paths(&self) -> Vec<(String, serde_json::Value)> {
977        match self.json_prefix() {
978            Some(prefix) => auth_routes::openapi_paths(&prefix),
979            None => Vec::new(),
980        }
981    }
982
983    /// Mount [`user_context_layer`] on the full merged router when the
984    /// `user_in_templates` flag is on (see
985    /// [`AuthPlugin::with_user_in_templates`]). The layer reads the
986    /// session cookie, hydrates the [`AuthUser`], and pushes a
987    /// `serde_json` representation into [`umbral::templates::CURRENT_USER`]
988    /// for the duration of the request — every template render
989    /// downstream gets `user` in its global context with no per-handler
990    /// plumbing.
991    ///
992    /// Off by default — see the builder method's docstring for the
993    /// "why" (one DB read per request, pointless for REST-only apps).
994    fn wrap_router(&self, router: umbral::web::Router) -> umbral::web::Router {
995        let mut router = router;
996        if self.user_in_templates {
997            // features #84: hand the layer the opt-in reverse-FK-list tables
998            // as state. Empty (the default) → the layer expands only forward
999            // FKs and reverse-O2Os, exactly as before.
1000            let expand_lists = std::sync::Arc::new(self.expand_lists.clone());
1001            router = router.layer(axum::middleware::from_fn_with_state(
1002                expand_lists,
1003                user_context_layer,
1004            ));
1005        }
1006        if let Some(name) = &self.db_session_var {
1007            // Applied last, so it is the OUTERMOST of this plugin's layers: the
1008            // session variable has to be on the RouteContext before any inner
1009            // layer or handler acquires a connection (gaps3 #45).
1010            let name: std::sync::Arc<str> = std::sync::Arc::from(name.as_str());
1011            router = router.layer(axum::middleware::from_fn_with_state(
1012                name,
1013                db_session_var_layer::<U>,
1014            ));
1015        }
1016        router
1017    }
1018
1019    /// Seal the password-strength policy into the ambient `OnceLock` so the
1020    /// free-function helpers (`create_user`, `set_password`) can read it
1021    /// without a handle to `Self`. Mirrors the sessions plugin's
1022    /// `SLIDING_EXPIRY_ENABLED` install.
1023    ///
1024    /// A `None` configured policy means "use the secure default" — NOT
1025    /// "off" — so we install [`PasswordPolicy::default`] in that case.
1026    /// `disable_password_validation` is the only path that installs an
1027    /// empty policy. The install is idempotent (first boot wins), matching
1028    /// the ambient-pool contract.
1029    fn on_ready(
1030        &self,
1031        _ctx: &umbral::plugin::AppContext,
1032    ) -> Result<(), umbral::plugin::PluginError> {
1033        let policy = self
1034            .password_policy
1035            .lock()
1036            .ok()
1037            .and_then(|mut guard| guard.take())
1038            .unwrap_or_default();
1039        password_validation::install_policy(policy);
1040        // Install the rate limiter the same way: the route handlers are free
1041        // functions, so they read the limiter ambiently via the `throttle`
1042        // free helpers. First boot wins (idempotent set), matching the
1043        // password-policy / ambient-pool contract.
1044        throttle::install(throttle::AuthThrottle::from_config(self.throttle_config));
1045        // Seal the mailer into the ambient OnceLock. If None (not configured
1046        // by the builder), the active_mailer() fallback supplies ConsoleMailer.
1047        if let Ok(mut guard) = self.mailer.0.lock() {
1048            if let Some(m) = guard.take() {
1049                crate::mailer::install_mailer(m);
1050            }
1051        }
1052        // Seal the verified-email enforcement flag. First boot wins (idempotent),
1053        // matching the password-policy / mailer / ambient-pool contract.
1054        let _ = REQUIRE_VERIFIED.set(self.require_verified);
1055        // Seal the argon2 concurrency cap BEFORE any request hashing runs, so
1056        // the gate's semaphore is sized from it (audit_2 plugin-auth #4). Only
1057        // when the builder set an explicit value; otherwise the lazy default
1058        // (machine parallelism / env var) applies.
1059        if let Some(n) = self.hash_concurrency.filter(|&n| n > 0) {
1060            let _ = HASH_CONCURRENCY.set(n);
1061        }
1062        Ok(())
1063    }
1064}
1065
1066// =========================================================================
1067// AuthError
1068// =========================================================================
1069
1070/// Errors the auth helpers can produce. Kept narrow at M9 v1 so the
1071/// surface is easy to handle in one match arm.
1072#[derive(Debug)]
1073pub enum AuthError {
1074    /// argon2 produced or failed to parse a password hash. Carries the
1075    /// raw error so the diagnostic includes argon2's own message.
1076    PasswordHash(argon2::password_hash::Error),
1077    /// sqlx error executing one of the helper queries.
1078    Sqlx(sqlx::Error),
1079    /// ORM write error — `create`, `update_values`, etc.
1080    Write(umbral::orm::write::WriteError),
1081    /// `authenticate` was called with credentials that don't match any
1082    /// active user. Returned for both "no such user" and "wrong
1083    /// password" so a caller can't tell which from the error alone.
1084    InvalidCredentials,
1085    /// The plaintext password failed one or more password-strength
1086    /// validators (see [`crate::password_validation`]). Carries every
1087    /// human-readable reason so the route / form can show the full list.
1088    ///
1089    /// This is NOT produced by the low-level creation helpers anymore
1090    /// (`create_user` / `create_user_with_flags` / `create_superuser` /
1091    /// `set_password` are all low-level and do not validate). It is
1092    /// constructed at the **registration boundary** — the `register` route
1093    /// calls [`crate::validate_password`] up front and wraps any failure in
1094    /// this variant, which the route layer then maps to 400. A custom signup
1095    /// flow that wants the same behaviour follows the same pattern.
1096    WeakPassword(Vec<String>),
1097    /// A blocking task offloaded to the tokio blocking pool (argon2
1098    /// hashing / verification via [`hash_password_async`] /
1099    /// [`verify_password_async`]) failed to join — i.e. the task panicked
1100    /// or was cancelled. Carries the `JoinError`'s message. A panic in the
1101    /// hash worker is a real error, surfaced rather than swallowed.
1102    Runtime(String),
1103    /// A session-layer error surfaced through one of the auth helpers
1104    /// (`logout`, etc.). Carries the session error's display string so
1105    /// callers match a single `AuthError` type without importing
1106    /// `umbral_sessions::SessionError`.
1107    Session(String),
1108    /// Template rendering failed (e.g. a missing template file or a
1109    /// syntax error). Carries the minijinja error message.
1110    Template(String),
1111    /// The ambient mailer failed to accept the message for delivery.
1112    /// Carries the `AuthMailError` display string.
1113    Mail(String),
1114    /// A challenge lookup or verification failed. Returned for ALL failure
1115    /// arms in the verification flows (no such user, no active challenge,
1116    /// attempt cap reached, wrong code) so a caller can't distinguish
1117    /// which arm fired — prevents account enumeration.
1118    InvalidChallenge,
1119    /// The argon2 concurrency gate shed this request: too much password
1120    /// hashing/verification is already in flight (audit_2 plugin-auth #4).
1121    /// Route handlers map this to HTTP 503 so clients back off rather than
1122    /// the process ballooning memory under a login/register flood.
1123    Overloaded,
1124}
1125
1126impl std::fmt::Display for AuthError {
1127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1128        match self {
1129            AuthError::PasswordHash(e) => write!(f, "umbral-auth: password hash: {e}"),
1130            AuthError::Sqlx(e) => write!(f, "umbral-auth: sqlx: {e}"),
1131            AuthError::Write(e) => write!(f, "umbral-auth: write: {e:?}"),
1132            AuthError::InvalidCredentials => write!(f, "umbral-auth: invalid credentials"),
1133            AuthError::WeakPassword(reasons) => {
1134                write!(f, "umbral-auth: password rejected: {}", reasons.join(" "))
1135            }
1136            AuthError::Runtime(msg) => write!(f, "umbral-auth: blocking task failed: {msg}"),
1137            AuthError::Session(msg) => write!(f, "umbral-auth: session: {msg}"),
1138            AuthError::Template(msg) => write!(f, "umbral-auth: template: {msg}"),
1139            AuthError::Mail(msg) => write!(f, "umbral-auth: mail: {msg}"),
1140            AuthError::InvalidChallenge => write!(f, "umbral-auth: invalid or expired challenge"),
1141            AuthError::Overloaded => {
1142                write!(
1143                    f,
1144                    "umbral-auth: password-hashing capacity exceeded (try again)"
1145                )
1146            }
1147        }
1148    }
1149}
1150
1151impl std::error::Error for AuthError {}
1152
1153impl From<argon2::password_hash::Error> for AuthError {
1154    fn from(e: argon2::password_hash::Error) -> Self {
1155        Self::PasswordHash(e)
1156    }
1157}
1158
1159impl From<sqlx::Error> for AuthError {
1160    fn from(e: sqlx::Error) -> Self {
1161        Self::Sqlx(e)
1162    }
1163}
1164
1165impl From<umbral::orm::write::WriteError> for AuthError {
1166    fn from(e: umbral::orm::write::WriteError) -> Self {
1167        Self::Write(e)
1168    }
1169}
1170
1171// =========================================================================
1172// Logout helper — single reusable logout for both built-in surfaces and
1173// any custom handler.
1174// =========================================================================
1175
1176/// Log the current request's user out: destroy the session row, emit a
1177/// clearing Set-Cookie on `resp`, and revoke the bearer token the request
1178/// presented (if any).
1179///
1180/// This is the single reusable logout — both built-in surfaces (the JSON
1181/// `/auth/logout` route, the HTML auth forms) and any custom handler call
1182/// this rather than reaching for `umbral_sessions::logout` directly.
1183///
1184/// Only the token in this request's `Authorization: Bearer` header is
1185/// revoked — logout means "end THIS credential", so the user's other
1186/// devices/tokens stay signed in. Revoking every token for the user is the
1187/// password-reset sweep's job, not logout's. The HTML form surface carries
1188/// no `Authorization` header, so this is a no-op there.
1189///
1190/// # Errors
1191///
1192/// Returns [`AuthError::Session`] if the underlying session destruction
1193/// fails (e.g. DB unreachable), or the token-revocation error when the
1194/// session half succeeded but the token delete failed. The clearing
1195/// Set-Cookie is still written to `resp` by `umbral_sessions::logout`
1196/// before the error is returned, so the client-side cookie is cleared even
1197/// on failure. Both halves always run — a failure in one never skips the
1198/// other.
1199pub async fn logout(
1200    req: &umbral::web::HeaderMap,
1201    resp: &mut umbral::web::HeaderMap,
1202) -> Result<(), AuthError> {
1203    let token_result = match parse_bearer_header(req) {
1204        Some(plaintext) => token::AuthToken::objects()
1205            .filter(token::auth_token::KEY_HASH.eq(digest_token(plaintext)))
1206            .delete()
1207            .await
1208            .map(|_| ()),
1209        None => Ok(()),
1210    };
1211    let session_result = umbral_sessions::logout(req, resp)
1212        .await
1213        .map_err(|e| AuthError::Session(e.to_string()));
1214    session_result.and(token_result.map_err(AuthError::from))
1215}
1216
1217// =========================================================================
1218// Password helpers - pure, no DB.
1219// =========================================================================
1220
1221/// Hash a plaintext password with argon2's framework-chosen
1222/// parameters. Returns the PHC-encoded string ready to store in
1223/// the password_hash column. The hash is self-describing so future
1224/// parameter upgrades stay transparent: a verified hash with old
1225/// parameters can be re-hashed on next login.
1226pub fn hash_password(plaintext: &str) -> Result<String, AuthError> {
1227    let salt = SaltString::generate(&mut OsRng);
1228    let hash = password_hasher()
1229        .hash_password(plaintext.as_bytes(), &salt)?
1230        .to_string();
1231    Ok(hash)
1232}
1233
1234/// Verify a plaintext password against an argon2 PHC-encoded hash.
1235/// Returns `Ok(true)` on match, `Ok(false)` on mismatch, and an error
1236/// only when the hash itself is malformed. Callers that just want a
1237/// bool can use `.unwrap_or(false)`.
1238pub fn verify_password(plaintext: &str, hash: &str) -> Result<bool, AuthError> {
1239    let parsed = PasswordHash::new(hash)?;
1240    match password_hasher().verify_password(plaintext.as_bytes(), &parsed) {
1241        Ok(()) => Ok(true),
1242        Err(argon2::password_hash::Error::Password) => Ok(false),
1243        Err(e) => Err(AuthError::PasswordHash(e)),
1244    }
1245}
1246
1247// ── Argon2 concurrency gate (audit_2 plugin-auth #4) ─────────────────────────
1248//
1249// Each argon2id hash/verify allocates ~19 MiB and pins a CPU for ~100 ms.
1250// `spawn_blocking` alone bounds nothing: tokio's blocking pool defaults to 512
1251// threads, so a login/register/reset flood (e.g. distinct usernames that slip
1252// past the per-IP throttle) can run hundreds of hashes at once — 512 × 19 MiB
1253// ≈ 10 GB — and OOM the process. The gate caps CONCURRENT argon2 work so peak
1254// memory is bounded to `cap × 19 MiB`.
1255//
1256// The permit is acquired BEFORE `spawn_blocking`, so a waiting request holds
1257// only its plaintext `String`, not the 19-MiB argon2 buffer — waiting is cheap
1258// and memory stays bounded no matter how deep the queue. To also bound LATENCY
1259// (and stop connections piling up without limit) a second cap on total
1260// in-flight work (`cap × HASH_QUEUE_MULT`, running + waiting) sheds load past
1261// that point with [`AuthError::Overloaded`] → HTTP 503, so clients back off
1262// instead of hanging.
1263
1264/// How many waiters-per-running-slot to admit before shedding load with 503.
1265/// `cap` running + `cap × (MULT-1)` waiting are admitted; the rest get 503.
1266const HASH_QUEUE_MULT: usize = 8;
1267
1268static HASH_CONCURRENCY: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1269static HASH_GATE: std::sync::OnceLock<tokio::sync::Semaphore> = std::sync::OnceLock::new();
1270static HASH_IN_FLIGHT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1271
1272/// The maximum number of argon2 operations that may run at once. Defaults to
1273/// the machine's parallelism (min 2) — running more concurrent hashes than
1274/// cores only thrashes and multiplies peak memory for no throughput. Override
1275/// with the `UMBRAL_AUTH_HASH_CONCURRENCY` env var (a positive integer);
1276/// [`AuthPlugin::hash_concurrency`] seals a programmatic value at boot.
1277fn hash_concurrency() -> usize {
1278    *HASH_CONCURRENCY.get_or_init(|| {
1279        std::env::var("UMBRAL_AUTH_HASH_CONCURRENCY")
1280            .ok()
1281            .and_then(|v| v.trim().parse::<usize>().ok())
1282            .filter(|&n| n > 0)
1283            .unwrap_or_else(|| {
1284                std::thread::available_parallelism()
1285                    .map(|n| n.get())
1286                    .unwrap_or(4)
1287                    .max(2)
1288            })
1289    })
1290}
1291
1292fn hash_gate() -> &'static tokio::sync::Semaphore {
1293    HASH_GATE.get_or_init(|| tokio::sync::Semaphore::new(hash_concurrency()))
1294}
1295
1296/// Run one CPU-bound argon2 closure on the blocking pool under the concurrency
1297/// gate. Sheds load with [`AuthError::Overloaded`] once total in-flight work
1298/// exceeds `cap × HASH_QUEUE_MULT`; otherwise waits for a permit (cheaply) and
1299/// runs `f` on `spawn_blocking`.
1300async fn with_hash_gate<F, T>(f: F) -> Result<T, AuthError>
1301where
1302    F: FnOnce() -> T + Send + 'static,
1303    T: Send + 'static,
1304{
1305    use std::sync::atomic::Ordering;
1306
1307    let max_in_flight = hash_concurrency().saturating_mul(HASH_QUEUE_MULT);
1308    // Reserve a slot; reject immediately if the bounded queue is full.
1309    let prev = HASH_IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
1310    if prev >= max_in_flight {
1311        HASH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
1312        return Err(AuthError::Overloaded);
1313    }
1314    // Ensure the counter is decremented on every exit path.
1315    struct Guard;
1316    impl Drop for Guard {
1317        fn drop(&mut self) {
1318            HASH_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
1319        }
1320    }
1321    let _guard = Guard;
1322
1323    // Wait for one of `cap` permits — cheap: only a String is held meanwhile.
1324    let _permit = hash_gate()
1325        .acquire()
1326        .await
1327        .map_err(|e| AuthError::Runtime(e.to_string()))?;
1328    tokio::task::spawn_blocking(f)
1329        .await
1330        .map_err(|e| AuthError::Runtime(e.to_string()))
1331}
1332
1333/// Async wrapper around [`hash_password`] that runs the CPU-bound argon2
1334/// work on tokio's blocking pool via `spawn_blocking`, under the concurrency
1335/// gate (see above). argon2id with the framework parameters takes ~100ms of
1336/// CPU; calling it directly from a request handler pins an async worker thread
1337/// for that whole time, so a login/registration burst starves the runtime and
1338/// HTTP/1.1 connections hang. Offloading keeps the async workers free to drive
1339/// other tasks. **Async request handlers must use this**; the sync
1340/// [`hash_password`] remains for non-async / CLI / test callers.
1341pub async fn hash_password_async(plaintext: &str) -> Result<String, AuthError> {
1342    let p = plaintext.to_owned();
1343    with_hash_gate(move || hash_password(&p)).await?
1344}
1345
1346/// Argon2 hash of a fresh, random, un-recoverable password.
1347///
1348/// For accounts created without a user-chosen password — social login, some
1349/// admin-provisioned users — so `password_hash` holds a **real, valid PHC
1350/// hash** instead of an empty string or a `"!"`-style sentinel. Nobody knows
1351/// the plaintext, so [`verify_password`] cleanly returns `false` for any login
1352/// attempt (rather than erroring on an unparseable marker), and the account can
1353/// still adopt a known password later through the email password-reset flow.
1354pub async fn random_password_hash() -> Result<String, AuthError> {
1355    use base64::Engine;
1356    use rand::RngCore;
1357    let mut buf = [0u8; 32];
1358    rand::rngs::OsRng.fill_bytes(&mut buf);
1359    let random = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf);
1360    hash_password_async(&random).await
1361}
1362
1363/// Async wrapper around [`verify_password`] that runs the CPU-bound argon2
1364/// verification on tokio's blocking pool via `spawn_blocking`, under the same
1365/// concurrency gate. See [`hash_password_async`] for the starvation rationale.
1366/// **Async request handlers must use this**; the sync [`verify_password`]
1367/// remains for non-async / CLI / test callers.
1368pub async fn verify_password_async(plaintext: &str, hash: &str) -> Result<bool, AuthError> {
1369    let p = plaintext.to_owned();
1370    let h = hash.to_owned();
1371    with_hash_gate(move || verify_password(&p, &h)).await?
1372}
1373
1374fn password_hasher() -> Argon2<'static> {
1375    Argon2::new(
1376        Algorithm::Argon2id,
1377        Version::V0x13,
1378        Params::new(19_456, 2, 1, None).expect("hard-coded argon2 params are valid"),
1379    )
1380}
1381
1382/// A fixed, valid Argon2id hash used purely to spend the same CPU on the
1383/// user-lookup-miss / inactive-user paths of [`authenticate`] as a real
1384/// verify would. Without this, a login for an existing active username costs
1385/// one ~30-50 ms Argon2 verify while a login for a non-existent (or inactive)
1386/// username returns right after the DB SELECT — a measurable timing side
1387/// channel that enumerates valid usernames. Computed once, lazily.
1388///
1389/// The plaintext hashed here is irrelevant; it is never compared against a
1390/// real password. What matters is that the string is a well-formed PHC hash
1391/// so `verify_password` runs the full Argon2 KDF against it.
1392fn dummy_password_hash() -> &'static str {
1393    static DUMMY: std::sync::OnceLock<String> = std::sync::OnceLock::new();
1394    DUMMY.get_or_init(|| {
1395        hash_password("umbral-timing-dummy-*").expect("hard-coded dummy hash is valid")
1396    })
1397}
1398
1399/// Spend one Argon2 verify against [`dummy_password_hash`] so a lookup-miss
1400/// path costs the same wall-clock time as a real credential check. The result
1401/// is intentionally discarded; only the CPU cost matters.
1402async fn burn_password_verify() {
1403    let _ = verify_password_async("umbral-timing-burn", dummy_password_hash()).await;
1404}
1405
1406// =========================================================================
1407// Identifier normalization.
1408//
1409// Usernames and emails are stored and matched case-insensitively: a user
1410// who registered `Dalmasonto` / `Dalmas@Gmail.com` must not be able to
1411// register a second account as `dalmasonto` / `dalmas@gmail.com`, and must
1412// be able to log in typing either case. We enforce this by normalizing to a
1413// canonical (trimmed + lowercased) form at BOTH the write boundary
1414// (`insert_user`) and every lookup boundary (`authenticate`, `verify_email`,
1415// `start_password_reset`, the resend-verification routes). Because every row
1416// is written lowercased, the existing `#[umbral(unique)]` constraint on
1417// `username` / `email` then enforces case-insensitive uniqueness for free —
1418// no case-insensitive index needed.
1419//
1420// Custom user models / signup forms that bypass these helpers should call
1421// `normalize_username` / `normalize_email` themselves at their own write and
1422// lookup sites to stay consistent.
1423// =========================================================================
1424
1425/// Canonicalize a username for storage and lookup: trim surrounding
1426/// whitespace, then lowercase. `"  Dalmasonto "` → `"dalmasonto"`.
1427///
1428/// Applied by [`create_user`] / [`create_user_with_flags`] /
1429/// [`create_superuser`] on write and by [`authenticate`] on lookup, so a
1430/// username is case-insensitively unique and case-insensitively matched.
1431pub fn normalize_username(raw: &str) -> String {
1432    raw.trim().to_lowercase()
1433}
1434
1435/// Canonicalize an email for storage and lookup: trim then lowercase.
1436/// Emails are treated case-insensitively (the pragmatic standard — no real
1437/// deployment relies on a case-sensitive local part), so `Dalmas@Gmail.com`
1438/// and `dalmas@gmail.com` are the same account.
1439pub fn normalize_email(raw: &str) -> String {
1440    raw.trim().to_lowercase()
1441}
1442
1443// =========================================================================
1444// AuthUser-specific creation helpers.
1445//
1446// These functions are intentionally tied to `AuthUser` because they
1447// construct the struct from a fixed set of columns. A custom user model
1448// that wants equivalent creation helpers should provide its own, using
1449// `hash_password` for the password column. See the docs for the
1450// recommended pattern.
1451// =========================================================================
1452
1453/// Create a new active user with the given username, email, and
1454/// plaintext password. The password is hashed before insert; the
1455/// plaintext never touches the database. `date_joined` is set to
1456/// `Utc::now()`; `last_login` is `None`; `is_active = true`,
1457/// `is_staff = false`, `is_superuser = false`.
1458pub async fn create_user(
1459    username: &str,
1460    email: &str,
1461    plaintext: &str,
1462) -> Result<AuthUser, AuthError> {
1463    create_user_with_flags(username, email, plaintext, false, false).await
1464}
1465
1466/// Create a superuser - `is_staff = true`, `is_superuser = true`,
1467/// `is_active = true`. Used by the `createsuperuser` management
1468/// command and available directly for tests / seed scripts.
1469pub async fn create_superuser(
1470    username: &str,
1471    email: &str,
1472    plaintext: &str,
1473) -> Result<AuthUser, AuthError> {
1474    // Low-level, like every other creation helper: it inserts a row and
1475    // does NOT run the password-strength policy. By design, the low-level
1476    // create_superuser doesn't validate; only the
1477    // registration boundary (the `register` route) and any custom signup
1478    // form do. A trusted operator path (the `createsuperuser` command, a
1479    // seed script, a test) chooses the password deliberately, so there's
1480    // nothing to gate here.
1481    insert_user(username, email, plaintext, true, true).await
1482}
1483
1484/// Insert a new user with arbitrary `is_staff` / `is_superuser`
1485/// flags. Used by `create_user` (flags = false, false) and
1486/// `create_superuser` (flags = true, true); exposed publicly so
1487/// custom seed paths can pick a specific shape (e.g. a staff-but-
1488/// not-superuser editor account).
1489pub async fn create_user_with_flags(
1490    username: &str,
1491    email: &str,
1492    plaintext: &str,
1493    is_staff: bool,
1494    is_superuser: bool,
1495) -> Result<AuthUser, AuthError> {
1496    insert_user(username, email, plaintext, is_staff, is_superuser).await
1497}
1498
1499/// The shared insert path behind [`create_user`], [`create_user_with_flags`]
1500/// and [`create_superuser`].
1501///
1502/// This is the **low-level** creation primitive: it hashes the plaintext and
1503/// writes the row, but it does NOT run the password-strength policy. That's
1504/// deliberate: by design the low-level `create_user` doesn't validate;
1505/// the registration boundary does (in umbral, the `register` route, which calls
1506/// [`validate_password`] itself before reaching here). Keeping validation out
1507/// of the insert path means seed scripts, bulk imports, and the workspace test
1508/// suite can create users with deliberately-chosen passwords without tripping
1509/// the policy. An untrusted signup surface must gate on `validate_password`
1510/// up front; the helper trusts its caller.
1511async fn insert_user(
1512    username: &str,
1513    email: &str,
1514    plaintext: &str,
1515    is_staff: bool,
1516    is_superuser: bool,
1517) -> Result<AuthUser, AuthError> {
1518    let now = chrono::Utc::now();
1519    let hash = hash_password_async(plaintext).await?;
1520    // Canonicalize before insert so the `#[umbral(unique)]` constraint enforces
1521    // case-insensitive uniqueness (every stored row is already lowercased).
1522    let username = normalize_username(username);
1523    let email = normalize_email(email);
1524    let row = AuthUser::objects()
1525        .create(AuthUser {
1526            id: 0,
1527            username,
1528            email,
1529            password_hash: hash,
1530            is_active: true,
1531            is_staff,
1532            is_superuser,
1533            date_joined: now,
1534            last_login: None,
1535            email_verified_at: None,
1536        })
1537        .await?;
1538    Ok(row)
1539}
1540
1541// =========================================================================
1542// Generic auth helpers - work against any UserModel.
1543// =========================================================================
1544
1545/// Verify a username + plaintext password against the user table for
1546/// user model `U`. Returns the user on success; returns
1547/// `AuthError::InvalidCredentials` for both "no such user" and "wrong
1548/// password" (the same shape, so a caller can't enumerate accounts).
1549///
1550/// The query uses `U::TABLE` for the table name. The WHERE clause
1551/// filters on `username = ?` and `is_active = 1` (the standard column
1552/// name for the active flag). Custom models that store the active flag
1553/// under a different column name should filter directly and call
1554/// `verify_password` themselves.
1555///
1556/// Does not update `last_login`; that is the login-flow's job once the
1557/// HTTP layer is wired end-to-end.
1558pub async fn authenticate<U>(username: &str, plaintext: &str) -> Result<U, AuthError>
1559where
1560    U: UserModel
1561        + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1562        + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1563        + umbral::orm::HydrateRelated
1564        + Unpin,
1565{
1566    // Match the canonical (trimmed + lowercased) form written at signup, so a
1567    // login typed as `Dalmasonto` finds the row stored as `dalmasonto`.
1568    let ident = normalize_username(username);
1569    // OR-combine every login column (`["username"]` by default; AuthUser adds
1570    // `email`) so a user can sign in with any of them, then AND the active-flag
1571    // guard. `login_columns()` is never empty.
1572    let mut ident_match: Option<umbral::orm::Predicate<U>> = None;
1573    for col in U::login_columns() {
1574        let p = umbral::orm::Predicate::<U>::col_eq(col, ident.as_str());
1575        ident_match = Some(match ident_match {
1576            Some(acc) => acc | p,
1577            None => p,
1578        });
1579    }
1580    let ident_match = ident_match.expect("UserModel::login_columns() must be non-empty");
1581    let user: Option<U> = umbral::orm::Manager::<U>::default()
1582        .filter(ident_match & umbral::orm::Predicate::<U>::col_eq("is_active", true))
1583        .first()
1584        .await?;
1585
1586    let Some(user) = user else {
1587        // Constant-work miss path: run one Argon2 verify against a dummy hash so
1588        // an unknown username costs the same wall-clock time as a real one. Skips
1589        // the username-enumeration timing oracle (audit plugin-auth #2).
1590        burn_password_verify().await;
1591        return Err(AuthError::InvalidCredentials);
1592    };
1593
1594    // Defence-in-depth: also check the trait method so custom types
1595    // that compute is_active dynamically (e.g. checking a TTL field)
1596    // are still respected even if the SQL filter passed.
1597    if !user.is_active() {
1598        // Same constant-work reasoning as the lookup-miss branch above: an
1599        // inactive account must not be distinguishable by response latency.
1600        burn_password_verify().await;
1601        return Err(AuthError::InvalidCredentials);
1602    }
1603
1604    if verify_password_async(plaintext, user.password_hash()).await? {
1605        Ok(user)
1606    } else {
1607        Err(AuthError::InvalidCredentials)
1608    }
1609}
1610
1611/// Replace a user's password with a fresh hash of the given plaintext.
1612/// Writes through to the database using `U::TABLE`. `user.password_hash`
1613/// is updated in place on success so the caller can keep using the same
1614/// value.
1615pub async fn set_password<U>(user: &mut U, plaintext: &str) -> Result<(), AuthError>
1616where
1617    U: UserModel,
1618{
1619    // Low-level, like `create_user`: this rotates the stored hash and does
1620    // NOT run the password-strength policy. Validation belongs at the
1621    // boundary — a password-change route or form should call
1622    // `validate_password` (with whatever user context it has) BEFORE invoking
1623    // `set_password`, exactly as the `register` route gates `create_user`.
1624    // Keeping the helper non-validating makes `set_password` a pure setter;
1625    // the form is what validates.
1626    let hash = hash_password_async(plaintext).await?;
1627    let mut patch = serde_json::Map::new();
1628    patch.insert(
1629        "password_hash".to_string(),
1630        serde_json::Value::String(hash.clone()),
1631    );
1632    umbral::orm::Manager::<U>::default()
1633        .filter(umbral::orm::Predicate::<U>::col_eq("id", user.id()))
1634        .update_values(patch)
1635        .await?;
1636    user.set_password_hash(hash);
1637    Ok(())
1638}
1639
1640// =========================================================================
1641// Porting: neutralize foreign password hashes
1642// =========================================================================
1643
1644/// Outcome of [`reset_unverifiable_passwords`].
1645#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1646pub struct PasswordAudit {
1647    /// Users scanned.
1648    pub total: usize,
1649    /// Users whose hash umbral-auth couldn't verify and so was reset.
1650    pub reset: usize,
1651}
1652
1653/// Replace every stored password hash that umbral-auth **cannot verify** with a
1654/// fresh [`random_password_hash`]. This is the fix for the classic port
1655/// caveat: a database migrated from Django (or any other stack) carries hashes
1656/// in a foreign format — `pbkdf2_sha256$…`, `argon2$…` (Django's own argon2
1657/// framing differs from the PHC string), a bcrypt `$2b$…` — none of which parse
1658/// as umbral's argon2 PHC hash. Left as-is, [`verify_password`] *errors* on
1659/// them (it can't even parse the hash), so those users can neither log in nor
1660/// get a clean rejection.
1661///
1662/// After neutralization each affected account holds a real, valid argon2 hash
1663/// of an unknown random password, so a login attempt cleanly returns `false`
1664/// and the account recovers through the **email password-reset flow**
1665/// ([`start_password_reset`]) — the documented re-hash path. A hash umbral
1666/// already accepts is left untouched, so this is idempotent and safe to re-run.
1667///
1668/// Generic over the user model `U`, so it targets whatever model the
1669/// [`AuthPlugin`] was configured with — the built-in [`AuthUser`] by default,
1670/// or a custom `AuthPlugin<MyUser>`. Reads the hash via [`UserModel`] and writes
1671/// through `U::TABLE`'s `password_hash` column (the same shape as
1672/// [`set_password`]). Loads users to check them — a one-shot post-port step,
1673/// not a hot path.
1674pub async fn reset_unverifiable_passwords<U>() -> Result<PasswordAudit, AuthError>
1675where
1676    U: UserModel
1677        + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1678        + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1679        + umbral::orm::HydrateRelated
1680        + Unpin,
1681{
1682    let users = umbral::orm::Manager::<U>::default().fetch().await?;
1683    let total = users.len();
1684    let mut reset = 0;
1685    for user in &users {
1686        // The authoritative test: can umbral's argon2 even PARSE this hash?
1687        if PasswordHash::new(user.password_hash()).is_ok() {
1688            continue;
1689        }
1690        let hash = random_password_hash().await?;
1691        let mut patch = serde_json::Map::new();
1692        patch.insert("password_hash".to_string(), serde_json::Value::String(hash));
1693        umbral::orm::Manager::<U>::default()
1694            .filter(umbral::orm::Predicate::<U>::col_eq("id", user.id()))
1695            .update_values(patch)
1696            .await?;
1697        reset += 1;
1698    }
1699    Ok(PasswordAudit { total, reset })
1700}
1701
1702// =========================================================================
1703// Management command: createsuperuser
1704// =========================================================================
1705
1706/// `createsuperuser` - interactive superuser creation,
1707/// dispatched via `cargo run -- createsuperuser` from any umbral
1708/// project that registers [`AuthPlugin`].
1709///
1710/// Prompts for username, email, and password (the password input
1711/// is read without terminal echo via `rpassword`). The new user
1712/// lands with `is_active = true`, `is_staff = true`, `is_superuser =
1713/// true` - the standard shape for the bootstrap admin account.
1714///
1715/// Flags:
1716///
1717/// - `--username <name>` - skip the username prompt.
1718/// - `--email <addr>` - skip the email prompt.
1719/// - `--noinput` - fail if any required value is missing instead of
1720///   prompting. Useful in CI / containers / declarative seed paths.
1721///   Reads password from `UMBRAL_SUPERUSER_PASSWORD` when set.
1722#[derive(Debug, Default)]
1723pub struct CreateSuperuserCommand;
1724
1725#[async_trait::async_trait]
1726impl umbral::cli::PluginCommand for CreateSuperuserCommand {
1727    fn command(&self) -> clap::Command {
1728        clap::Command::new("createsuperuser")
1729            .about("Create a superuser account (is_staff = is_superuser = true)")
1730            .after_help(
1731                "Example:\n  cargo run -- createsuperuser --username admin --email admin@example.com",
1732            )
1733            .arg(
1734                clap::Arg::new("username")
1735                    .long("username")
1736                    .help("Skip the interactive username prompt")
1737                    .value_name("NAME"),
1738            )
1739            .arg(
1740                clap::Arg::new("email")
1741                    .long("email")
1742                    .help("Skip the interactive email prompt")
1743                    .value_name("ADDR"),
1744            )
1745            .arg(
1746                clap::Arg::new("noinput")
1747                    .long("noinput")
1748                    .help(
1749                        "Fail rather than prompt for any missing value. \
1750                         Reads password from UMBRAL_SUPERUSER_PASSWORD env var.",
1751                    )
1752                    .action(clap::ArgAction::SetTrue),
1753            )
1754    }
1755
1756    async fn run(&self, matches: &clap::ArgMatches) -> Result<(), umbral::cli::CliError> {
1757        let noinput = matches.get_flag("noinput");
1758        let username = resolve_or_prompt(
1759            matches.get_one::<String>("username").cloned(),
1760            "Username",
1761            noinput,
1762            None,
1763            None,
1764        )?;
1765        let email = resolve_or_prompt(
1766            matches.get_one::<String>("email").cloned(),
1767            "Email",
1768            noinput,
1769            None,
1770            Some(validate_email_input),
1771        )?;
1772        let password = resolve_password(noinput)?;
1773
1774        let user = create_superuser(&username, &email, &password)
1775            .await
1776            .map_err(|e| -> umbral::cli::CliError { Box::new(e) })?;
1777        println!(
1778            "Created superuser `{}` (id = {}) - is_staff = true, is_superuser = true",
1779            user.username, user.id,
1780        );
1781        Ok(())
1782    }
1783}
1784
1785/// `resetforeignpasswords` — the post-port fixer for the password-hash caveat.
1786/// Neutralizes every stored hash umbral-auth can't verify (see
1787/// [`reset_unverifiable_passwords`]) and tells the operator those users must
1788/// reset. Dispatched via `cargo run -- resetforeignpasswords`.
1789pub struct ResetForeignPasswordsCommand<U = AuthUser>(std::marker::PhantomData<U>);
1790
1791impl<U> Default for ResetForeignPasswordsCommand<U> {
1792    fn default() -> Self {
1793        Self(std::marker::PhantomData)
1794    }
1795}
1796
1797#[async_trait::async_trait]
1798impl<U> umbral::cli::PluginCommand for ResetForeignPasswordsCommand<U>
1799where
1800    U: UserModel
1801        + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
1802        + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
1803        + umbral::orm::HydrateRelated
1804        + Unpin,
1805{
1806    fn command(&self) -> clap::Command {
1807        clap::Command::new("resetforeignpasswords")
1808            .about(
1809                "Neutralize user password hashes umbral-auth can't verify (e.g. after \
1810                 porting from Django); affected users must reset via password-forgot",
1811            )
1812            .after_help("Example:\n  cargo run -- resetforeignpasswords")
1813    }
1814
1815    async fn run(&self, _matches: &clap::ArgMatches) -> Result<(), umbral::cli::CliError> {
1816        let audit = reset_unverifiable_passwords::<U>()
1817            .await
1818            .map_err(|e| -> umbral::cli::CliError { Box::new(e) })?;
1819        if audit.reset == 0 {
1820            println!(
1821                "Checked {} user(s); every password hash is umbral-verifiable. Nothing to do.",
1822                audit.total,
1823            );
1824        } else {
1825            println!(
1826                "Neutralized {} of {} user password hash(es) umbral-auth could not verify.",
1827                audit.reset, audit.total,
1828            );
1829            println!(
1830                "Those users must set a new password via the password-forgot / reset flow \
1831                 before they can log in.",
1832            );
1833        }
1834        Ok(())
1835    }
1836}
1837
1838/// The email check `createsuperuser` applies — the ORM's single-source
1839/// `email` text-format validator, so the CLI, the register route, and the
1840/// dynamic write path all agree on what a valid address is (gaps4 #35).
1841fn validate_email_input(v: &str) -> Result<(), String> {
1842    umbral::orm::validate_text_format("email", v)
1843        .map_err(|_| format!("`{v}` is not a valid email address"))
1844}
1845
1846/// Get a value from the CLI flag, the env var, or the interactive
1847/// prompt. The `noinput` flag fails the CLI call rather than
1848/// prompting when no value is available.
1849///
1850/// `validate` gates every source: a flag/env value that fails it is a
1851/// hard error (scripts must not half-succeed), while the interactive
1852/// prompt prints the reason and asks again.
1853fn resolve_or_prompt(
1854    cli_value: Option<String>,
1855    label: &str,
1856    noinput: bool,
1857    env_var: Option<&str>,
1858    validate: Option<fn(&str) -> Result<(), String>>,
1859) -> Result<String, umbral::cli::CliError> {
1860    let check = |v: &str| -> Result<(), String> {
1861        match validate {
1862            Some(f) => f(v),
1863            None => Ok(()),
1864        }
1865    };
1866    if let Some(v) = cli_value
1867        && !v.is_empty()
1868    {
1869        check(&v).map_err(|reason| format!("umbral createsuperuser: {reason}"))?;
1870        return Ok(v);
1871    }
1872    if let Some(key) = env_var
1873        && let Ok(v) = std::env::var(key)
1874        && !v.is_empty()
1875    {
1876        check(&v).map_err(|reason| format!("umbral createsuperuser: {reason}"))?;
1877        return Ok(v);
1878    }
1879    if noinput {
1880        return Err(
1881            format!("umbral createsuperuser: {label} not provided and --noinput is set").into(),
1882        );
1883    }
1884    use std::io::Write;
1885    loop {
1886        print!("{label}: ");
1887        std::io::stdout().flush().ok();
1888        let mut s = String::new();
1889        std::io::stdin().read_line(&mut s)?;
1890        let v = s.trim().to_string();
1891        // Empty stays a hard error, not a re-prompt: it is how a piped
1892        // stdin reaching EOF terminates, so looping here would spin.
1893        if v.is_empty() {
1894            return Err(format!("umbral createsuperuser: {label} cannot be empty").into());
1895        }
1896        match check(&v) {
1897            Ok(()) => return Ok(v),
1898            Err(reason) => eprintln!("{reason} — try again"),
1899        }
1900    }
1901}
1902
1903/// Get the password - env var -> confirm-prompt with no-echo. Refuses
1904/// to proceed when the two confirmation entries don't match.
1905fn resolve_password(noinput: bool) -> Result<String, umbral::cli::CliError> {
1906    if let Ok(v) = std::env::var("UMBRAL_SUPERUSER_PASSWORD")
1907        && !v.is_empty()
1908    {
1909        return Ok(v);
1910    }
1911    if noinput {
1912        return Err(
1913            "umbral createsuperuser: password not provided (set UMBRAL_SUPERUSER_PASSWORD) \
1914             and --noinput is set"
1915                .into(),
1916        );
1917    }
1918    let first = rpassword::prompt_password("Password: ")?;
1919    if first.is_empty() {
1920        return Err("umbral createsuperuser: password cannot be empty".into());
1921    }
1922    let second = rpassword::prompt_password("Password (again): ")?;
1923    if first != second {
1924        return Err("umbral createsuperuser: passwords do not match".into());
1925    }
1926    Ok(first)
1927}
1928
1929#[cfg(test)]
1930mod timing_tests {
1931    use super::*;
1932
1933    /// The constant-work miss path (audit plugin-auth #2) is only real if the
1934    /// dummy hash is a well-formed Argon2id PHC string — otherwise
1935    /// `verify_password` errors out early instead of spending the KDF cost,
1936    /// re-opening the timing oracle. Assert the dummy is a valid hash and that a
1937    /// verify against it actually runs the KDF (returns Ok(false), not Err).
1938    #[test]
1939    fn dummy_hash_is_valid_argon2id_so_miss_path_spends_kdf() {
1940        let h = dummy_password_hash();
1941        assert!(
1942            h.starts_with("$argon2id$"),
1943            "dummy hash must be Argon2id PHC, got {h}"
1944        );
1945        // A real verify runs against it; a wrong password yields Ok(false),
1946        // which means the full KDF executed (an invalid hash would be Err).
1947        assert!(!verify_password("not-the-dummy", h).unwrap());
1948    }
1949}