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