rustio_admin/auth/recovery.rs
1//! Self-service password recovery (R1).
2//!
3//! See `DESIGN_RECOVERY.md` for the canonical contract this module
4//! implements. R1 ships in 0.5.0; this commit lands the schema, the
5//! [`PasswordPolicy`] surface, and the [`RecoveryPolicy`] surface.
6//! The issue + consume flow, the mailer wiring, the routes, and the
7//! templates land in subsequent atomic commits per
8//! `DESIGN_RECOVERY.md` §16.
9//!
10//! ## What lives here today
11//!
12//! - [`init_recovery_tables`] — creates `rustio_password_reset_tokens`
13//! with the partial unique index that makes the consume path's
14//! atomic `UPDATE … RETURNING` an index seek
15//! (`DESIGN_RECOVERY.md` §9.1).
16//! - [`migrate_user_recovery_schema`] — adds the additive
17//! `must_change_password` and `password_changed_at` columns on
18//! `rustio_users` (§9.2). R1's `set_password` populates
19//! `password_changed_at`; R2 enforces `must_change_password`.
20//! - [`PasswordPolicy`] / [`DefaultPasswordPolicy`] /
21//! [`PasswordPolicyError`] / [`SharedPasswordPolicy`] — the
22//! password-policy surface (§13).
23//! - [`RecoveryPolicy`] / [`DefaultRecoveryPolicy`] /
24//! [`SharedRecoveryPolicy`] — the recovery-flow tunables (§10.2,
25//! §12.3). Reset-token TTL, rate-limit shape, strict-mailer boot
26//! guard, and the public-site-URL derivation rule.
27//!
28//! `Admin::password_policy(...)` and `Admin::recovery_policy(...)`
29//! live in `admin::types`; the traits and default impls live here so
30//! the recovery module owns its vocabulary.
31//!
32//! The migration functions are idempotent and safe to call on every
33//! boot. `auth::init_tables` invokes them after the existing user /
34//! session migrations. The policy surface is data-only at this
35//! commit; no handler reads either policy yet.
36
37use std::sync::Arc;
38use std::time::Duration as StdDuration;
39
40use chrono::Duration as ChronoDuration;
41
42use crate::admin::audit::{record as audit_record, ActionType, AuditEvent, LogEntry};
43use crate::admin::redact::redact_token;
44use crate::admin::Admin;
45use crate::auth::sessions::{hash_token_for_storage, random_token};
46use crate::auth::users::{find_user_by_email, Identity};
47use crate::auth::{invalidate_sessions, set_password, SessionInvalidationReason, SessionTarget};
48use crate::email::Mail;
49use crate::error::Result;
50use crate::http::Request;
51use crate::middleware::RateLimiter;
52use crate::orm::Db;
53
54/// Create the `rustio_password_reset_tokens` table and its indexes.
55///
56/// Schema (see `DESIGN_RECOVERY.md` §9.1 for the contract):
57///
58/// - `token_hash` is `sha256(token)` URL-safe-base64 — the plaintext
59/// token never lands in this row.
60/// - `mail_status` is one of `'pending' | 'sent' | 'failed'`; the state
61/// evolves in the issue handler (one row per request).
62/// - `correlation_id` mirrors the request's audit `correlation_id` so
63/// an operator can pivot from token row → audit chain.
64/// - The partial unique index `WHERE consumed_at IS NULL` is the index
65/// the atomic consume statement seeks on.
66///
67/// Idempotent. Safe to call on every boot. Depends on `rustio_users`
68/// existing first.
69pub(crate) async fn init_recovery_tables(db: &Db) -> Result<()> {
70 sqlx::query(
71 "CREATE TABLE IF NOT EXISTS rustio_password_reset_tokens (
72 id BIGSERIAL PRIMARY KEY,
73 user_id BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
74 token_hash TEXT NOT NULL,
75 requested_ip TEXT,
76 requested_user_agent TEXT,
77 requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
78 expires_at TIMESTAMPTZ NOT NULL,
79 consumed_at TIMESTAMPTZ,
80 mail_status TEXT NOT NULL DEFAULT 'pending'
81 CHECK (mail_status IN ('pending', 'sent', 'failed')),
82 correlation_id TEXT
83 )",
84 )
85 .execute(db.pool())
86 .await?;
87
88 // Partial unique on the active-token lookup. Guarantees the
89 // consume statement (`UPDATE … WHERE token_hash = $1 AND
90 // consumed_at IS NULL RETURNING …`) is an index seek even after
91 // the table accumulates consumed/expired rows for forensic
92 // retention.
93 sqlx::query(
94 "CREATE UNIQUE INDEX IF NOT EXISTS rustio_password_reset_tokens_active_uq \
95 ON rustio_password_reset_tokens (token_hash) \
96 WHERE consumed_at IS NULL",
97 )
98 .execute(db.pool())
99 .await?;
100
101 sqlx::query(
102 "CREATE INDEX IF NOT EXISTS rustio_password_reset_tokens_user_idx \
103 ON rustio_password_reset_tokens (user_id)",
104 )
105 .execute(db.pool())
106 .await?;
107
108 sqlx::query(
109 "CREATE INDEX IF NOT EXISTS rustio_password_reset_tokens_expires_idx \
110 ON rustio_password_reset_tokens (expires_at) \
111 WHERE consumed_at IS NULL",
112 )
113 .execute(db.pool())
114 .await?;
115
116 Ok(())
117}
118
119/// Add the additive recovery columns on `rustio_users`.
120///
121/// - `must_change_password BOOLEAN NOT NULL DEFAULT FALSE` — R2 will
122/// read this on login to force a password reset on the next sign-in.
123/// R1 introduces the column because R2's commit set stays narrower
124/// when the column already exists.
125/// - `password_changed_at TIMESTAMPTZ` (nullable) — populated by
126/// `auth::set_password` from R1 onwards. NULL for users created
127/// before the upgrade; the active-sessions UI renders "(unknown)" or
128/// omits the row when NULL.
129///
130/// Idempotent. Safe to call on every boot. Depends on `rustio_users`
131/// existing first.
132pub(crate) async fn migrate_user_recovery_schema(db: &Db) -> Result<()> {
133 sqlx::query(
134 "ALTER TABLE rustio_users \
135 ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE",
136 )
137 .execute(db.pool())
138 .await?;
139
140 sqlx::query(
141 "ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ",
142 )
143 .execute(db.pool())
144 .await?;
145
146 Ok(())
147}
148
149// ---- Password policy -------------------------------------------------------
150
151/// Validates a candidate password against project-defined rules.
152///
153/// The framework ships [`DefaultPasswordPolicy`] (length-only floor)
154/// as the secure-by-default baseline. Projects layer a stronger
155/// policy via [`crate::admin::Admin::password_policy`] when
156/// regulation or risk requires it. The trait is `Send + Sync` so the
157/// `Arc<dyn PasswordPolicy>` lives on `Admin` and is cheap to clone
158/// into async futures.
159///
160/// ## Implementing a custom policy
161///
162/// ```ignore
163/// use rustio_admin::auth::{PasswordPolicy, PasswordPolicyError};
164///
165/// struct OrgPolicy;
166/// impl PasswordPolicy for OrgPolicy {
167/// fn validate(&self, candidate: &str) -> Result<(), PasswordPolicyError> {
168/// let len = candidate.chars().count();
169/// if len < 16 {
170/// return Err(PasswordPolicyError::TooShort { min: 16, actual: len });
171/// }
172/// if !candidate.chars().any(|c| c.is_ascii_digit()) {
173/// return Err(PasswordPolicyError::Custom(
174/// "Password must contain at least one digit.".into(),
175/// ));
176/// }
177/// Ok(())
178/// }
179/// fn min_length(&self) -> usize { 16 }
180/// }
181/// ```
182///
183/// Implementations MUST treat the borrowed candidate as a secret:
184/// no logging, no panic-with-the-plaintext, no inclusion in the
185/// returned error. The framework's audit + log helpers redact
186/// passwords (`audit::redact_password()`); custom policies that
187/// want to surface a project-specific message use
188/// [`PasswordPolicyError::Custom`] with a user-safe string.
189pub trait PasswordPolicy: Send + Sync {
190 /// Approve or reject the candidate.
191 fn validate(&self, candidate: &str) -> std::result::Result<(), PasswordPolicyError>;
192
193 /// The minimum length the policy enforces, in Unicode `char`s.
194 /// Templates display this on the new-password form so users see
195 /// the floor before submitting.
196 fn min_length(&self) -> usize;
197}
198
199/// Type-erased shared password-policy reference, mirroring
200/// [`crate::email::SharedMailer`]. The framework's `Admin` holds one
201/// of these; defaults to `Arc::new(DefaultPasswordPolicy::new())`
202/// until a project overrides via
203/// `Admin::password_policy(Arc::new(...))`.
204pub type SharedPasswordPolicy = Arc<dyn PasswordPolicy>;
205
206/// Reasons a candidate password fails policy validation.
207///
208/// Variants intentionally omit the candidate plaintext — none of the
209/// fields carry the rejected password, so a `Display` / `Debug`
210/// rendering of any error value is safe to log, audit, or pass to a
211/// form-field renderer. Project-supplied policies that emit
212/// [`PasswordPolicyError::Custom`] are responsible for keeping their
213/// message free of the plaintext as well.
214#[derive(Debug, Clone, PartialEq, Eq)]
215#[non_exhaustive]
216pub enum PasswordPolicyError {
217 /// Length floor not met. Both fields are character counts (not
218 /// bytes), matching `min_length()`.
219 TooShort { min: usize, actual: usize },
220 /// Project-defined rejection. The string renders to the user
221 /// verbatim and lands in logs verbatim — keep it free of secrets.
222 Custom(String),
223}
224
225impl std::fmt::Display for PasswordPolicyError {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 match self {
228 Self::TooShort { min, actual } => write!(
229 f,
230 "This password is too short. It must contain at least {min} characters \
231 (you entered {actual})."
232 ),
233 Self::Custom(msg) => f.write_str(msg),
234 }
235 }
236}
237
238impl std::error::Error for PasswordPolicyError {}
239
240/// Length-only password policy. Default `min_len` is **10** — the
241/// secure-by-default baseline R1 ships with: long enough to defeat
242/// trivial guessing under Argon2id + per-IP rate-limiting (NIST SP
243/// 800-63B's recommended length floor is 8, with longer being
244/// preferable), short enough not to drive operators toward sticky-
245/// note workarounds. Production / regulated deployments are
246/// encouraged to override to 12+ via
247/// [`crate::admin::Admin::password_policy`]; high-sensitivity
248/// deployments may want 16+ paired with an organisational
249/// complexity rule or breach blocklist.
250///
251/// The framework deliberately ships **no complexity-class rules**
252/// ("must contain a symbol", "must include uppercase") in the
253/// default — they demonstrably push humans toward predictable
254/// patterns without improving entropy meaningfully (NIST SP
255/// 800-63B Appendix A). Projects that need them implement a
256/// custom `PasswordPolicy`.
257#[derive(Debug, Clone, Copy)]
258pub struct DefaultPasswordPolicy {
259 pub min_len: usize,
260}
261
262impl DefaultPasswordPolicy {
263 /// New policy with the framework's default floor (`min_len = 10`).
264 pub const fn new() -> Self {
265 Self { min_len: 10 }
266 }
267
268 /// New policy with an explicit floor. Useful for projects that
269 /// want a stronger length baseline without authoring a full
270 /// `PasswordPolicy` impl.
271 pub const fn with_min_len(min_len: usize) -> Self {
272 Self { min_len }
273 }
274}
275
276impl Default for DefaultPasswordPolicy {
277 fn default() -> Self {
278 Self::new()
279 }
280}
281
282impl PasswordPolicy for DefaultPasswordPolicy {
283 fn validate(&self, candidate: &str) -> std::result::Result<(), PasswordPolicyError> {
284 // Count Unicode `char`s, not bytes — a 10-char password is
285 // 10 user-visible characters regardless of UTF-8 byte width.
286 // Grapheme-cluster counting is left to project policies that
287 // need it.
288 let actual = candidate.chars().count();
289 if actual < self.min_len {
290 return Err(PasswordPolicyError::TooShort {
291 min: self.min_len,
292 actual,
293 });
294 }
295 Ok(())
296 }
297
298 fn min_length(&self) -> usize {
299 self.min_len
300 }
301}
302
303// ---- Login throttle (R2) ---------------------------------------------------
304
305/// Auto-throttle parameters for the login flow
306/// (`DESIGN_R2_ORGANISATIONAL.md` §3.3 + §12 locked decisions).
307///
308/// All three knobs are exposed via [`RecoveryPolicy::login_throttle`]
309/// so projects override the threshold without authoring a full trait
310/// impl. The locked default matches `DESIGN_R2_ORGANISATIONAL.md` §12:
311/// 5 failed attempts within a 10-minute sliding window trigger a
312/// 15-minute soft lock. Soft locks do NOT revoke sessions
313/// (Doctrine 22 + §13 locked-decision: only manual lock revokes).
314///
315/// Field semantics:
316///
317/// - `max_attempts` — failure count that trips the soft lock when
318/// reached within `window_minutes`. The counter is anchored on
319/// `rustio_users.last_failed_login_at` (R2 commit #1 schema) and
320/// logically resets when the window elapses.
321/// - `window_minutes` — sliding window over which `max_attempts` is
322/// measured. Failures older than this are ignored when evaluating
323/// the threshold.
324/// - `lock_minutes` — duration of the soft lock written to
325/// `rustio_users.locked_until` when the threshold trips.
326///
327/// Setting `max_attempts = 0` is valid and disables the auto-throttle
328/// entirely (no failure ever trips a soft lock). Manual lock via
329/// `/admin/users/:id/lock` (R2 commit #16) is independent of this
330/// struct.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub struct LoginThrottle {
333 /// Failure threshold within `window_minutes` that trips a soft
334 /// lock. Default `5`.
335 pub max_attempts: u32,
336 /// Sliding-window length, in minutes. Default `10`.
337 pub window_minutes: i64,
338 /// Soft-lock duration, in minutes. Default `15`.
339 pub lock_minutes: i64,
340}
341
342impl LoginThrottle {
343 /// The framework's locked default
344 /// (`DESIGN_R2_ORGANISATIONAL.md` §12): **5 failures /
345 /// 10-minute window / 15-minute soft lock**. `const`-constructible
346 /// so projects use it in `static` recovery-policy builders.
347 pub const DEFAULT: Self = Self {
348 max_attempts: 5,
349 window_minutes: 10,
350 lock_minutes: 15,
351 };
352}
353
354impl Default for LoginThrottle {
355 fn default() -> Self {
356 Self::DEFAULT
357 }
358}
359
360// ---- Recovery policy -------------------------------------------------------
361
362/// Tunables for the R1 recovery flow: token TTL, rate-limit shape,
363/// strict-mailer boot guard, and public-site-URL derivation.
364///
365/// `Admin::new()` seeds [`DefaultRecoveryPolicy`]; projects override
366/// via [`crate::admin::Admin::recovery_policy`]. The trait is `Send +
367/// Sync` so the `Arc<dyn RecoveryPolicy>` lives on `Admin` and is
368/// cheap to clone into async futures.
369///
370/// The trait method `public_site_url` has a provided default that
371/// derives the URL from request headers via [`derive_public_site_url`]
372/// per `DESIGN_RECOVERY.md` §12.3. Projects whose deployment can't
373/// rely on the standard Forwarded / X-Forwarded-* / Host headers
374/// override this method and return their own absolute URL (e.g.
375/// stamped at deployment time from a config secret).
376///
377/// ## Trust boundary for forwarded headers
378///
379/// The default `public_site_url` honours these client-supplied
380/// inputs in priority order:
381///
382/// 1. RFC 7239 `Forwarded` header (`for / proto / host` of the first
383/// hop)
384/// 2. `X-Forwarded-Proto` + `X-Forwarded-Host` (first CSV entry of
385/// each)
386/// 3. `Host` header (assumes `http://`)
387///
388/// **The operator's reverse proxy MUST strip incoming versions of
389/// these headers before adding its own.** The framework cannot know
390/// the deployment topology; if a hostile client can reach the
391/// process directly with a chosen `Forwarded: …` header set, the
392/// reset link in the dispatched email will point wherever they ask.
393/// `proto` is whitelisted to `{http, https}` (case-insensitive) and
394/// `host` is rejected when it contains whitespace, control bytes, or
395/// CRLF — so direct injection of `\r\n`-style header smuggling
396/// fails — but a malicious yet shape-conformant value still needs
397/// to be filtered upstream.
398///
399/// Projects that need a stricter trust posture: override
400/// `public_site_url` to return a fixed string (e.g. read from
401/// project config at startup) and the framework will use that
402/// regardless of headers.
403pub trait RecoveryPolicy: Send + Sync {
404 /// How long a freshly-issued reset token stays valid. Default
405 /// 1 hour. Locked-decision per `DESIGN_RECOVERY.md` §17.
406 fn reset_token_ttl(&self) -> ChronoDuration;
407
408 /// Per-IP rate-limit on `POST /admin/forgot-password`. Returned
409 /// as `(capacity, window)`: at most `capacity` requests within
410 /// `window`. Default `(5, 15min)`.
411 fn request_rate_limit(&self) -> (u32, StdDuration);
412
413 /// Per-IP rate-limit on `POST /admin/reset-password/<token>`.
414 /// Tighter than the request limit since the consume path is the
415 /// brute-force surface. Default `(10, 5min)`.
416 fn consume_rate_limit(&self) -> (u32, StdDuration);
417
418 /// When `true`, the framework refuses to start at boot if the
419 /// registered mailer is still the default [`crate::email::LogMailer`]
420 /// (production deployments must opt in to a real mailer).
421 /// Default `false`. Enforcement lands when the recovery handlers
422 /// ship (R1 commit #7+); this commit ships the declaration only.
423 fn strict_mailer_required(&self) -> bool;
424
425 /// Derive the absolute base URL the reset email's link should
426 /// point at. Default: see [`derive_public_site_url`] +
427 /// trust-boundary docs on this trait. Projects override this
428 /// method to return a fixed string (e.g. read from config) when
429 /// header derivation isn't appropriate for their topology.
430 ///
431 /// Returns `None` when nothing resolves; the caller (R1 issue
432 /// handler, commit #7) treats `None` as a hard failure and
433 /// records `metadata.email_send_status = "failed"` with a clear
434 /// log line.
435 fn public_site_url(&self, req: &Request) -> Option<String> {
436 derive_public_site_url(|name| req.header(name).map(|s| s.to_string()))
437 }
438
439 // ---- R2 organisational-recovery extensions -----------------------------
440 //
441 // All three methods below have provided defaults so existing R1
442 // impls keep compiling. See `DESIGN_R2_ORGANISATIONAL.md` §6.3
443 // and §8.1 for the contract.
444
445 /// Auto-throttle parameters for the login flow. Default
446 /// [`LoginThrottle::DEFAULT`] (5 / 10min / 15min).
447 /// Projects override to relax for development environments
448 /// (`max_attempts: 100`) or tighten for high-sensitivity
449 /// deployments (`max_attempts: 3, lock_minutes: 60`).
450 ///
451 /// Setting `max_attempts = 0` disables the auto-throttle
452 /// entirely; manual lock via `/admin/users/:id/lock` (R2
453 /// commit #16) remains available.
454 fn login_throttle(&self) -> LoginThrottle {
455 LoginThrottle::default()
456 }
457
458 /// Window during which a session that has cleared the re-auth
459 /// wall (`/admin/reauth`) is considered *elevated* and may
460 /// access destructive admin-recovery surfaces (admin-driven
461 /// password reset, lock, unlock, revoke-sessions). Default
462 /// 15 minutes (`DESIGN_R2_ORGANISATIONAL.md` §12 locked-decision).
463 ///
464 /// Re-auth state lives on the session row's `elevated_until`
465 /// column (R0 schema, runtime lands in R2 commit #10). Returning
466 /// a duration of zero or negative is a no-op promotion: every
467 /// admin-recovery action will require a fresh re-auth.
468 fn reauth_window(&self) -> ChronoDuration {
469 ChronoDuration::minutes(15)
470 }
471
472 /// Multi-tenant readiness hook. Returns `Some(scoped_policy)` to
473 /// scope rate-limits / TTLs / lockout windows per tenant when an
474 /// authenticated identity is in scope; returns `None` to mean
475 /// "no scoping, the caller continues to use the
476 /// `Admin`-bound recovery policy unchanged".
477 ///
478 /// Default returns `None` — single-tenant deployments see no
479 /// change. Multi-tenant projects override to look up the
480 /// tenant from `identity.user_id` (or a project-specific
481 /// claim) and return a fresh `Arc<dyn RecoveryPolicy>`
482 /// with that tenant's tunables. Per
483 /// `DESIGN_R2_ORGANISATIONAL.md` §6.3 the framework call site
484 /// is:
485 ///
486 /// ```ignore
487 /// let policy = admin
488 /// .recovery_policy
489 /// .scope_for(&identity)
490 /// .unwrap_or_else(|| Arc::clone(&admin.recovery_policy));
491 /// ```
492 ///
493 /// Why `Option<SharedRecoveryPolicy>` and not
494 /// `SharedRecoveryPolicy` (as the design doc's first sketch
495 /// suggested): returning a fresh `Arc<Self>` from `&self`
496 /// requires the trait method to either receive the policy's own
497 /// `Arc` as a parameter (awkward at every call site) or rely on
498 /// `dyn-clone` (extra dependency). `Option::None` expresses
499 /// "no override" without either. Multi-tenant impls return
500 /// `Some(Arc::new(per_tenant_policy))`, which is cheap and
501 /// idiomatic.
502 fn scope_for(&self, _identity: &Identity) -> Option<SharedRecoveryPolicy> {
503 None
504 }
505}
506
507/// Type-erased shared recovery-policy reference, mirroring
508/// [`SharedPasswordPolicy`] / [`crate::email::SharedMailer`].
509pub type SharedRecoveryPolicy = Arc<dyn RecoveryPolicy>;
510
511/// Length-only / rate-limit-only baseline policy. Public fields plus
512/// chainable `with_*` setters so projects that want to tweak one knob
513/// don't need to author a full trait impl.
514#[derive(Debug, Clone)]
515pub struct DefaultRecoveryPolicy {
516 pub reset_token_ttl: ChronoDuration,
517 pub request_rate_limit: (u32, StdDuration),
518 pub consume_rate_limit: (u32, StdDuration),
519 pub strict_mailer_required: bool,
520}
521
522impl DefaultRecoveryPolicy {
523 /// New policy with the framework's locked defaults
524 /// (`DESIGN_RECOVERY.md` §17): TTL 1h, request 5/15min, consume
525 /// 10/5min, strict-mailer guard off.
526 pub fn new() -> Self {
527 Self {
528 reset_token_ttl: ChronoDuration::hours(1),
529 request_rate_limit: (5, StdDuration::from_secs(15 * 60)),
530 consume_rate_limit: (10, StdDuration::from_secs(5 * 60)),
531 strict_mailer_required: false,
532 }
533 }
534
535 /// Override the reset-token TTL. Projects that want shorter
536 /// blast-radius windows pass `Duration::minutes(30)`; projects
537 /// that need user-friendlier deadlines pass `Duration::hours(2)`.
538 pub fn with_reset_token_ttl(mut self, ttl: ChronoDuration) -> Self {
539 self.reset_token_ttl = ttl;
540 self
541 }
542
543 /// Override the request-endpoint rate-limit shape.
544 pub fn with_request_rate_limit(mut self, capacity: u32, window: StdDuration) -> Self {
545 self.request_rate_limit = (capacity, window);
546 self
547 }
548
549 /// Override the consume-endpoint rate-limit shape.
550 pub fn with_consume_rate_limit(mut self, capacity: u32, window: StdDuration) -> Self {
551 self.consume_rate_limit = (capacity, window);
552 self
553 }
554
555 /// Toggle the strict-mailer boot guard. When `true`, R1's boot
556 /// sequence (commits #7+) refuses to start with the default
557 /// `LogMailer`. Default `false`.
558 pub fn with_strict_mailer_required(mut self, required: bool) -> Self {
559 self.strict_mailer_required = required;
560 self
561 }
562}
563
564impl Default for DefaultRecoveryPolicy {
565 fn default() -> Self {
566 Self::new()
567 }
568}
569
570impl RecoveryPolicy for DefaultRecoveryPolicy {
571 fn reset_token_ttl(&self) -> ChronoDuration {
572 self.reset_token_ttl
573 }
574
575 fn request_rate_limit(&self) -> (u32, StdDuration) {
576 self.request_rate_limit
577 }
578
579 fn consume_rate_limit(&self) -> (u32, StdDuration) {
580 self.consume_rate_limit
581 }
582
583 fn strict_mailer_required(&self) -> bool {
584 self.strict_mailer_required
585 }
586
587 // public_site_url uses the trait's provided default.
588}
589
590/// Pure helper for the default `RecoveryPolicy::public_site_url`
591/// implementation, factored out so the parser can be unit-tested
592/// without constructing a full [`Request`].
593///
594/// `header` is a closure that returns the named header's value (case-
595/// insensitive name match, owned `String` because the default
596/// closure copies out of the request's borrowed buffer).
597///
598/// Priority order — first source that resolves to a safe
599/// `(proto, host)` pair wins:
600///
601/// 1. RFC 7239 `Forwarded` — first comma-separated entry's
602/// `proto=` + `host=` pairs.
603/// 2. `X-Forwarded-Proto` + `X-Forwarded-Host` — first CSV entry of
604/// each, both required to fall through if either's missing.
605/// 3. `Host` header alone — assumes `http://` (no HTTPS guesswork).
606///
607/// Returns `None` when nothing resolves. Never panics on malformed
608/// input — see the test suite's `malformed_forwarded_inputs_never_panic`
609/// for the property check.
610///
611/// **Trust:** see the `RecoveryPolicy` trait's "Trust boundary"
612/// section. The operator's reverse proxy is responsible for
613/// stripping incoming versions of these headers before its own
614/// hop appends them.
615pub(crate) fn derive_public_site_url<F>(header: F) -> Option<String>
616where
617 F: Fn(&str) -> Option<String>,
618{
619 // 1. RFC 7239 Forwarded — first hop
620 if let Some(value) = header("forwarded") {
621 if let Some(url) = parse_forwarded_first_hop(&value) {
622 return Some(url);
623 }
624 }
625
626 // 2. X-Forwarded-Proto + X-Forwarded-Host
627 let xfp = header("x-forwarded-proto").and_then(|s| first_csv(&s).map(|v| v.to_string()));
628 let xfh = header("x-forwarded-host").and_then(|s| first_csv(&s).map(|v| v.to_string()));
629 if let (Some(proto), Some(host)) = (xfp, xfh) {
630 if is_safe_proto(&proto) && is_safe_host(&host) {
631 return Some(format!("{}://{}", proto.to_ascii_lowercase(), host));
632 }
633 }
634
635 // 3. Host header — assume http
636 if let Some(host) = header("host") {
637 if is_safe_host(&host) {
638 return Some(format!("http://{host}"));
639 }
640 }
641
642 None
643}
644
645/// Take the first comma-separated, trimmed, non-empty token of `s`.
646fn first_csv(s: &str) -> Option<&str> {
647 let trimmed = s.split(',').next()?.trim();
648 if trimmed.is_empty() {
649 None
650 } else {
651 Some(trimmed)
652 }
653}
654
655/// Whitelist: only `http` and `https` are accepted. Case-insensitive.
656fn is_safe_proto(p: &str) -> bool {
657 p.eq_ignore_ascii_case("http") || p.eq_ignore_ascii_case("https")
658}
659
660/// Reject empty / over-long / control-char / whitespace hosts. Allows
661/// alphanumerics, the dot/dash/underscore separators, the colon for
662/// the `host:port` shape, and `[` / `]` for IPv6 literals.
663fn is_safe_host(h: &str) -> bool {
664 if h.is_empty() || h.len() > 253 {
665 return false;
666 }
667 h.chars()
668 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | ':' | '-' | '_' | '[' | ']'))
669}
670
671/// Parse `proto=` and `host=` from the FIRST comma-separated entry
672/// of an RFC 7239 `Forwarded` header value. Returns the canonical
673/// `proto://host` URL, or `None` if either is missing or fails the
674/// safety check.
675fn parse_forwarded_first_hop(value: &str) -> Option<String> {
676 let first = value.split(',').next()?;
677 let mut proto: Option<&str> = None;
678 let mut host: Option<&str> = None;
679
680 for pair in first.split(';') {
681 let pair = pair.trim();
682 if pair.is_empty() {
683 continue;
684 }
685 let (key, val) = match pair.split_once('=') {
686 Some(p) => p,
687 None => continue,
688 };
689 let key = key.trim();
690 // Strip surrounding quotes if present (RFC 7239 allows
691 // quoted-string syntax for values containing special chars).
692 let val = val.trim().trim_matches('"');
693 if val.is_empty() {
694 continue;
695 }
696 if key.eq_ignore_ascii_case("proto") {
697 proto = Some(val);
698 } else if key.eq_ignore_ascii_case("host") {
699 host = Some(val);
700 }
701 }
702
703 let proto = proto?;
704 let host = host?;
705 if !is_safe_proto(proto) || !is_safe_host(host) {
706 return None;
707 }
708 Some(format!("{}://{}", proto.to_ascii_lowercase(), host))
709}
710
711// ---- Runtime: token issuance + consumption -------------------------------
712
713/// Outcome of [`issue_reset_token`]. Variants exist for
714/// observability and testability — the user-facing handler renders
715/// the same uniform "if that email has an account, we just sent a
716/// link" page across every variant per the disclosure rule
717/// (`DESIGN_RECOVERY.md` §2.3).
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub(crate) enum IssueOutcome {
720 /// A token row was inserted; the mailer dispatch attempt
721 /// finished (see `email_status` for whether the message
722 /// actually went out). One audit row written
723 /// (`AuditEvent::PasswordResetSelfRequest`).
724 Issued {
725 token_id: i64,
726 email_status: MailerEmailStatus,
727 },
728 /// Email didn't match an active user — either unknown OR
729 /// deactivated. The two sub-cases are deliberately
730 /// indistinguishable from outside (doctrine 9, §2.3 disclosure
731 /// rule). No DB row, no audit, no mail. A `log::info!` line is
732 /// written for operator-side visibility, but it never carries
733 /// a token, password, or anything that could be used for
734 /// enumeration analysis later.
735 UnknownOrInactive,
736 /// Per-IP rate-limit on the request endpoint exhausted. No DB
737 /// row. Renderer treats this identically to `Issued` /
738 /// `UnknownOrInactive` (uniform-response invariant).
739 RateLimited,
740}
741
742/// Whether the mailer's `send` call returned `Ok` or a typed
743/// `MailerError`. Persisted on the token row's `mail_status` column
744/// and into the audit row's `metadata.email_send_status`.
745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub enum MailerEmailStatus {
747 Sent,
748 Failed,
749}
750
751/// Outcome of [`consume_reset_token`]. The user-facing handler
752/// renders `Invalid` and `RateLimited` identically (the "this link
753/// is no longer valid" page) per disclosure rule §2.3 — the variant
754/// distinction exists for observability + tests, not for branching
755/// the UI.
756#[derive(Debug, Clone, PartialEq, Eq)]
757pub(crate) enum ConsumeOutcome {
758 /// Token consumed atomically; password updated; every session
759 /// for the affected user revoked through
760 /// `invalidate_sessions(SessionTarget::User { user_id },
761 /// SessionInvalidationReason::PasswordReset)`. One audit row
762 /// written (`AuditEvent::PasswordResetSelfConsume`).
763 Consumed {
764 user_id: i64,
765 revoked_session_count: usize,
766 },
767 /// Token unknown / expired / already consumed (the three are
768 /// deliberately indistinguishable per §2.3). No password
769 /// change, no session revocation, no audit row written. A
770 /// `log::info!` line carries the token's redacted fingerprint
771 /// for cross-row pivoting if the operator needs to investigate.
772 Invalid,
773 /// `PasswordPolicy::validate` rejected the candidate password.
774 /// No DB mutation: the token stays valid for retry; the form
775 /// re-renders with the policy error. The error itself is safe
776 /// to render — `PasswordPolicyError` variants do not carry the
777 /// candidate plaintext (see commit #4's leak-prevention test).
778 PolicyRejected(PasswordPolicyError),
779 /// Per-IP rate-limit on the consume endpoint exhausted. No DB
780 /// mutation. Renderer treats this identically to `Invalid`.
781 RateLimited,
782}
783
784/// Issue a password-reset token for `email` — or pretend to,
785/// preserving the uniform-response invariant.
786///
787/// See `DESIGN_RECOVERY.md` §4.2 for the canonical contract this
788/// implements. The function is `pub(crate)` because the framework
789/// owns the route shape (CSRF, rate-limit middleware, render
790/// pipeline). External projects compose recovery via the trait
791/// surfaces ([`PasswordPolicy`], [`RecoveryPolicy`],
792/// [`crate::email::Mailer`]) rather than calling this directly.
793///
794/// ## Security properties (LOCKED)
795///
796/// - The plaintext token leaves this function only as part of the
797/// email body dispatched through [`crate::email::Mailer`]. The DB
798/// row stores `token_hash = sha256(token)` only.
799/// - Outward result is uniform: `IssueOutcome::Issued`,
800/// `UnknownOrInactive`, and `RateLimited` all map to the same
801/// user-facing page in the handler (commit #8). The variant
802/// distinction is for audit + tests only.
803/// - No `log::info!` / `log::error!` / audit row contains the
804/// plaintext token. Logs use [`redact_token`] (8-char SHA-256
805/// fingerprint); audit metadata stores `token_fingerprint`.
806/// - On mailer failure (transient OR permanent OR `public_site_url`
807/// derivation returning None), the outward result is still
808/// `IssueOutcome::Issued { email_status: Failed }` — the row
809/// exists with `mail_status = 'failed'` and the audit row carries
810/// `email_send_status = "failed"`. The user sees the uniform
811/// response.
812pub(crate) async fn issue_reset_token(
813 db: &Db,
814 admin: &Admin,
815 request_limiter: &RateLimiter,
816 request: &Request,
817 email: &str,
818 correlation_id: Option<&str>,
819) -> Result<IssueOutcome> {
820 let ip = extract_request_ip(request);
821
822 // 1. Per-IP rate-limit — bucket exhaustion → uniform response.
823 if !request_limiter.allow(&ip) {
824 log::info!(
825 target: "rustio_admin::recovery::issue",
826 "rate-limit exhausted ip={} correlation_id={:?}",
827 ip,
828 correlation_id,
829 );
830 return Ok(IssueOutcome::RateLimited);
831 }
832
833 // 2. Normalise email input.
834 let email_input = email.trim().to_ascii_lowercase();
835 if email_input.is_empty() {
836 log::info!(
837 target: "rustio_admin::recovery::issue",
838 "empty-email submission ip={} correlation_id={:?}",
839 ip,
840 correlation_id,
841 );
842 return Ok(IssueOutcome::UnknownOrInactive);
843 }
844
845 // 3. User lookup. Both unknown-email and inactive-user collapse
846 // into UnknownOrInactive — leaking either creates an
847 // enumeration channel.
848 let user = match find_user_by_email(db, &email_input).await? {
849 Some(u) if u.is_active => u,
850 Some(u) => {
851 log::info!(
852 target: "rustio_admin::recovery::issue",
853 "inactive-user submission user_id={} ip={} correlation_id={:?}",
854 u.id,
855 ip,
856 correlation_id,
857 );
858 return Ok(IssueOutcome::UnknownOrInactive);
859 }
860 None => {
861 log::info!(
862 target: "rustio_admin::recovery::issue",
863 "unknown-email submission ip={} correlation_id={:?}",
864 ip,
865 correlation_id,
866 );
867 return Ok(IssueOutcome::UnknownOrInactive);
868 }
869 };
870
871 // 4. Generate token. 256-bit URL-safe-base64. Plaintext lives
872 // only here, in the email body, and in the user's mailbox —
873 // NEVER in the DB, NEVER in any log line.
874 let token = random_token();
875 let token_hash = hash_token_for_storage(&token);
876
877 // 5. Insert the token row with mail_status = 'pending'.
878 let policy = admin.active_recovery_policy();
879 let ttl = policy.reset_token_ttl();
880 let expires_at = chrono::Utc::now() + ttl;
881 let user_agent_owned = request.header("user-agent").map(|s| s.to_string());
882
883 let token_id: i64 = sqlx::query_scalar(
884 "INSERT INTO rustio_password_reset_tokens
885 (user_id, token_hash, requested_ip, requested_user_agent,
886 expires_at, mail_status, correlation_id)
887 VALUES ($1, $2, $3, $4, $5, 'pending', $6)
888 RETURNING id",
889 )
890 .bind(user.id)
891 .bind(&token_hash)
892 .bind(&ip)
893 .bind(user_agent_owned.as_deref())
894 .bind(expires_at)
895 .bind(correlation_id)
896 .fetch_one(db.pool())
897 .await?;
898
899 // 6. Compose + dispatch mail. If site-URL derivation fails or
900 // the mailer returns an error, mark mail_status = 'failed'
901 // and continue — the user-facing response stays uniform.
902 let mail_status = match policy.public_site_url(request) {
903 Some(public_site_url) => {
904 let reset_link = format!(
905 "{}/admin/reset-password/{}",
906 public_site_url.trim_end_matches('/'),
907 token,
908 );
909 let when = chrono::Utc::now();
910 let body = format!(
911 "We received a request to sign you back in to {site_header}.\n\n\
912 Click the link below to set a new password:\n\n\
913 {reset_link}\n\n\
914 The link expires {ttl_human}. If you didn't request this, you can \
915 safely ignore this email.\n",
916 site_header = admin.branding().site_header,
917 reset_link = reset_link,
918 ttl_human = humanize_ttl(ttl),
919 );
920 let mail = Mail::framework_envelope(
921 user.email.clone(),
922 format!("{} — sign-in link", admin.branding().site_header),
923 body,
924 &admin.branding().site_header,
925 Some(&ip),
926 user_agent_owned.as_deref(),
927 when,
928 );
929 match admin.active_mailer().send(mail).await {
930 Ok(()) => {
931 set_token_mail_status(db, token_id, "sent").await?;
932 MailerEmailStatus::Sent
933 }
934 Err(e) => {
935 log::error!(
936 target: "rustio_admin::recovery::issue",
937 "mailer send failed user_id={} fingerprint={} correlation_id={:?}: {}",
938 user.id,
939 redact_token(&token),
940 correlation_id,
941 e,
942 );
943 set_token_mail_status(db, token_id, "failed").await?;
944 MailerEmailStatus::Failed
945 }
946 }
947 }
948 None => {
949 log::error!(
950 target: "rustio_admin::recovery::issue",
951 "public_site_url derivation returned None — reset link cannot be built. \
952 user_id={} fingerprint={} correlation_id={:?}",
953 user.id,
954 redact_token(&token),
955 correlation_id,
956 );
957 set_token_mail_status(db, token_id, "failed").await?;
958 MailerEmailStatus::Failed
959 }
960 };
961
962 // 7. Audit row. Token fingerprint, NEVER the plaintext.
963 let metadata = serde_json::json!({
964 "token_fingerprint": redact_token(&token),
965 "email_send_status": match mail_status {
966 MailerEmailStatus::Sent => "sent",
967 MailerEmailStatus::Failed => "failed",
968 },
969 "requested_ip": ip,
970 "requested_user_agent": user_agent_owned,
971 "expires_at": expires_at.to_rfc3339(),
972 });
973 let mut entry = LogEntry::new(user.id, ActionType::Update, "user", user.id)
974 .with_event(AuditEvent::PasswordResetSelfRequest);
975 entry.correlation_id = correlation_id;
976 entry.ip_address = Some(&ip);
977 entry.metadata = Some(metadata);
978 entry.summary = format!(
979 "password reset requested; mail {}",
980 match mail_status {
981 MailerEmailStatus::Sent => "sent",
982 MailerEmailStatus::Failed => "failed",
983 }
984 );
985 audit_record(db, entry).await?;
986
987 Ok(IssueOutcome::Issued {
988 token_id,
989 email_status: mail_status,
990 })
991}
992
993/// Consume a reset token, set the new password, revoke every
994/// session for the affected user.
995///
996/// See `DESIGN_RECOVERY.md` §4.3 for the canonical contract this
997/// implements. The function is `pub(crate)` for the same reason
998/// [`issue_reset_token`] is.
999///
1000/// ## Security properties (LOCKED)
1001///
1002/// - **Atomic consume.** The single SQL statement
1003/// `UPDATE … SET consumed_at = NOW() WHERE token_hash = $1 AND
1004/// consumed_at IS NULL AND expires_at > NOW() RETURNING user_id`
1005/// is the only place a token's `consumed_at` flips. The partial
1006/// unique index `WHERE consumed_at IS NULL` (commit #1) makes
1007/// concurrent consumes resolve as one Consumed + one Invalid —
1008/// never two of either.
1009/// - **Policy first, consume second.** A bad password fails
1010/// validation BEFORE the atomic UPDATE, so the user can fix the
1011/// form and retry without burning a token.
1012/// - **Doctrine 22.** Session revocation goes through
1013/// `invalidate_sessions(SessionTarget::User, …PasswordReset)` —
1014/// the framework's only `revoked_at` writer.
1015/// - No log / audit row contains the plaintext token. Token
1016/// fingerprints (8-char SHA-256) are used for cross-row pivoting
1017/// when an operator needs to trace activity.
1018/// - The handler MUST NOT auto-log-in the user on success — they
1019/// go through `/admin/login` so MFA (R3+) gets exercised.
1020pub(crate) async fn consume_reset_token(
1021 db: &Db,
1022 admin: &Admin,
1023 consume_limiter: &RateLimiter,
1024 request: &Request,
1025 token: &str,
1026 new_password: &str,
1027 correlation_id: Option<&str>,
1028) -> Result<ConsumeOutcome> {
1029 let ip = extract_request_ip(request);
1030
1031 // 1. Per-IP rate-limit — bucket exhaustion → render Invalid.
1032 if !consume_limiter.allow(&ip) {
1033 log::info!(
1034 target: "rustio_admin::recovery::consume",
1035 "rate-limit exhausted ip={} correlation_id={:?}",
1036 ip,
1037 correlation_id,
1038 );
1039 return Ok(ConsumeOutcome::RateLimited);
1040 }
1041
1042 // 2. Validate password against policy. A bad password does NOT
1043 // burn the token; the user re-tries the form.
1044 if let Err(e) = admin.active_password_policy().validate(new_password) {
1045 return Ok(ConsumeOutcome::PolicyRejected(e));
1046 }
1047
1048 // 3. Atomic consume — see "Atomic consume" doctrine in the
1049 // function-level docs above.
1050 let token_hash = hash_token_for_storage(token);
1051 let user_id: Option<i64> = sqlx::query_scalar(
1052 "UPDATE rustio_password_reset_tokens
1053 SET consumed_at = NOW()
1054 WHERE token_hash = $1
1055 AND consumed_at IS NULL
1056 AND expires_at > NOW()
1057 RETURNING user_id",
1058 )
1059 .bind(&token_hash)
1060 .fetch_optional(db.pool())
1061 .await?;
1062
1063 let user_id = match user_id {
1064 Some(uid) => uid,
1065 None => {
1066 log::info!(
1067 target: "rustio_admin::recovery::consume",
1068 "consume on invalid/expired/consumed token ip={} fingerprint={} correlation_id={:?}",
1069 ip,
1070 redact_token(token),
1071 correlation_id,
1072 );
1073 return Ok(ConsumeOutcome::Invalid);
1074 }
1075 };
1076
1077 // 4. Set new password. `set_password` stamps
1078 // `password_changed_at` (commit #2). If this fails the
1079 // token is consumed but password unchanged — rare DB-error
1080 // mode, surfaces in logs; the user re-runs the request flow.
1081 set_password(db, user_id, new_password).await?;
1082
1083 // 5. Doctrine 22: every session for the user goes through
1084 // `invalidate_sessions`. Single writer of `revoked_at`.
1085 let outcome = invalidate_sessions(
1086 db,
1087 SessionTarget::User { user_id },
1088 SessionInvalidationReason::PasswordReset,
1089 )
1090 .await?;
1091 let revoked_session_count = outcome.revoked_session_ids.len();
1092
1093 // 6. Audit row. Token fingerprint only.
1094 let user_agent_owned = request.header("user-agent").map(|s| s.to_string());
1095 let metadata = serde_json::json!({
1096 "token_fingerprint": redact_token(token),
1097 "invalidated_session_count": revoked_session_count,
1098 "ip": ip,
1099 "user_agent": user_agent_owned,
1100 });
1101 let mut entry = LogEntry::new(user_id, ActionType::Update, "user", user_id)
1102 .with_event(AuditEvent::PasswordResetSelfConsume);
1103 entry.correlation_id = correlation_id;
1104 entry.ip_address = Some(&ip);
1105 entry.metadata = Some(metadata);
1106 entry.summary =
1107 format!("password reset self-consumed; {revoked_session_count} session(s) revoked");
1108 audit_record(db, entry).await?;
1109
1110 Ok(ConsumeOutcome::Consumed {
1111 user_id,
1112 revoked_session_count,
1113 })
1114}
1115
1116/// Non-mutating check used by the `GET /admin/reset-password/<token>`
1117/// handler (R1 commit #8) to decide whether to render the new-
1118/// password form or the "this link is no longer valid" card. The
1119/// `POST` path still performs the atomic consume regardless — the
1120/// GET-time check is purely a UX courtesy so a user clicking a
1121/// stale link doesn't fill in the form before being told it's
1122/// invalid.
1123///
1124/// Disclosure-equivalent to the consume path: returns `false` for
1125/// unknown / expired / already-consumed tokens. The three sub-cases
1126/// are deliberately indistinguishable to the caller so the renderer
1127/// can't accidentally branch on them (`DESIGN_RECOVERY.md` §2.3).
1128pub(crate) async fn check_reset_token_valid(db: &Db, token: &str) -> Result<bool> {
1129 // Postgres treats `SELECT 1` as INT4; binding the result to
1130 // `Option<i64>` produces a runtime decode mismatch that lands
1131 // as a 500 (downstream validation pass caught it before
1132 // 0.5.0 publish). We `SELECT id` instead — the `id` column is
1133 // BIGSERIAL → INT8, matching `Option<i64>` cleanly, and the
1134 // semantics of "does any row match" are identical. A mistaken
1135 // `Option<i32>` would also work but would drift from the
1136 // sibling `consume_reset_token` query that returns the same
1137 // column shape.
1138 let token_hash = hash_token_for_storage(token);
1139 let exists: Option<i64> = sqlx::query_scalar(
1140 "SELECT id FROM rustio_password_reset_tokens
1141 WHERE token_hash = $1
1142 AND consumed_at IS NULL
1143 AND expires_at > NOW()
1144 LIMIT 1",
1145 )
1146 .bind(&token_hash)
1147 .fetch_optional(db.pool())
1148 .await?;
1149 Ok(exists.is_some())
1150}
1151
1152/// Retention window after a reset-token row's `expires_at` before
1153/// the periodic sweeper purges it. Locked at 7 days
1154/// (`DESIGN_RECOVERY.md` §4.4): the recently-expired window keeps
1155/// the row available for audit correlation, operational debugging,
1156/// and abuse investigations; after 7 days the row's forensic value
1157/// is gone and it disappears.
1158///
1159/// Applies to BOTH consumed and unconsumed rows — once the
1160/// `expires_at` is more than 7 days in the past, neither
1161/// classification carries operational value worth retaining.
1162const RESET_TOKEN_RETENTION_DAYS: i64 = 7;
1163
1164/// Periodically-callable purge of stale reset-token rows. Wired
1165/// into `background::spawn_session_sweeper` (R1 commit #12) on a
1166/// 10-minute tick alongside the session sweeper.
1167///
1168/// **Deletion criterion:** `expires_at < NOW() - INTERVAL '7 days'`.
1169/// One single `DELETE` statement; no per-row loop. The framework's
1170/// partial expires-at index from commit #1 covers the unconsumed-
1171/// row hot path; consumed rows fall to a heap scan over a small
1172/// portion of the table (admin-tier scale, acceptable).
1173///
1174/// **Idempotency:** the predicate is purely time-based against
1175/// `NOW()`; running the function twice in quick succession
1176/// deletes the same rows the first time and returns 0 the second.
1177/// Safe to call from any number of concurrent ticks.
1178///
1179/// **What this function does NOT do:**
1180///
1181/// - Does NOT touch `rustio_users`, `rustio_sessions`, or
1182/// `rustio_admin_actions`. Cleanup is scoped to the recovery
1183/// table; no auth / session / audit behaviour is affected.
1184/// - Does NOT emit audit rows for the deletions — the cleaned-up
1185/// rows themselves carry the forensic record (token_fingerprint,
1186/// correlation_id), and the sweep is operational rather than
1187/// user-facing.
1188/// - Does NOT write to `revoked_at` (Doctrine 22 — the only
1189/// `revoked_at` writer remains `auth::sessions::invalidate_sessions`).
1190/// - Does NOT log any token identifier, user identifier, or
1191/// correlation id. The single info-level line on success records
1192/// only the deleted-row count.
1193pub(crate) async fn purge_expired_reset_tokens(db: &Db) -> Result<u64> {
1194 // The retention window is embedded as a literal in the SQL
1195 // (Postgres INTERVAL doesn't bind cleanly via sqlx). The
1196 // constant + the test below pin the value; a drift would
1197 // surface mechanically.
1198 let query = format!(
1199 "DELETE FROM rustio_password_reset_tokens \
1200 WHERE expires_at < NOW() - INTERVAL '{RESET_TOKEN_RETENTION_DAYS} days'"
1201 );
1202 let result = sqlx::query(&query).execute(db.pool()).await?;
1203 Ok(result.rows_affected())
1204}
1205
1206/// Update an issued token's `mail_status` column. Only the values
1207/// `'pending' | 'sent' | 'failed'` are valid (CHECK constraint
1208/// added in commit #1).
1209async fn set_token_mail_status(db: &Db, token_id: i64, status: &str) -> Result<()> {
1210 sqlx::query(
1211 "UPDATE rustio_password_reset_tokens
1212 SET mail_status = $1
1213 WHERE id = $2",
1214 )
1215 .bind(status)
1216 .bind(token_id)
1217 .execute(db.pool())
1218 .await?;
1219 Ok(())
1220}
1221
1222/// Best-effort client-IP extraction from the `X-Forwarded-For`
1223/// header — first comma-separated entry, trimmed. Falls back to
1224/// `"anon"` when no proxy header is present; rate-limit buckets
1225/// all anonymous requests under one key in that case (acceptable
1226/// for single-tenant deployments; multi-tenant deployments behind
1227/// an unconfigured proxy get noisy and should set the header
1228/// upstream).
1229fn extract_request_ip(request: &Request) -> String {
1230 request
1231 .header("x-forwarded-for")
1232 .and_then(|v| v.split(',').next())
1233 .map(|s| s.trim().to_string())
1234 .filter(|s| !s.is_empty())
1235 .unwrap_or_else(|| "anon".to_string())
1236}
1237
1238/// Render a `chrono::Duration` as a human-readable email-body
1239/// string (e.g. `"in 1 hour"`, `"in 30 minutes"`). Boundary cases
1240/// fall back gracefully — never returns an empty / grammatically
1241/// broken string.
1242fn humanize_ttl(ttl: ChronoDuration) -> String {
1243 let secs = ttl.num_seconds();
1244 if secs <= 0 {
1245 return "very soon".to_string();
1246 }
1247 if ttl.num_hours() >= 1 {
1248 let h = ttl.num_hours();
1249 return if h == 1 {
1250 "in 1 hour".to_string()
1251 } else {
1252 format!("in {h} hours")
1253 };
1254 }
1255 if ttl.num_minutes() >= 1 {
1256 let m = ttl.num_minutes();
1257 return if m == 1 {
1258 "in 1 minute".to_string()
1259 } else {
1260 format!("in {m} minutes")
1261 };
1262 }
1263 if secs == 1 {
1264 "in 1 second".to_string()
1265 } else {
1266 format!("in {secs} seconds")
1267 }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use super::*;
1273
1274 #[test]
1275 fn default_policy_floor_is_ten() {
1276 assert_eq!(DefaultPasswordPolicy::new().min_length(), 10);
1277 assert_eq!(DefaultPasswordPolicy::default().min_length(), 10);
1278 }
1279
1280 #[test]
1281 fn default_policy_accepts_password_at_floor() {
1282 let p = DefaultPasswordPolicy::new();
1283 // Exactly 10 chars — the doctrine-locked default floor.
1284 assert!(p.validate("aaaaaaaaaa").is_ok());
1285 // Comfortable margin.
1286 assert!(p.validate("correct horse battery staple").is_ok());
1287 }
1288
1289 #[test]
1290 fn default_policy_rejects_short_password() {
1291 let p = DefaultPasswordPolicy::new();
1292 let err = p.validate("nine_char").unwrap_err();
1293 assert_eq!(err, PasswordPolicyError::TooShort { min: 10, actual: 9 });
1294 }
1295
1296 #[test]
1297 fn default_policy_rejects_empty_password() {
1298 let p = DefaultPasswordPolicy::new();
1299 let err = p.validate("").unwrap_err();
1300 assert_eq!(err, PasswordPolicyError::TooShort { min: 10, actual: 0 });
1301 }
1302
1303 #[test]
1304 fn default_policy_with_min_len_overrides_floor() {
1305 let p = DefaultPasswordPolicy::with_min_len(16);
1306 assert_eq!(p.min_length(), 16);
1307 assert!(p.validate("fifteen_chars__").is_err()); // 15 chars
1308 assert!(p.validate("sixteen_chars___").is_ok()); // 16 chars
1309 }
1310
1311 #[test]
1312 fn default_policy_counts_chars_not_bytes() {
1313 let p = DefaultPasswordPolicy::new();
1314 // 10 Cyrillic chars = 20 bytes. Char count passes the floor.
1315 let pw = "пароль1234";
1316 assert_eq!(pw.chars().count(), 10);
1317 assert!(pw.len() > 10);
1318 assert!(p.validate(pw).is_ok());
1319
1320 // 9 Cyrillic chars must fail with the char count, not the
1321 // byte count.
1322 let pw = "пароль123";
1323 let err = p.validate(pw).unwrap_err();
1324 assert_eq!(err, PasswordPolicyError::TooShort { min: 10, actual: 9 });
1325 }
1326
1327 #[test]
1328 fn error_renderings_do_not_leak_plaintext() {
1329 // Property: neither Display nor Debug formatting of a
1330 // policy error rendered for a rejected candidate leaks the
1331 // candidate string. Picked plaintext is unlikely to collide
1332 // with English words in the default error message.
1333 let p = DefaultPasswordPolicy::new();
1334 let plaintext = "Pwn4Ge#xy"; // 9 chars — fails the 10-char floor
1335 let err = p.validate(plaintext).unwrap_err();
1336 let display = format!("{err}");
1337 let debug = format!("{err:?}");
1338 assert!(
1339 !display.contains(plaintext),
1340 "Display leaked plaintext: {display}"
1341 );
1342 assert!(
1343 !debug.contains(plaintext),
1344 "Debug leaked plaintext: {debug}"
1345 );
1346 }
1347
1348 #[test]
1349 fn custom_error_renders_message_verbatim() {
1350 let err = PasswordPolicyError::Custom("breached password rejected".into());
1351 assert_eq!(format!("{err}"), "breached password rejected");
1352 }
1353
1354 #[test]
1355 fn shared_password_policy_is_send_sync() {
1356 // Compile-time guarantee that the trait-object alias retains
1357 // the bounds the framework relies on.
1358 fn assert_send_sync<T: Send + Sync>() {}
1359 assert_send_sync::<SharedPasswordPolicy>();
1360 }
1361
1362 // ---- recovery policy ---------------------------------------------------
1363
1364 #[test]
1365 fn default_recovery_policy_ttl_is_one_hour() {
1366 let p = DefaultRecoveryPolicy::new();
1367 assert_eq!(p.reset_token_ttl(), ChronoDuration::hours(1));
1368 }
1369
1370 #[test]
1371 fn default_recovery_policy_request_rate_limit_is_five_per_fifteen_min() {
1372 let p = DefaultRecoveryPolicy::new();
1373 assert_eq!(p.request_rate_limit(), (5, StdDuration::from_secs(15 * 60)));
1374 }
1375
1376 #[test]
1377 fn default_recovery_policy_consume_rate_limit_is_ten_per_five_min() {
1378 let p = DefaultRecoveryPolicy::new();
1379 assert_eq!(p.consume_rate_limit(), (10, StdDuration::from_secs(5 * 60)));
1380 }
1381
1382 #[test]
1383 fn default_recovery_policy_strict_mailer_required_is_false() {
1384 // Locked-decision: project opts in via with_strict_mailer_required(true).
1385 // R1 commit #5 ships the field; enforcement is deferred to commit #7+.
1386 let p = DefaultRecoveryPolicy::new();
1387 assert!(!p.strict_mailer_required());
1388 }
1389
1390 #[test]
1391 fn default_recovery_policy_with_overrides_apply_field_by_field() {
1392 let p = DefaultRecoveryPolicy::new()
1393 .with_reset_token_ttl(ChronoDuration::hours(2))
1394 .with_request_rate_limit(3, StdDuration::from_secs(60))
1395 .with_consume_rate_limit(20, StdDuration::from_secs(30))
1396 .with_strict_mailer_required(true);
1397 assert_eq!(p.reset_token_ttl(), ChronoDuration::hours(2));
1398 assert_eq!(p.request_rate_limit(), (3, StdDuration::from_secs(60)));
1399 assert_eq!(p.consume_rate_limit(), (20, StdDuration::from_secs(30)));
1400 assert!(p.strict_mailer_required());
1401 }
1402
1403 #[test]
1404 fn shared_recovery_policy_is_send_sync() {
1405 fn assert_send_sync<T: Send + Sync>() {}
1406 assert_send_sync::<SharedRecoveryPolicy>();
1407 }
1408
1409 // ---- R2 trait extensions -----------------------------------------------
1410
1411 #[test]
1412 fn login_throttle_default_is_five_ten_fifteen() {
1413 // Locked-decision per DESIGN_R2_ORGANISATIONAL.md §12.
1414 let t = LoginThrottle::default();
1415 assert_eq!(t.max_attempts, 5);
1416 assert_eq!(t.window_minutes, 10);
1417 assert_eq!(t.lock_minutes, 15);
1418 // The const surface and the Default impl agree.
1419 assert_eq!(t, LoginThrottle::DEFAULT);
1420 }
1421
1422 #[test]
1423 fn default_recovery_policy_login_throttle_is_default() {
1424 let p = DefaultRecoveryPolicy::new();
1425 assert_eq!(p.login_throttle(), LoginThrottle::DEFAULT);
1426 }
1427
1428 #[test]
1429 fn default_recovery_policy_reauth_window_is_fifteen_minutes() {
1430 // Locked-decision per DESIGN_R2_ORGANISATIONAL.md §12.
1431 let p = DefaultRecoveryPolicy::new();
1432 assert_eq!(p.reauth_window(), ChronoDuration::minutes(15));
1433 }
1434
1435 #[test]
1436 fn default_recovery_policy_scope_for_returns_none() {
1437 // Default impl signals "no per-tenant scoping". Multi-tenant
1438 // projects override to return Some(scoped_arc); the framework
1439 // call site (`admin.recovery_policy.scope_for(&identity)
1440 // .unwrap_or_else(|| Arc::clone(&admin.recovery_policy))`)
1441 // collapses None back to the original Arc.
1442 use crate::auth::Role;
1443 let identity = Identity {
1444 user_id: 42,
1445 email: "test@example.com".into(),
1446 role: Role::User,
1447 is_active: true,
1448 is_demo: false,
1449 demo_label: None,
1450 must_change_password: false,
1451 };
1452 let p = DefaultRecoveryPolicy::new();
1453 assert!(p.scope_for(&identity).is_none());
1454 }
1455
1456 #[test]
1457 fn login_throttle_is_send_sync_copy() {
1458 fn assert_send_sync_copy<T: Send + Sync + Copy>() {}
1459 assert_send_sync_copy::<LoginThrottle>();
1460 }
1461
1462 // ---- public_site_url derivation ----------------------------------------
1463
1464 fn header_lookup(
1465 pairs: &'static [(&'static str, &'static str)],
1466 ) -> impl Fn(&str) -> Option<String> + 'static {
1467 move |name| {
1468 pairs
1469 .iter()
1470 .find(|(k, _)| k.eq_ignore_ascii_case(name))
1471 .map(|(_, v)| (*v).to_string())
1472 }
1473 }
1474
1475 #[test]
1476 fn site_url_prefers_rfc7239_forwarded_first_hop() {
1477 let h = header_lookup(&[
1478 (
1479 "forwarded",
1480 "for=1.2.3.4;proto=https;host=admin.example.com",
1481 ),
1482 ("x-forwarded-proto", "http"),
1483 ("x-forwarded-host", "wrong.example.com"),
1484 ("host", "internal.local"),
1485 ]);
1486 assert_eq!(
1487 derive_public_site_url(&h),
1488 Some("https://admin.example.com".to_string())
1489 );
1490 }
1491
1492 #[test]
1493 fn site_url_falls_through_to_x_forwarded_pair() {
1494 let h = header_lookup(&[
1495 ("x-forwarded-proto", "https"),
1496 ("x-forwarded-host", "admin.example.com"),
1497 ("host", "internal.local"),
1498 ]);
1499 assert_eq!(
1500 derive_public_site_url(&h),
1501 Some("https://admin.example.com".to_string())
1502 );
1503 }
1504
1505 #[test]
1506 fn site_url_x_forwarded_takes_first_csv_entry() {
1507 // Multiple proxy hops — outermost (closest to client) is first.
1508 let h = header_lookup(&[
1509 ("x-forwarded-proto", "https, http"),
1510 ("x-forwarded-host", "admin.example.com, internal.local"),
1511 ]);
1512 assert_eq!(
1513 derive_public_site_url(&h),
1514 Some("https://admin.example.com".to_string())
1515 );
1516 }
1517
1518 #[test]
1519 fn site_url_falls_back_to_host_header_with_http() {
1520 let h = header_lookup(&[("host", "admin.example.com")]);
1521 assert_eq!(
1522 derive_public_site_url(&h),
1523 Some("http://admin.example.com".to_string())
1524 );
1525 }
1526
1527 #[test]
1528 fn site_url_returns_none_when_no_headers_resolve() {
1529 let h = header_lookup(&[]);
1530 assert_eq!(derive_public_site_url(&h), None);
1531 }
1532
1533 #[test]
1534 fn site_url_rejects_non_http_proto() {
1535 // A malicious client setting `Forwarded: proto=javascript`
1536 // must NOT poison the reset link. We refuse anything outside
1537 // {http, https} and fall through to the next source.
1538 let h = header_lookup(&[
1539 (
1540 "forwarded",
1541 "for=1.2.3.4;proto=javascript;host=evil.example.com",
1542 ),
1543 ("host", "fallback.example.com"),
1544 ]);
1545 assert_eq!(
1546 derive_public_site_url(&h),
1547 Some("http://fallback.example.com".to_string())
1548 );
1549 }
1550
1551 #[test]
1552 fn site_url_rejects_host_with_whitespace_or_control() {
1553 let h = header_lookup(&[("host", "example.com\r\nX-Injected: yes")]);
1554 assert_eq!(derive_public_site_url(&h), None);
1555 }
1556
1557 #[test]
1558 fn site_url_handles_quoted_forwarded_values() {
1559 let h = header_lookup(&[(
1560 "forwarded",
1561 "for=\"_obfuscated\";proto=\"https\";host=\"admin.example.com\"",
1562 )]);
1563 assert_eq!(
1564 derive_public_site_url(&h),
1565 Some("https://admin.example.com".to_string())
1566 );
1567 }
1568
1569 #[test]
1570 fn site_url_handles_ipv6_bracketed_host() {
1571 let h = header_lookup(&[
1572 ("x-forwarded-proto", "https"),
1573 ("x-forwarded-host", "[2001:db8::1]:8443"),
1574 ]);
1575 assert_eq!(
1576 derive_public_site_url(&h),
1577 Some("https://[2001:db8::1]:8443".to_string())
1578 );
1579 }
1580
1581 // ---- humanize_ttl ----
1582
1583 #[test]
1584 fn humanize_ttl_one_hour_default() {
1585 assert_eq!(humanize_ttl(ChronoDuration::hours(1)), "in 1 hour");
1586 }
1587
1588 #[test]
1589 fn humanize_ttl_two_hours_pluralises() {
1590 assert_eq!(humanize_ttl(ChronoDuration::hours(2)), "in 2 hours");
1591 }
1592
1593 #[test]
1594 fn humanize_ttl_minutes() {
1595 assert_eq!(humanize_ttl(ChronoDuration::minutes(30)), "in 30 minutes");
1596 assert_eq!(humanize_ttl(ChronoDuration::minutes(1)), "in 1 minute");
1597 }
1598
1599 #[test]
1600 fn humanize_ttl_seconds_for_short_windows() {
1601 assert_eq!(humanize_ttl(ChronoDuration::seconds(45)), "in 45 seconds");
1602 assert_eq!(humanize_ttl(ChronoDuration::seconds(1)), "in 1 second");
1603 }
1604
1605 // ---- purge_expired_reset_tokens ----------------------------------------
1606
1607 /// Locked retention doctrine — DESIGN_RECOVERY.md §4.4.
1608 /// Changing this constant is a behaviour change requiring a
1609 /// CHANGELOG entry under `Behaviour change`.
1610 #[test]
1611 fn reset_token_retention_window_is_seven_days() {
1612 assert_eq!(RESET_TOKEN_RETENTION_DAYS, 7);
1613 }
1614
1615 /// The DELETE statement targets the recovery table only,
1616 /// embeds the retention window as a literal `INTERVAL` (since
1617 /// sqlx can't bind interval params cleanly), and applies the
1618 /// same predicate to consumed AND unconsumed rows — no
1619 /// `consumed_at` filter on the WHERE clause. Pins the SQL
1620 /// shape so a future drift surfaces here.
1621 #[test]
1622 fn purge_query_includes_retention_window_and_table() {
1623 let query = format!(
1624 "DELETE FROM rustio_password_reset_tokens \
1625 WHERE expires_at < NOW() - INTERVAL '{RESET_TOKEN_RETENTION_DAYS} days'"
1626 );
1627 assert!(
1628 query.contains("rustio_password_reset_tokens"),
1629 "purge must target the recovery table"
1630 );
1631 assert!(
1632 query.contains("INTERVAL '7 days'"),
1633 "purge must use the locked 7-day retention window"
1634 );
1635 assert!(
1636 !query.contains("consumed_at"),
1637 "purge must apply to BOTH consumed and unconsumed expired rows; \
1638 a `consumed_at` filter would leak old consumed rows indefinitely"
1639 );
1640 // Defense-in-depth — the query is a DELETE, not a SELECT
1641 // / UPDATE. A copy-paste accident that turned this into an
1642 // UPDATE would silently leave rows in place; an accidental
1643 // SELECT would do nothing.
1644 assert!(
1645 query.starts_with("DELETE FROM"),
1646 "purge must be a DELETE statement"
1647 );
1648 }
1649
1650 #[test]
1651 fn humanize_ttl_zero_or_negative_returns_safe_string() {
1652 // Boundary: a TTL that's already in the past renders as a
1653 // grammatically safe placeholder. Never empty, never broken.
1654 assert_eq!(humanize_ttl(ChronoDuration::zero()), "very soon");
1655 assert_eq!(humanize_ttl(ChronoDuration::seconds(-30)), "very soon");
1656 }
1657
1658 // ---- IssueOutcome / ConsumeOutcome leak prevention ----
1659
1660 #[test]
1661 fn issue_outcome_debug_never_carries_plaintext_token() {
1662 // Variants are designed without a token field; this test
1663 // pins that property — a future change that adds one would
1664 // fail this. Synthetic plaintext is unlikely to collide
1665 // with the structural form-fields ("Issued", "token_id",
1666 // numbers, etc.).
1667 let synthetic = "Pwn4Ge_ZZ_token_plaintext_1234567890";
1668 for outcome in [
1669 IssueOutcome::Issued {
1670 token_id: 42,
1671 email_status: MailerEmailStatus::Sent,
1672 },
1673 IssueOutcome::Issued {
1674 token_id: 7,
1675 email_status: MailerEmailStatus::Failed,
1676 },
1677 IssueOutcome::UnknownOrInactive,
1678 IssueOutcome::RateLimited,
1679 ] {
1680 let debug = format!("{outcome:?}");
1681 assert!(
1682 !debug.contains(synthetic),
1683 "IssueOutcome Debug leaked plaintext: {debug}",
1684 );
1685 }
1686 }
1687
1688 #[test]
1689 fn consume_outcome_debug_never_carries_plaintext_token() {
1690 let synthetic = "Pwn4Ge_ZZ_token_plaintext_1234567890";
1691 for outcome in [
1692 ConsumeOutcome::Consumed {
1693 user_id: 1,
1694 revoked_session_count: 3,
1695 },
1696 ConsumeOutcome::Invalid,
1697 ConsumeOutcome::PolicyRejected(PasswordPolicyError::TooShort { min: 10, actual: 4 }),
1698 ConsumeOutcome::PolicyRejected(PasswordPolicyError::Custom("stub rejected".into())),
1699 ConsumeOutcome::RateLimited,
1700 ] {
1701 let debug = format!("{outcome:?}");
1702 assert!(
1703 !debug.contains(synthetic),
1704 "ConsumeOutcome Debug leaked plaintext: {debug}",
1705 );
1706 }
1707 }
1708
1709 #[test]
1710 fn mailer_email_status_round_trip_strings() {
1711 // Locked-in for the audit metadata field
1712 // `email_send_status` — values are 'sent' / 'failed'.
1713 assert_eq!(format!("{:?}", MailerEmailStatus::Sent), "Sent");
1714 assert_eq!(format!("{:?}", MailerEmailStatus::Failed), "Failed");
1715 }
1716
1717 #[test]
1718 fn malformed_forwarded_inputs_never_panic() {
1719 for input in &[
1720 "",
1721 "garbage",
1722 "for=",
1723 "proto=;host=",
1724 "proto=javascript:alert(1);host=evil",
1725 "host=example com",
1726 "proto=https;host=",
1727 ";;;",
1728 ",,,",
1729 "proto=https",
1730 "host=example.com",
1731 "for=\"unterminated",
1732 "=value",
1733 "key=",
1734 "key==value=",
1735 ] {
1736 let value = (*input).to_string();
1737 // The lookup returns the test input for "forwarded" and
1738 // a safe host fallback so we exercise the "fall through"
1739 // path too.
1740 let h = move |name: &str| match name {
1741 "forwarded" => Some(value.clone()),
1742 "host" => Some("fallback.example.com".to_string()),
1743 _ => None,
1744 };
1745 // Property: never panics. The result is acceptable as
1746 // long as the fall-through landed somewhere safe.
1747 let result = derive_public_site_url(h);
1748 assert!(
1749 result.is_none()
1750 || result.as_deref() == Some("http://fallback.example.com")
1751 || result.as_deref().map(|s| s.starts_with("https://")) == Some(true)
1752 || result.as_deref().map(|s| s.starts_with("http://")) == Some(true),
1753 "input {input:?} produced unexpected url {result:?}"
1754 );
1755 }
1756 }
1757}