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