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