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