Skip to main content

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;
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// ---- Recovery policy -------------------------------------------------------
304
305/// Tunables for the R1 recovery flow: token TTL, rate-limit shape,
306/// strict-mailer boot guard, and public-site-URL derivation.
307///
308/// `Admin::new()` seeds [`DefaultRecoveryPolicy`]; projects override
309/// via [`crate::admin::Admin::recovery_policy`]. The trait is `Send +
310/// Sync` so the `Arc<dyn RecoveryPolicy>` lives on `Admin` and is
311/// cheap to clone into async futures.
312///
313/// The trait method `public_site_url` has a provided default that
314/// derives the URL from request headers via [`derive_public_site_url`]
315/// per `DESIGN_RECOVERY.md` §12.3. Projects whose deployment can't
316/// rely on the standard Forwarded / X-Forwarded-* / Host headers
317/// override this method and return their own absolute URL (e.g.
318/// stamped at deployment time from a config secret).
319///
320/// ## Trust boundary for forwarded headers
321///
322/// The default `public_site_url` honours these client-supplied
323/// inputs in priority order:
324///
325/// 1. RFC 7239 `Forwarded` header (`for / proto / host` of the first
326///    hop)
327/// 2. `X-Forwarded-Proto` + `X-Forwarded-Host` (first CSV entry of
328///    each)
329/// 3. `Host` header (assumes `http://`)
330///
331/// **The operator's reverse proxy MUST strip incoming versions of
332/// these headers before adding its own.** The framework cannot know
333/// the deployment topology; if a hostile client can reach the
334/// process directly with a chosen `Forwarded: …` header set, the
335/// reset link in the dispatched email will point wherever they ask.
336/// `proto` is whitelisted to `{http, https}` (case-insensitive) and
337/// `host` is rejected when it contains whitespace, control bytes, or
338/// CRLF — so direct injection of `\r\n`-style header smuggling
339/// fails — but a malicious yet shape-conformant value still needs
340/// to be filtered upstream.
341///
342/// Projects that need a stricter trust posture: override
343/// `public_site_url` to return a fixed string (e.g. read from
344/// project config at startup) and the framework will use that
345/// regardless of headers.
346pub trait RecoveryPolicy: Send + Sync {
347    /// How long a freshly-issued reset token stays valid. Default
348    /// 1 hour. Locked-decision per `DESIGN_RECOVERY.md` §17.
349    fn reset_token_ttl(&self) -> ChronoDuration;
350
351    /// Per-IP rate-limit on `POST /admin/forgot-password`. Returned
352    /// as `(capacity, window)`: at most `capacity` requests within
353    /// `window`. Default `(5, 15min)`.
354    fn request_rate_limit(&self) -> (u32, StdDuration);
355
356    /// Per-IP rate-limit on `POST /admin/reset-password/<token>`.
357    /// Tighter than the request limit since the consume path is the
358    /// brute-force surface. Default `(10, 5min)`.
359    fn consume_rate_limit(&self) -> (u32, StdDuration);
360
361    /// When `true`, the framework refuses to start at boot if the
362    /// registered mailer is still the default [`crate::email::LogMailer`]
363    /// (production deployments must opt in to a real mailer).
364    /// Default `false`. Enforcement lands when the recovery handlers
365    /// ship (R1 commit #7+); this commit ships the declaration only.
366    fn strict_mailer_required(&self) -> bool;
367
368    /// Derive the absolute base URL the reset email's link should
369    /// point at. Default: see [`derive_public_site_url`] +
370    /// trust-boundary docs on this trait. Projects override this
371    /// method to return a fixed string (e.g. read from config) when
372    /// header derivation isn't appropriate for their topology.
373    ///
374    /// Returns `None` when nothing resolves; the caller (R1 issue
375    /// handler, commit #7) treats `None` as a hard failure and
376    /// records `metadata.email_send_status = "failed"` with a clear
377    /// log line.
378    fn public_site_url(&self, req: &Request) -> Option<String> {
379        derive_public_site_url(|name| req.header(name).map(|s| s.to_string()))
380    }
381}
382
383/// Type-erased shared recovery-policy reference, mirroring
384/// [`SharedPasswordPolicy`] / [`crate::email::SharedMailer`].
385pub type SharedRecoveryPolicy = Arc<dyn RecoveryPolicy>;
386
387/// Length-only / rate-limit-only baseline policy. Public fields plus
388/// chainable `with_*` setters so projects that want to tweak one knob
389/// don't need to author a full trait impl.
390#[derive(Debug, Clone)]
391pub struct DefaultRecoveryPolicy {
392    pub reset_token_ttl: ChronoDuration,
393    pub request_rate_limit: (u32, StdDuration),
394    pub consume_rate_limit: (u32, StdDuration),
395    pub strict_mailer_required: bool,
396}
397
398impl DefaultRecoveryPolicy {
399    /// New policy with the framework's locked defaults
400    /// (`DESIGN_RECOVERY.md` §17): TTL 1h, request 5/15min, consume
401    /// 10/5min, strict-mailer guard off.
402    pub fn new() -> Self {
403        Self {
404            reset_token_ttl: ChronoDuration::hours(1),
405            request_rate_limit: (5, StdDuration::from_secs(15 * 60)),
406            consume_rate_limit: (10, StdDuration::from_secs(5 * 60)),
407            strict_mailer_required: false,
408        }
409    }
410
411    /// Override the reset-token TTL. Projects that want shorter
412    /// blast-radius windows pass `Duration::minutes(30)`; projects
413    /// that need user-friendlier deadlines pass `Duration::hours(2)`.
414    pub fn with_reset_token_ttl(mut self, ttl: ChronoDuration) -> Self {
415        self.reset_token_ttl = ttl;
416        self
417    }
418
419    /// Override the request-endpoint rate-limit shape.
420    pub fn with_request_rate_limit(mut self, capacity: u32, window: StdDuration) -> Self {
421        self.request_rate_limit = (capacity, window);
422        self
423    }
424
425    /// Override the consume-endpoint rate-limit shape.
426    pub fn with_consume_rate_limit(mut self, capacity: u32, window: StdDuration) -> Self {
427        self.consume_rate_limit = (capacity, window);
428        self
429    }
430
431    /// Toggle the strict-mailer boot guard. When `true`, R1's boot
432    /// sequence (commits #7+) refuses to start with the default
433    /// `LogMailer`. Default `false`.
434    pub fn with_strict_mailer_required(mut self, required: bool) -> Self {
435        self.strict_mailer_required = required;
436        self
437    }
438}
439
440impl Default for DefaultRecoveryPolicy {
441    fn default() -> Self {
442        Self::new()
443    }
444}
445
446impl RecoveryPolicy for DefaultRecoveryPolicy {
447    fn reset_token_ttl(&self) -> ChronoDuration {
448        self.reset_token_ttl
449    }
450
451    fn request_rate_limit(&self) -> (u32, StdDuration) {
452        self.request_rate_limit
453    }
454
455    fn consume_rate_limit(&self) -> (u32, StdDuration) {
456        self.consume_rate_limit
457    }
458
459    fn strict_mailer_required(&self) -> bool {
460        self.strict_mailer_required
461    }
462
463    // public_site_url uses the trait's provided default.
464}
465
466/// Pure helper for the default `RecoveryPolicy::public_site_url`
467/// implementation, factored out so the parser can be unit-tested
468/// without constructing a full [`Request`].
469///
470/// `header` is a closure that returns the named header's value (case-
471/// insensitive name match, owned `String` because the default
472/// closure copies out of the request's borrowed buffer).
473///
474/// Priority order — first source that resolves to a safe
475/// `(proto, host)` pair wins:
476///
477/// 1. RFC 7239 `Forwarded` — first comma-separated entry's
478///    `proto=` + `host=` pairs.
479/// 2. `X-Forwarded-Proto` + `X-Forwarded-Host` — first CSV entry of
480///    each, both required to fall through if either's missing.
481/// 3. `Host` header alone — assumes `http://` (no HTTPS guesswork).
482///
483/// Returns `None` when nothing resolves. Never panics on malformed
484/// input — see the test suite's `malformed_forwarded_inputs_never_panic`
485/// for the property check.
486///
487/// **Trust:** see the `RecoveryPolicy` trait's "Trust boundary"
488/// section. The operator's reverse proxy is responsible for
489/// stripping incoming versions of these headers before its own
490/// hop appends them.
491pub(crate) fn derive_public_site_url<F>(header: F) -> Option<String>
492where
493    F: Fn(&str) -> Option<String>,
494{
495    // 1. RFC 7239 Forwarded — first hop
496    if let Some(value) = header("forwarded") {
497        if let Some(url) = parse_forwarded_first_hop(&value) {
498            return Some(url);
499        }
500    }
501
502    // 2. X-Forwarded-Proto + X-Forwarded-Host
503    let xfp = header("x-forwarded-proto").and_then(|s| first_csv(&s).map(|v| v.to_string()));
504    let xfh = header("x-forwarded-host").and_then(|s| first_csv(&s).map(|v| v.to_string()));
505    if let (Some(proto), Some(host)) = (xfp, xfh) {
506        if is_safe_proto(&proto) && is_safe_host(&host) {
507            return Some(format!("{}://{}", proto.to_ascii_lowercase(), host));
508        }
509    }
510
511    // 3. Host header — assume http
512    if let Some(host) = header("host") {
513        if is_safe_host(&host) {
514            return Some(format!("http://{host}"));
515        }
516    }
517
518    None
519}
520
521/// Take the first comma-separated, trimmed, non-empty token of `s`.
522fn first_csv(s: &str) -> Option<&str> {
523    let trimmed = s.split(',').next()?.trim();
524    if trimmed.is_empty() {
525        None
526    } else {
527        Some(trimmed)
528    }
529}
530
531/// Whitelist: only `http` and `https` are accepted. Case-insensitive.
532fn is_safe_proto(p: &str) -> bool {
533    p.eq_ignore_ascii_case("http") || p.eq_ignore_ascii_case("https")
534}
535
536/// Reject empty / over-long / control-char / whitespace hosts. Allows
537/// alphanumerics, the dot/dash/underscore separators, the colon for
538/// the `host:port` shape, and `[` / `]` for IPv6 literals.
539fn is_safe_host(h: &str) -> bool {
540    if h.is_empty() || h.len() > 253 {
541        return false;
542    }
543    h.chars()
544        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | ':' | '-' | '_' | '[' | ']'))
545}
546
547/// Parse `proto=` and `host=` from the FIRST comma-separated entry
548/// of an RFC 7239 `Forwarded` header value. Returns the canonical
549/// `proto://host` URL, or `None` if either is missing or fails the
550/// safety check.
551fn parse_forwarded_first_hop(value: &str) -> Option<String> {
552    let first = value.split(',').next()?;
553    let mut proto: Option<&str> = None;
554    let mut host: Option<&str> = None;
555
556    for pair in first.split(';') {
557        let pair = pair.trim();
558        if pair.is_empty() {
559            continue;
560        }
561        let (key, val) = match pair.split_once('=') {
562            Some(p) => p,
563            None => continue,
564        };
565        let key = key.trim();
566        // Strip surrounding quotes if present (RFC 7239 allows
567        // quoted-string syntax for values containing special chars).
568        let val = val.trim().trim_matches('"');
569        if val.is_empty() {
570            continue;
571        }
572        if key.eq_ignore_ascii_case("proto") {
573            proto = Some(val);
574        } else if key.eq_ignore_ascii_case("host") {
575            host = Some(val);
576        }
577    }
578
579    let proto = proto?;
580    let host = host?;
581    if !is_safe_proto(proto) || !is_safe_host(host) {
582        return None;
583    }
584    Some(format!("{}://{}", proto.to_ascii_lowercase(), host))
585}
586
587// ---- Runtime: token issuance + consumption -------------------------------
588
589/// Outcome of [`issue_reset_token`]. Variants exist for
590/// observability and testability — the user-facing handler renders
591/// the same uniform "if that email has an account, we just sent a
592/// link" page across every variant per the disclosure rule
593/// (`DESIGN_RECOVERY.md` §2.3).
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub(crate) enum IssueOutcome {
596    /// A token row was inserted; the mailer dispatch attempt
597    /// finished (see `email_status` for whether the message
598    /// actually went out). One audit row written
599    /// (`AuditEvent::PasswordResetSelfRequest`).
600    Issued {
601        token_id: i64,
602        email_status: MailerEmailStatus,
603    },
604    /// Email didn't match an active user — either unknown OR
605    /// deactivated. The two sub-cases are deliberately
606    /// indistinguishable from outside (doctrine 9, §2.3 disclosure
607    /// rule). No DB row, no audit, no mail. A `log::info!` line is
608    /// written for operator-side visibility, but it never carries
609    /// a token, password, or anything that could be used for
610    /// enumeration analysis later.
611    UnknownOrInactive,
612    /// Per-IP rate-limit on the request endpoint exhausted. No DB
613    /// row. Renderer treats this identically to `Issued` /
614    /// `UnknownOrInactive` (uniform-response invariant).
615    RateLimited,
616}
617
618/// Whether the mailer's `send` call returned `Ok` or a typed
619/// `MailerError`. Persisted on the token row's `mail_status` column
620/// and into the audit row's `metadata.email_send_status`.
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622pub(crate) enum MailerEmailStatus {
623    Sent,
624    Failed,
625}
626
627/// Outcome of [`consume_reset_token`]. The user-facing handler
628/// renders `Invalid` and `RateLimited` identically (the "this link
629/// is no longer valid" page) per disclosure rule §2.3 — the variant
630/// distinction exists for observability + tests, not for branching
631/// the UI.
632#[derive(Debug, Clone, PartialEq, Eq)]
633pub(crate) enum ConsumeOutcome {
634    /// Token consumed atomically; password updated; every session
635    /// for the affected user revoked through
636    /// `invalidate_sessions(SessionTarget::User { user_id },
637    /// SessionInvalidationReason::PasswordReset)`. One audit row
638    /// written (`AuditEvent::PasswordResetSelfConsume`).
639    Consumed {
640        user_id: i64,
641        revoked_session_count: usize,
642    },
643    /// Token unknown / expired / already consumed (the three are
644    /// deliberately indistinguishable per §2.3). No password
645    /// change, no session revocation, no audit row written. A
646    /// `log::info!` line carries the token's redacted fingerprint
647    /// for cross-row pivoting if the operator needs to investigate.
648    Invalid,
649    /// `PasswordPolicy::validate` rejected the candidate password.
650    /// No DB mutation: the token stays valid for retry; the form
651    /// re-renders with the policy error. The error itself is safe
652    /// to render — `PasswordPolicyError` variants do not carry the
653    /// candidate plaintext (see commit #4's leak-prevention test).
654    PolicyRejected(PasswordPolicyError),
655    /// Per-IP rate-limit on the consume endpoint exhausted. No DB
656    /// mutation. Renderer treats this identically to `Invalid`.
657    RateLimited,
658}
659
660/// Issue a password-reset token for `email` — or pretend to,
661/// preserving the uniform-response invariant.
662///
663/// See `DESIGN_RECOVERY.md` §4.2 for the canonical contract this
664/// implements. The function is `pub(crate)` because the framework
665/// owns the route shape (CSRF, rate-limit middleware, render
666/// pipeline). External projects compose recovery via the trait
667/// surfaces ([`PasswordPolicy`], [`RecoveryPolicy`],
668/// [`crate::email::Mailer`]) rather than calling this directly.
669///
670/// ## Security properties (LOCKED)
671///
672/// - The plaintext token leaves this function only as part of the
673///   email body dispatched through [`crate::email::Mailer`]. The DB
674///   row stores `token_hash = sha256(token)` only.
675/// - Outward result is uniform: `IssueOutcome::Issued`,
676///   `UnknownOrInactive`, and `RateLimited` all map to the same
677///   user-facing page in the handler (commit #8). The variant
678///   distinction is for audit + tests only.
679/// - No `log::info!` / `log::error!` / audit row contains the
680///   plaintext token. Logs use [`redact_token`] (8-char SHA-256
681///   fingerprint); audit metadata stores `token_fingerprint`.
682/// - On mailer failure (transient OR permanent OR `public_site_url`
683///   derivation returning None), the outward result is still
684///   `IssueOutcome::Issued { email_status: Failed }` — the row
685///   exists with `mail_status = 'failed'` and the audit row carries
686///   `email_send_status = "failed"`. The user sees the uniform
687///   response.
688pub(crate) async fn issue_reset_token(
689    db: &Db,
690    admin: &Admin,
691    request_limiter: &RateLimiter,
692    request: &Request,
693    email: &str,
694    correlation_id: Option<&str>,
695) -> Result<IssueOutcome> {
696    let ip = extract_request_ip(request);
697
698    // 1. Per-IP rate-limit — bucket exhaustion → uniform response.
699    if !request_limiter.allow(&ip) {
700        log::info!(
701            target: "rustio_admin::recovery::issue",
702            "rate-limit exhausted ip={} correlation_id={:?}",
703            ip,
704            correlation_id,
705        );
706        return Ok(IssueOutcome::RateLimited);
707    }
708
709    // 2. Normalise email input.
710    let email_input = email.trim().to_ascii_lowercase();
711    if email_input.is_empty() {
712        log::info!(
713            target: "rustio_admin::recovery::issue",
714            "empty-email submission ip={} correlation_id={:?}",
715            ip,
716            correlation_id,
717        );
718        return Ok(IssueOutcome::UnknownOrInactive);
719    }
720
721    // 3. User lookup. Both unknown-email and inactive-user collapse
722    //    into UnknownOrInactive — leaking either creates an
723    //    enumeration channel.
724    let user = match find_user_by_email(db, &email_input).await? {
725        Some(u) if u.is_active => u,
726        Some(u) => {
727            log::info!(
728                target: "rustio_admin::recovery::issue",
729                "inactive-user submission user_id={} ip={} correlation_id={:?}",
730                u.id,
731                ip,
732                correlation_id,
733            );
734            return Ok(IssueOutcome::UnknownOrInactive);
735        }
736        None => {
737            log::info!(
738                target: "rustio_admin::recovery::issue",
739                "unknown-email submission ip={} correlation_id={:?}",
740                ip,
741                correlation_id,
742            );
743            return Ok(IssueOutcome::UnknownOrInactive);
744        }
745    };
746
747    // 4. Generate token. 256-bit URL-safe-base64. Plaintext lives
748    //    only here, in the email body, and in the user's mailbox —
749    //    NEVER in the DB, NEVER in any log line.
750    let token = random_token();
751    let token_hash = hash_token_for_storage(&token);
752
753    // 5. Insert the token row with mail_status = 'pending'.
754    let policy = admin.active_recovery_policy();
755    let ttl = policy.reset_token_ttl();
756    let expires_at = chrono::Utc::now() + ttl;
757    let user_agent_owned = request.header("user-agent").map(|s| s.to_string());
758
759    let token_id: i64 = sqlx::query_scalar(
760        "INSERT INTO rustio_password_reset_tokens
761            (user_id, token_hash, requested_ip, requested_user_agent,
762             expires_at, mail_status, correlation_id)
763         VALUES ($1, $2, $3, $4, $5, 'pending', $6)
764       RETURNING id",
765    )
766    .bind(user.id)
767    .bind(&token_hash)
768    .bind(&ip)
769    .bind(user_agent_owned.as_deref())
770    .bind(expires_at)
771    .bind(correlation_id)
772    .fetch_one(db.pool())
773    .await?;
774
775    // 6. Compose + dispatch mail. If site-URL derivation fails or
776    //    the mailer returns an error, mark mail_status = 'failed'
777    //    and continue — the user-facing response stays uniform.
778    let mail_status = match policy.public_site_url(request) {
779        Some(public_site_url) => {
780            let reset_link = format!(
781                "{}/admin/reset-password/{}",
782                public_site_url.trim_end_matches('/'),
783                token,
784            );
785            let when = chrono::Utc::now();
786            let body = format!(
787                "We received a request to sign you back in to {site_header}.\n\n\
788                 Click the link below to set a new password:\n\n\
789                 {reset_link}\n\n\
790                 The link expires {ttl_human}. If you didn't request this, you can \
791                 safely ignore this email.\n",
792                site_header = admin.branding().site_header,
793                reset_link = reset_link,
794                ttl_human = humanize_ttl(ttl),
795            );
796            let mail = Mail::framework_envelope(
797                user.email.clone(),
798                format!("{} — sign-in link", admin.branding().site_header),
799                body,
800                &admin.branding().site_header,
801                Some(&ip),
802                user_agent_owned.as_deref(),
803                when,
804            );
805            match admin.active_mailer().send(mail).await {
806                Ok(()) => {
807                    set_token_mail_status(db, token_id, "sent").await?;
808                    MailerEmailStatus::Sent
809                }
810                Err(e) => {
811                    log::error!(
812                        target: "rustio_admin::recovery::issue",
813                        "mailer send failed user_id={} fingerprint={} correlation_id={:?}: {}",
814                        user.id,
815                        redact_token(&token),
816                        correlation_id,
817                        e,
818                    );
819                    set_token_mail_status(db, token_id, "failed").await?;
820                    MailerEmailStatus::Failed
821                }
822            }
823        }
824        None => {
825            log::error!(
826                target: "rustio_admin::recovery::issue",
827                "public_site_url derivation returned None — reset link cannot be built. \
828                 user_id={} fingerprint={} correlation_id={:?}",
829                user.id,
830                redact_token(&token),
831                correlation_id,
832            );
833            set_token_mail_status(db, token_id, "failed").await?;
834            MailerEmailStatus::Failed
835        }
836    };
837
838    // 7. Audit row. Token fingerprint, NEVER the plaintext.
839    let metadata = serde_json::json!({
840        "token_fingerprint": redact_token(&token),
841        "email_send_status": match mail_status {
842            MailerEmailStatus::Sent => "sent",
843            MailerEmailStatus::Failed => "failed",
844        },
845        "requested_ip": ip,
846        "requested_user_agent": user_agent_owned,
847        "expires_at": expires_at.to_rfc3339(),
848    });
849    let mut entry = LogEntry::new(user.id, ActionType::Update, "user", user.id)
850        .with_event(AuditEvent::PasswordResetSelfRequest);
851    entry.correlation_id = correlation_id;
852    entry.ip_address = Some(&ip);
853    entry.metadata = Some(metadata);
854    entry.summary = format!(
855        "password reset requested; mail {}",
856        match mail_status {
857            MailerEmailStatus::Sent => "sent",
858            MailerEmailStatus::Failed => "failed",
859        }
860    );
861    audit_record(db, entry).await?;
862
863    Ok(IssueOutcome::Issued {
864        token_id,
865        email_status: mail_status,
866    })
867}
868
869/// Consume a reset token, set the new password, revoke every
870/// session for the affected user.
871///
872/// See `DESIGN_RECOVERY.md` §4.3 for the canonical contract this
873/// implements. The function is `pub(crate)` for the same reason
874/// [`issue_reset_token`] is.
875///
876/// ## Security properties (LOCKED)
877///
878/// - **Atomic consume.** The single SQL statement
879///   `UPDATE … SET consumed_at = NOW() WHERE token_hash = $1 AND
880///    consumed_at IS NULL AND expires_at > NOW() RETURNING user_id`
881///   is the only place a token's `consumed_at` flips. The partial
882///   unique index `WHERE consumed_at IS NULL` (commit #1) makes
883///   concurrent consumes resolve as one Consumed + one Invalid —
884///   never two of either.
885/// - **Policy first, consume second.** A bad password fails
886///   validation BEFORE the atomic UPDATE, so the user can fix the
887///   form and retry without burning a token.
888/// - **Doctrine 22.** Session revocation goes through
889///   `invalidate_sessions(SessionTarget::User, …PasswordReset)` —
890///   the framework's only `revoked_at` writer.
891/// - No log / audit row contains the plaintext token. Token
892///   fingerprints (8-char SHA-256) are used for cross-row pivoting
893///   when an operator needs to trace activity.
894/// - The handler MUST NOT auto-log-in the user on success — they
895///   go through `/admin/login` so MFA (R3+) gets exercised.
896pub(crate) async fn consume_reset_token(
897    db: &Db,
898    admin: &Admin,
899    consume_limiter: &RateLimiter,
900    request: &Request,
901    token: &str,
902    new_password: &str,
903    correlation_id: Option<&str>,
904) -> Result<ConsumeOutcome> {
905    let ip = extract_request_ip(request);
906
907    // 1. Per-IP rate-limit — bucket exhaustion → render Invalid.
908    if !consume_limiter.allow(&ip) {
909        log::info!(
910            target: "rustio_admin::recovery::consume",
911            "rate-limit exhausted ip={} correlation_id={:?}",
912            ip,
913            correlation_id,
914        );
915        return Ok(ConsumeOutcome::RateLimited);
916    }
917
918    // 2. Validate password against policy. A bad password does NOT
919    //    burn the token; the user re-tries the form.
920    if let Err(e) = admin.active_password_policy().validate(new_password) {
921        return Ok(ConsumeOutcome::PolicyRejected(e));
922    }
923
924    // 3. Atomic consume — see "Atomic consume" doctrine in the
925    //    function-level docs above.
926    let token_hash = hash_token_for_storage(token);
927    let user_id: Option<i64> = sqlx::query_scalar(
928        "UPDATE rustio_password_reset_tokens
929            SET consumed_at = NOW()
930          WHERE token_hash = $1
931            AND consumed_at IS NULL
932            AND expires_at > NOW()
933        RETURNING user_id",
934    )
935    .bind(&token_hash)
936    .fetch_optional(db.pool())
937    .await?;
938
939    let user_id = match user_id {
940        Some(uid) => uid,
941        None => {
942            log::info!(
943                target: "rustio_admin::recovery::consume",
944                "consume on invalid/expired/consumed token ip={} fingerprint={} correlation_id={:?}",
945                ip,
946                redact_token(token),
947                correlation_id,
948            );
949            return Ok(ConsumeOutcome::Invalid);
950        }
951    };
952
953    // 4. Set new password. `set_password` stamps
954    //    `password_changed_at` (commit #2). If this fails the
955    //    token is consumed but password unchanged — rare DB-error
956    //    mode, surfaces in logs; the user re-runs the request flow.
957    set_password(db, user_id, new_password).await?;
958
959    // 5. Doctrine 22: every session for the user goes through
960    //    `invalidate_sessions`. Single writer of `revoked_at`.
961    let outcome = invalidate_sessions(
962        db,
963        SessionTarget::User { user_id },
964        SessionInvalidationReason::PasswordReset,
965    )
966    .await?;
967    let revoked_session_count = outcome.revoked_session_ids.len();
968
969    // 6. Audit row. Token fingerprint only.
970    let user_agent_owned = request.header("user-agent").map(|s| s.to_string());
971    let metadata = serde_json::json!({
972        "token_fingerprint": redact_token(token),
973        "invalidated_session_count": revoked_session_count,
974        "ip": ip,
975        "user_agent": user_agent_owned,
976    });
977    let mut entry = LogEntry::new(user_id, ActionType::Update, "user", user_id)
978        .with_event(AuditEvent::PasswordResetSelfConsume);
979    entry.correlation_id = correlation_id;
980    entry.ip_address = Some(&ip);
981    entry.metadata = Some(metadata);
982    entry.summary =
983        format!("password reset self-consumed; {revoked_session_count} session(s) revoked");
984    audit_record(db, entry).await?;
985
986    Ok(ConsumeOutcome::Consumed {
987        user_id,
988        revoked_session_count,
989    })
990}
991
992/// Non-mutating check used by the `GET /admin/reset-password/<token>`
993/// handler (R1 commit #8) to decide whether to render the new-
994/// password form or the "this link is no longer valid" card. The
995/// `POST` path still performs the atomic consume regardless — the
996/// GET-time check is purely a UX courtesy so a user clicking a
997/// stale link doesn't fill in the form before being told it's
998/// invalid.
999///
1000/// Disclosure-equivalent to the consume path: returns `false` for
1001/// unknown / expired / already-consumed tokens. The three sub-cases
1002/// are deliberately indistinguishable to the caller so the renderer
1003/// can't accidentally branch on them (`DESIGN_RECOVERY.md` §2.3).
1004pub(crate) async fn check_reset_token_valid(db: &Db, token: &str) -> Result<bool> {
1005    // Postgres treats `SELECT 1` as INT4; binding the result to
1006    // `Option<i64>` produces a runtime decode mismatch that lands
1007    // as a 500 (downstream validation pass caught it before
1008    // 0.5.0 publish). We `SELECT id` instead — the `id` column is
1009    // BIGSERIAL → INT8, matching `Option<i64>` cleanly, and the
1010    // semantics of "does any row match" are identical. A mistaken
1011    // `Option<i32>` would also work but would drift from the
1012    // sibling `consume_reset_token` query that returns the same
1013    // column shape.
1014    let token_hash = hash_token_for_storage(token);
1015    let exists: Option<i64> = sqlx::query_scalar(
1016        "SELECT id FROM rustio_password_reset_tokens
1017          WHERE token_hash = $1
1018            AND consumed_at IS NULL
1019            AND expires_at > NOW()
1020          LIMIT 1",
1021    )
1022    .bind(&token_hash)
1023    .fetch_optional(db.pool())
1024    .await?;
1025    Ok(exists.is_some())
1026}
1027
1028/// Retention window after a reset-token row's `expires_at` before
1029/// the periodic sweeper purges it. Locked at 7 days
1030/// (`DESIGN_RECOVERY.md` §4.4): the recently-expired window keeps
1031/// the row available for audit correlation, operational debugging,
1032/// and abuse investigations; after 7 days the row's forensic value
1033/// is gone and it disappears.
1034///
1035/// Applies to BOTH consumed and unconsumed rows — once the
1036/// `expires_at` is more than 7 days in the past, neither
1037/// classification carries operational value worth retaining.
1038const RESET_TOKEN_RETENTION_DAYS: i64 = 7;
1039
1040/// Periodically-callable purge of stale reset-token rows. Wired
1041/// into `background::spawn_session_sweeper` (R1 commit #12) on a
1042/// 10-minute tick alongside the session sweeper.
1043///
1044/// **Deletion criterion:** `expires_at < NOW() - INTERVAL '7 days'`.
1045/// One single `DELETE` statement; no per-row loop. The framework's
1046/// partial expires-at index from commit #1 covers the unconsumed-
1047/// row hot path; consumed rows fall to a heap scan over a small
1048/// portion of the table (admin-tier scale, acceptable).
1049///
1050/// **Idempotency:** the predicate is purely time-based against
1051/// `NOW()`; running the function twice in quick succession
1052/// deletes the same rows the first time and returns 0 the second.
1053/// Safe to call from any number of concurrent ticks.
1054///
1055/// **What this function does NOT do:**
1056///
1057/// - Does NOT touch `rustio_users`, `rustio_sessions`, or
1058///   `rustio_admin_actions`. Cleanup is scoped to the recovery
1059///   table; no auth / session / audit behaviour is affected.
1060/// - Does NOT emit audit rows for the deletions — the cleaned-up
1061///   rows themselves carry the forensic record (token_fingerprint,
1062///   correlation_id), and the sweep is operational rather than
1063///   user-facing.
1064/// - Does NOT write to `revoked_at` (Doctrine 22 — the only
1065///   `revoked_at` writer remains `auth::sessions::invalidate_sessions`).
1066/// - Does NOT log any token identifier, user identifier, or
1067///   correlation id. The single info-level line on success records
1068///   only the deleted-row count.
1069pub(crate) async fn purge_expired_reset_tokens(db: &Db) -> Result<u64> {
1070    // The retention window is embedded as a literal in the SQL
1071    // (Postgres INTERVAL doesn't bind cleanly via sqlx). The
1072    // constant + the test below pin the value; a drift would
1073    // surface mechanically.
1074    let query = format!(
1075        "DELETE FROM rustio_password_reset_tokens \
1076          WHERE expires_at < NOW() - INTERVAL '{RESET_TOKEN_RETENTION_DAYS} days'"
1077    );
1078    let result = sqlx::query(&query).execute(db.pool()).await?;
1079    Ok(result.rows_affected())
1080}
1081
1082/// Update an issued token's `mail_status` column. Only the values
1083/// `'pending' | 'sent' | 'failed'` are valid (CHECK constraint
1084/// added in commit #1).
1085async fn set_token_mail_status(db: &Db, token_id: i64, status: &str) -> Result<()> {
1086    sqlx::query(
1087        "UPDATE rustio_password_reset_tokens
1088            SET mail_status = $1
1089          WHERE id = $2",
1090    )
1091    .bind(status)
1092    .bind(token_id)
1093    .execute(db.pool())
1094    .await?;
1095    Ok(())
1096}
1097
1098/// Best-effort client-IP extraction from the `X-Forwarded-For`
1099/// header — first comma-separated entry, trimmed. Falls back to
1100/// `"anon"` when no proxy header is present; rate-limit buckets
1101/// all anonymous requests under one key in that case (acceptable
1102/// for single-tenant deployments; multi-tenant deployments behind
1103/// an unconfigured proxy get noisy and should set the header
1104/// upstream).
1105fn extract_request_ip(request: &Request) -> String {
1106    request
1107        .header("x-forwarded-for")
1108        .and_then(|v| v.split(',').next())
1109        .map(|s| s.trim().to_string())
1110        .filter(|s| !s.is_empty())
1111        .unwrap_or_else(|| "anon".to_string())
1112}
1113
1114/// Render a `chrono::Duration` as a human-readable email-body
1115/// string (e.g. `"in 1 hour"`, `"in 30 minutes"`). Boundary cases
1116/// fall back gracefully — never returns an empty / grammatically
1117/// broken string.
1118fn humanize_ttl(ttl: ChronoDuration) -> String {
1119    let secs = ttl.num_seconds();
1120    if secs <= 0 {
1121        return "very soon".to_string();
1122    }
1123    if ttl.num_hours() >= 1 {
1124        let h = ttl.num_hours();
1125        return if h == 1 {
1126            "in 1 hour".to_string()
1127        } else {
1128            format!("in {h} hours")
1129        };
1130    }
1131    if ttl.num_minutes() >= 1 {
1132        let m = ttl.num_minutes();
1133        return if m == 1 {
1134            "in 1 minute".to_string()
1135        } else {
1136            format!("in {m} minutes")
1137        };
1138    }
1139    if secs == 1 {
1140        "in 1 second".to_string()
1141    } else {
1142        format!("in {secs} seconds")
1143    }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148    use super::*;
1149
1150    #[test]
1151    fn default_policy_floor_is_ten() {
1152        assert_eq!(DefaultPasswordPolicy::new().min_length(), 10);
1153        assert_eq!(DefaultPasswordPolicy::default().min_length(), 10);
1154    }
1155
1156    #[test]
1157    fn default_policy_accepts_password_at_floor() {
1158        let p = DefaultPasswordPolicy::new();
1159        // Exactly 10 chars — the doctrine-locked default floor.
1160        assert!(p.validate("aaaaaaaaaa").is_ok());
1161        // Comfortable margin.
1162        assert!(p.validate("correct horse battery staple").is_ok());
1163    }
1164
1165    #[test]
1166    fn default_policy_rejects_short_password() {
1167        let p = DefaultPasswordPolicy::new();
1168        let err = p.validate("nine_char").unwrap_err();
1169        assert_eq!(err, PasswordPolicyError::TooShort { min: 10, actual: 9 });
1170    }
1171
1172    #[test]
1173    fn default_policy_rejects_empty_password() {
1174        let p = DefaultPasswordPolicy::new();
1175        let err = p.validate("").unwrap_err();
1176        assert_eq!(err, PasswordPolicyError::TooShort { min: 10, actual: 0 });
1177    }
1178
1179    #[test]
1180    fn default_policy_with_min_len_overrides_floor() {
1181        let p = DefaultPasswordPolicy::with_min_len(16);
1182        assert_eq!(p.min_length(), 16);
1183        assert!(p.validate("fifteen_chars__").is_err()); // 15 chars
1184        assert!(p.validate("sixteen_chars___").is_ok()); //  16 chars
1185    }
1186
1187    #[test]
1188    fn default_policy_counts_chars_not_bytes() {
1189        let p = DefaultPasswordPolicy::new();
1190        // 10 Cyrillic chars = 20 bytes. Char count passes the floor.
1191        let pw = "пароль1234";
1192        assert_eq!(pw.chars().count(), 10);
1193        assert!(pw.len() > 10);
1194        assert!(p.validate(pw).is_ok());
1195
1196        // 9 Cyrillic chars must fail with the char count, not the
1197        // byte count.
1198        let pw = "пароль123";
1199        let err = p.validate(pw).unwrap_err();
1200        assert_eq!(err, PasswordPolicyError::TooShort { min: 10, actual: 9 });
1201    }
1202
1203    #[test]
1204    fn error_renderings_do_not_leak_plaintext() {
1205        // Property: neither Display nor Debug formatting of a
1206        // policy error rendered for a rejected candidate leaks the
1207        // candidate string. Picked plaintext is unlikely to collide
1208        // with English words in the default error message.
1209        let p = DefaultPasswordPolicy::new();
1210        let plaintext = "Pwn4Ge#xy"; // 9 chars — fails the 10-char floor
1211        let err = p.validate(plaintext).unwrap_err();
1212        let display = format!("{err}");
1213        let debug = format!("{err:?}");
1214        assert!(
1215            !display.contains(plaintext),
1216            "Display leaked plaintext: {display}"
1217        );
1218        assert!(
1219            !debug.contains(plaintext),
1220            "Debug leaked plaintext: {debug}"
1221        );
1222    }
1223
1224    #[test]
1225    fn custom_error_renders_message_verbatim() {
1226        let err = PasswordPolicyError::Custom("breached password rejected".into());
1227        assert_eq!(format!("{err}"), "breached password rejected");
1228    }
1229
1230    #[test]
1231    fn shared_password_policy_is_send_sync() {
1232        // Compile-time guarantee that the trait-object alias retains
1233        // the bounds the framework relies on.
1234        fn assert_send_sync<T: Send + Sync>() {}
1235        assert_send_sync::<SharedPasswordPolicy>();
1236    }
1237
1238    // ---- recovery policy ---------------------------------------------------
1239
1240    #[test]
1241    fn default_recovery_policy_ttl_is_one_hour() {
1242        let p = DefaultRecoveryPolicy::new();
1243        assert_eq!(p.reset_token_ttl(), ChronoDuration::hours(1));
1244    }
1245
1246    #[test]
1247    fn default_recovery_policy_request_rate_limit_is_five_per_fifteen_min() {
1248        let p = DefaultRecoveryPolicy::new();
1249        assert_eq!(p.request_rate_limit(), (5, StdDuration::from_secs(15 * 60)));
1250    }
1251
1252    #[test]
1253    fn default_recovery_policy_consume_rate_limit_is_ten_per_five_min() {
1254        let p = DefaultRecoveryPolicy::new();
1255        assert_eq!(p.consume_rate_limit(), (10, StdDuration::from_secs(5 * 60)));
1256    }
1257
1258    #[test]
1259    fn default_recovery_policy_strict_mailer_required_is_false() {
1260        // Locked-decision: project opts in via with_strict_mailer_required(true).
1261        // R1 commit #5 ships the field; enforcement is deferred to commit #7+.
1262        let p = DefaultRecoveryPolicy::new();
1263        assert!(!p.strict_mailer_required());
1264    }
1265
1266    #[test]
1267    fn default_recovery_policy_with_overrides_apply_field_by_field() {
1268        let p = DefaultRecoveryPolicy::new()
1269            .with_reset_token_ttl(ChronoDuration::hours(2))
1270            .with_request_rate_limit(3, StdDuration::from_secs(60))
1271            .with_consume_rate_limit(20, StdDuration::from_secs(30))
1272            .with_strict_mailer_required(true);
1273        assert_eq!(p.reset_token_ttl(), ChronoDuration::hours(2));
1274        assert_eq!(p.request_rate_limit(), (3, StdDuration::from_secs(60)));
1275        assert_eq!(p.consume_rate_limit(), (20, StdDuration::from_secs(30)));
1276        assert!(p.strict_mailer_required());
1277    }
1278
1279    #[test]
1280    fn shared_recovery_policy_is_send_sync() {
1281        fn assert_send_sync<T: Send + Sync>() {}
1282        assert_send_sync::<SharedRecoveryPolicy>();
1283    }
1284
1285    // ---- public_site_url derivation ----------------------------------------
1286
1287    fn header_lookup(
1288        pairs: &'static [(&'static str, &'static str)],
1289    ) -> impl Fn(&str) -> Option<String> + 'static {
1290        move |name| {
1291            pairs
1292                .iter()
1293                .find(|(k, _)| k.eq_ignore_ascii_case(name))
1294                .map(|(_, v)| (*v).to_string())
1295        }
1296    }
1297
1298    #[test]
1299    fn site_url_prefers_rfc7239_forwarded_first_hop() {
1300        let h = header_lookup(&[
1301            (
1302                "forwarded",
1303                "for=1.2.3.4;proto=https;host=admin.example.com",
1304            ),
1305            ("x-forwarded-proto", "http"),
1306            ("x-forwarded-host", "wrong.example.com"),
1307            ("host", "internal.local"),
1308        ]);
1309        assert_eq!(
1310            derive_public_site_url(&h),
1311            Some("https://admin.example.com".to_string())
1312        );
1313    }
1314
1315    #[test]
1316    fn site_url_falls_through_to_x_forwarded_pair() {
1317        let h = header_lookup(&[
1318            ("x-forwarded-proto", "https"),
1319            ("x-forwarded-host", "admin.example.com"),
1320            ("host", "internal.local"),
1321        ]);
1322        assert_eq!(
1323            derive_public_site_url(&h),
1324            Some("https://admin.example.com".to_string())
1325        );
1326    }
1327
1328    #[test]
1329    fn site_url_x_forwarded_takes_first_csv_entry() {
1330        // Multiple proxy hops — outermost (closest to client) is first.
1331        let h = header_lookup(&[
1332            ("x-forwarded-proto", "https, http"),
1333            ("x-forwarded-host", "admin.example.com, internal.local"),
1334        ]);
1335        assert_eq!(
1336            derive_public_site_url(&h),
1337            Some("https://admin.example.com".to_string())
1338        );
1339    }
1340
1341    #[test]
1342    fn site_url_falls_back_to_host_header_with_http() {
1343        let h = header_lookup(&[("host", "admin.example.com")]);
1344        assert_eq!(
1345            derive_public_site_url(&h),
1346            Some("http://admin.example.com".to_string())
1347        );
1348    }
1349
1350    #[test]
1351    fn site_url_returns_none_when_no_headers_resolve() {
1352        let h = header_lookup(&[]);
1353        assert_eq!(derive_public_site_url(&h), None);
1354    }
1355
1356    #[test]
1357    fn site_url_rejects_non_http_proto() {
1358        // A malicious client setting `Forwarded: proto=javascript`
1359        // must NOT poison the reset link. We refuse anything outside
1360        // {http, https} and fall through to the next source.
1361        let h = header_lookup(&[
1362            (
1363                "forwarded",
1364                "for=1.2.3.4;proto=javascript;host=evil.example.com",
1365            ),
1366            ("host", "fallback.example.com"),
1367        ]);
1368        assert_eq!(
1369            derive_public_site_url(&h),
1370            Some("http://fallback.example.com".to_string())
1371        );
1372    }
1373
1374    #[test]
1375    fn site_url_rejects_host_with_whitespace_or_control() {
1376        let h = header_lookup(&[("host", "example.com\r\nX-Injected: yes")]);
1377        assert_eq!(derive_public_site_url(&h), None);
1378    }
1379
1380    #[test]
1381    fn site_url_handles_quoted_forwarded_values() {
1382        let h = header_lookup(&[(
1383            "forwarded",
1384            "for=\"_obfuscated\";proto=\"https\";host=\"admin.example.com\"",
1385        )]);
1386        assert_eq!(
1387            derive_public_site_url(&h),
1388            Some("https://admin.example.com".to_string())
1389        );
1390    }
1391
1392    #[test]
1393    fn site_url_handles_ipv6_bracketed_host() {
1394        let h = header_lookup(&[
1395            ("x-forwarded-proto", "https"),
1396            ("x-forwarded-host", "[2001:db8::1]:8443"),
1397        ]);
1398        assert_eq!(
1399            derive_public_site_url(&h),
1400            Some("https://[2001:db8::1]:8443".to_string())
1401        );
1402    }
1403
1404    // ---- humanize_ttl ----
1405
1406    #[test]
1407    fn humanize_ttl_one_hour_default() {
1408        assert_eq!(humanize_ttl(ChronoDuration::hours(1)), "in 1 hour");
1409    }
1410
1411    #[test]
1412    fn humanize_ttl_two_hours_pluralises() {
1413        assert_eq!(humanize_ttl(ChronoDuration::hours(2)), "in 2 hours");
1414    }
1415
1416    #[test]
1417    fn humanize_ttl_minutes() {
1418        assert_eq!(humanize_ttl(ChronoDuration::minutes(30)), "in 30 minutes");
1419        assert_eq!(humanize_ttl(ChronoDuration::minutes(1)), "in 1 minute");
1420    }
1421
1422    #[test]
1423    fn humanize_ttl_seconds_for_short_windows() {
1424        assert_eq!(humanize_ttl(ChronoDuration::seconds(45)), "in 45 seconds");
1425        assert_eq!(humanize_ttl(ChronoDuration::seconds(1)), "in 1 second");
1426    }
1427
1428    // ---- purge_expired_reset_tokens ----------------------------------------
1429
1430    /// Locked retention doctrine — DESIGN_RECOVERY.md §4.4.
1431    /// Changing this constant is a behaviour change requiring a
1432    /// CHANGELOG entry under `Behaviour change`.
1433    #[test]
1434    fn reset_token_retention_window_is_seven_days() {
1435        assert_eq!(RESET_TOKEN_RETENTION_DAYS, 7);
1436    }
1437
1438    /// The DELETE statement targets the recovery table only,
1439    /// embeds the retention window as a literal `INTERVAL` (since
1440    /// sqlx can't bind interval params cleanly), and applies the
1441    /// same predicate to consumed AND unconsumed rows — no
1442    /// `consumed_at` filter on the WHERE clause. Pins the SQL
1443    /// shape so a future drift surfaces here.
1444    #[test]
1445    fn purge_query_includes_retention_window_and_table() {
1446        let query = format!(
1447            "DELETE FROM rustio_password_reset_tokens \
1448              WHERE expires_at < NOW() - INTERVAL '{RESET_TOKEN_RETENTION_DAYS} days'"
1449        );
1450        assert!(
1451            query.contains("rustio_password_reset_tokens"),
1452            "purge must target the recovery table"
1453        );
1454        assert!(
1455            query.contains("INTERVAL '7 days'"),
1456            "purge must use the locked 7-day retention window"
1457        );
1458        assert!(
1459            !query.contains("consumed_at"),
1460            "purge must apply to BOTH consumed and unconsumed expired rows; \
1461             a `consumed_at` filter would leak old consumed rows indefinitely"
1462        );
1463        // Defense-in-depth — the query is a DELETE, not a SELECT
1464        // / UPDATE. A copy-paste accident that turned this into an
1465        // UPDATE would silently leave rows in place; an accidental
1466        // SELECT would do nothing.
1467        assert!(
1468            query.starts_with("DELETE FROM"),
1469            "purge must be a DELETE statement"
1470        );
1471    }
1472
1473    #[test]
1474    fn humanize_ttl_zero_or_negative_returns_safe_string() {
1475        // Boundary: a TTL that's already in the past renders as a
1476        // grammatically safe placeholder. Never empty, never broken.
1477        assert_eq!(humanize_ttl(ChronoDuration::zero()), "very soon");
1478        assert_eq!(humanize_ttl(ChronoDuration::seconds(-30)), "very soon");
1479    }
1480
1481    // ---- IssueOutcome / ConsumeOutcome leak prevention ----
1482
1483    #[test]
1484    fn issue_outcome_debug_never_carries_plaintext_token() {
1485        // Variants are designed without a token field; this test
1486        // pins that property — a future change that adds one would
1487        // fail this. Synthetic plaintext is unlikely to collide
1488        // with the structural form-fields ("Issued", "token_id",
1489        // numbers, etc.).
1490        let synthetic = "Pwn4Ge_ZZ_token_plaintext_1234567890";
1491        for outcome in [
1492            IssueOutcome::Issued {
1493                token_id: 42,
1494                email_status: MailerEmailStatus::Sent,
1495            },
1496            IssueOutcome::Issued {
1497                token_id: 7,
1498                email_status: MailerEmailStatus::Failed,
1499            },
1500            IssueOutcome::UnknownOrInactive,
1501            IssueOutcome::RateLimited,
1502        ] {
1503            let debug = format!("{outcome:?}");
1504            assert!(
1505                !debug.contains(synthetic),
1506                "IssueOutcome Debug leaked plaintext: {debug}",
1507            );
1508        }
1509    }
1510
1511    #[test]
1512    fn consume_outcome_debug_never_carries_plaintext_token() {
1513        let synthetic = "Pwn4Ge_ZZ_token_plaintext_1234567890";
1514        for outcome in [
1515            ConsumeOutcome::Consumed {
1516                user_id: 1,
1517                revoked_session_count: 3,
1518            },
1519            ConsumeOutcome::Invalid,
1520            ConsumeOutcome::PolicyRejected(PasswordPolicyError::TooShort { min: 10, actual: 4 }),
1521            ConsumeOutcome::PolicyRejected(PasswordPolicyError::Custom("stub rejected".into())),
1522            ConsumeOutcome::RateLimited,
1523        ] {
1524            let debug = format!("{outcome:?}");
1525            assert!(
1526                !debug.contains(synthetic),
1527                "ConsumeOutcome Debug leaked plaintext: {debug}",
1528            );
1529        }
1530    }
1531
1532    #[test]
1533    fn mailer_email_status_round_trip_strings() {
1534        // Locked-in for the audit metadata field
1535        // `email_send_status` — values are 'sent' / 'failed'.
1536        assert_eq!(format!("{:?}", MailerEmailStatus::Sent), "Sent");
1537        assert_eq!(format!("{:?}", MailerEmailStatus::Failed), "Failed");
1538    }
1539
1540    #[test]
1541    fn malformed_forwarded_inputs_never_panic() {
1542        for input in &[
1543            "",
1544            "garbage",
1545            "for=",
1546            "proto=;host=",
1547            "proto=javascript:alert(1);host=evil",
1548            "host=example com",
1549            "proto=https;host=",
1550            ";;;",
1551            ",,,",
1552            "proto=https",
1553            "host=example.com",
1554            "for=\"unterminated",
1555            "=value",
1556            "key=",
1557            "key==value=",
1558        ] {
1559            let value = (*input).to_string();
1560            // The lookup returns the test input for "forwarded" and
1561            // a safe host fallback so we exercise the "fall through"
1562            // path too.
1563            let h = move |name: &str| match name {
1564                "forwarded" => Some(value.clone()),
1565                "host" => Some("fallback.example.com".to_string()),
1566                _ => None,
1567            };
1568            // Property: never panics. The result is acceptable as
1569            // long as the fall-through landed somewhere safe.
1570            let result = derive_public_site_url(h);
1571            assert!(
1572                result.is_none()
1573                    || result.as_deref() == Some("http://fallback.example.com")
1574                    || result.as_deref().map(|s| s.starts_with("https://")) == Some(true)
1575                    || result.as_deref().map(|s| s.starts_with("http://")) == Some(true),
1576                "input {input:?} produced unexpected url {result:?}"
1577            );
1578        }
1579    }
1580}