Skip to main content

umbral_auth/
challenge.rs

1//! Short-lived, single-use, hashed-at-rest secrets for the email-verification
2//! and password-reset flows. One table, discriminated by `purpose`.
3
4use crate::AuthUser;
5use crate::mailer::{OutgoingMail, active_mailer};
6use base64::Engine;
7use base64::engine::general_purpose::URL_SAFE_NO_PAD;
8use chrono::{DateTime, Utc};
9use rand::{Rng, RngCore};
10use serde::{Deserialize, Serialize};
11use std::time::Duration;
12use umbral::db::transaction;
13use umbral::orm::{F, ForeignKey};
14use umbral::templates::{context, render};
15
16/// Stored discriminator values for [`AuthChallenge::purpose`].
17pub const PURPOSE_EMAIL_VERIFY: &str = "email_verify";
18pub const PURPOSE_PASSWORD_RESET: &str = "password_reset";
19
20/// One pending challenge. The plaintext (6-digit code or opaque token) is
21/// never stored — only `base64(sha256(plaintext))`. Single-use (`used_at`),
22/// time-boxed (`expires_at`), and (for codes) attempt-capped (`attempts`).
23#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
24pub struct AuthChallenge {
25    pub id: i64,
26    #[umbral(on_delete = "cascade")]
27    pub user_id: ForeignKey<AuthUser>,
28    #[umbral(max_length = 32)]
29    pub purpose: String,
30    #[umbral(max_length = 64)]
31    pub secret_hash: String,
32    pub expires_at: DateTime<Utc>,
33    pub attempts: i32,
34    pub used_at: Option<DateTime<Utc>>,
35    pub created_at: DateTime<Utc>,
36}
37
38// =========================================================================
39// Generation helpers
40// =========================================================================
41
42/// Generate a zero-padded 6-digit verification code.
43///
44/// Returns a `String` like `"048392"`. The uniform padding ensures the
45/// string is always 6 characters regardless of the random value.
46///
47/// # Called by
48///
49/// `start_email_verification` (email verification flow).
50pub(crate) fn generate_code() -> String {
51    let n: u32 = rand::rngs::OsRng.gen_range(0..1_000_000);
52    format!("{n:06}")
53}
54
55/// Generate an opaque password-reset token.
56///
57/// Shape: `umbral_` + 43 URL-safe base64 chars (32 bytes of OS entropy,
58/// no padding). The prefix mirrors [`crate::token::TOKEN_PREFIX`] and lets
59/// log scrubbers grep for accidentally-logged tokens. The 43-char body
60/// gives 256 bits of entropy — brute-force infeasible.
61///
62/// # Called by
63///
64/// [`start_password_reset`] (password-reset flow).
65pub(crate) fn generate_reset_token() -> String {
66    let mut buf = [0u8; 32];
67    rand::rngs::OsRng.fill_bytes(&mut buf);
68    format!("umbral_{}", URL_SAFE_NO_PAD.encode(buf))
69}
70
71/// SHA-256 the plaintext and return a URL-safe base64 digest (43 chars,
72/// no padding). Delegates to [`crate::token::digest_token`] so the
73/// hashing algorithm is the same as bearer tokens — one implementation,
74/// one test surface.
75pub(crate) fn hash_secret(plaintext: &str) -> String {
76    crate::token::digest_token(plaintext)
77}
78
79// =========================================================================
80// AuthChallenge ORM methods
81// =========================================================================
82
83impl AuthChallenge {
84    /// Create and persist a new challenge for `user_id`.
85    ///
86    /// The `plaintext` (a 6-digit code or opaque token generated by the
87    /// caller) is **not** stored — only its `hash_secret` digest reaches
88    /// the database. The challenge is valid for `ttl`; `attempts` starts
89    /// at 0; `used_at` is `None`.
90    ///
91    /// # Called by
92    ///
93    /// Tasks 8 (email verify) and 9 (password reset) call this.
94    pub async fn issue(
95        user_id: i64,
96        purpose: &str,
97        plaintext: &str,
98        ttl: Duration,
99    ) -> Result<AuthChallenge, crate::AuthError> {
100        let now = Utc::now();
101        let expires_at =
102            now + chrono::Duration::from_std(ttl).unwrap_or_else(|_| chrono::Duration::minutes(15));
103        let row = AuthChallenge::objects()
104            .create(AuthChallenge {
105                id: 0, // assigned by the DB
106                user_id: ForeignKey::new(user_id),
107                purpose: purpose.to_string(),
108                secret_hash: hash_secret(plaintext),
109                expires_at,
110                attempts: 0,
111                used_at: None,
112                created_at: now,
113            })
114            .await?;
115        Ok(row)
116    }
117
118    /// `true` when the challenge has not been consumed (`used_at IS NULL`)
119    /// and has not expired (`expires_at > now`). The in-memory check
120    /// complements the SQL `USED_AT IS NULL` filter: expired-but-unused
121    /// rows are filtered out by this method even if they slipped through
122    /// (e.g. a race where `expires_at` passed between the query and the
123    /// caller).
124    pub fn is_live(&self) -> bool {
125        self.used_at.is_none() && self.expires_at > Utc::now()
126    }
127
128    /// Return the most-recently-issued active challenge for `(user_id,
129    /// purpose)`, or `None` if no unused, unexpired row exists.
130    ///
131    /// "Active" = `USED_AT IS NULL` in SQL **and** `is_live()` in Rust
132    /// (the Rust guard catches challenges whose `expires_at` passed between
133    /// the query and this call).
134    ///
135    /// # ORM note
136    ///
137    /// `auth_challenge::USED_AT.is_null()` is the SQL `IS NULL` predicate
138    /// on the nullable `used_at` column. `order_by` takes a single
139    /// `OrderExpr<T>` (not a slice) and can be called multiple times to
140    /// append clauses. Mirror the single-arg idiom from `umbral-tasks`.
141    pub async fn find_active_for_user(
142        user_id: i64,
143        purpose: &str,
144    ) -> Result<Option<AuthChallenge>, crate::AuthError> {
145        let row = AuthChallenge::objects()
146            .filter(
147                auth_challenge::USER_ID.eq(user_id)
148                    & auth_challenge::PURPOSE.eq(purpose)
149                    & auth_challenge::USED_AT.is_null(),
150            )
151            .order_by(auth_challenge::CREATED_AT.desc())
152            .first()
153            .await?;
154        // `Option::filter` keeps the row only if it passes the in-Rust
155        // liveness check (handles the expires_at race window).
156        Ok(row.filter(|c| c.is_live()))
157    }
158
159    /// Return the challenge whose stored hash matches `hash_secret(plaintext)`
160    /// for the given `purpose`, or `None` if no active match exists.
161    ///
162    /// Used by the verification handler: the handler receives the raw
163    /// plaintext (code or token), this method hashes it and looks up the
164    /// digest — the plaintext never touches the WHERE clause.
165    pub async fn find_active_by_secret(
166        plaintext: &str,
167        purpose: &str,
168    ) -> Result<Option<AuthChallenge>, crate::AuthError> {
169        let row = AuthChallenge::objects()
170            .filter(
171                auth_challenge::SECRET_HASH.eq(hash_secret(plaintext))
172                    & auth_challenge::PURPOSE.eq(purpose)
173                    & auth_challenge::USED_AT.is_null(),
174            )
175            .first()
176            .await?;
177        Ok(row.filter(|c| c.is_live()))
178    }
179
180    /// Stamp `used_at` with the current time. After this call,
181    /// `find_active_for_user` and `find_active_by_secret` will not return
182    /// this challenge. Idempotent in the DB sense (a second call just
183    /// overwrites with a slightly later timestamp), though callers should
184    /// treat the challenge as finished after the first call.
185    pub async fn mark_used(&self) -> Result<(), crate::AuthError> {
186        let mut delta = serde_json::Map::new();
187        delta.insert("used_at".to_string(), serde_json::json!(Utc::now()));
188        AuthChallenge::objects()
189            .filter(auth_challenge::ID.eq(self.id))
190            .update_values(delta)
191            .await?;
192        Ok(())
193    }
194
195    /// Increment `attempts` by 1. Call this on each failed verification
196    /// attempt so the caller can gate on a maximum before locking the
197    /// challenge. The in-memory `self.attempts` is NOT updated; re-fetch
198    /// the row to get the current count.
199    ///
200    /// The increment is an atomic server-side `SET attempts = attempts + 1`
201    /// (via `update_expr`) — two concurrent calls cannot both read the same
202    /// stale value and both write the same result, so the attempt cap used
203    /// by the brute-force guard in Task 8 cannot be under-counted.
204    pub async fn bump_attempts(&self) -> Result<(), crate::AuthError> {
205        AuthChallenge::objects()
206            .filter(auth_challenge::ID.eq(self.id))
207            .update_expr("attempts", F::col("attempts").add(1))
208            .await?;
209        Ok(())
210    }
211}
212
213// =========================================================================
214// Email-verification flow helpers (Task 8)
215// =========================================================================
216
217/// How long a verification code stays live after issue.
218const CODE_TTL: Duration = Duration::from_secs(15 * 60);
219
220/// Maximum failed attempts before the challenge is burned.
221const MAX_CODE_ATTEMPTS: i32 = 5;
222
223/// Issue a 6-digit verification code for `user`, render
224/// `auth/email/verify_code.{html,txt}`, and send via the ambient mailer.
225///
226/// On success the code is stored (hashed) in `auth_challenge` and the
227/// user receives an email with the plaintext code. The code expires in
228/// 15 minutes. Calling this multiple times issues fresh challenges
229/// (the old row stays in the table but `find_active_for_user` returns
230/// the most-recent one by `created_at DESC`).
231pub async fn start_email_verification(user: &AuthUser) -> Result<(), crate::AuthError> {
232    let code = generate_code();
233    AuthChallenge::issue(user.id, PURPOSE_EMAIL_VERIFY, &code, CODE_TTL).await?;
234    let ctx = context! { code => code.clone(), username => user.username.clone() };
235    let html = render("auth/email/verify_code.html", &ctx)
236        .map_err(|e| crate::AuthError::Template(e.to_string()))?;
237    let text = render("auth/email/verify_code.txt", &ctx)
238        .map_err(|e| crate::AuthError::Template(e.to_string()))?;
239    active_mailer()
240        .send(OutgoingMail {
241            to: user.email.clone(),
242            username: user.username.clone(),
243            kind: crate::mailer::MailKind::EmailVerification { code },
244            subject: "Verify your email".into(),
245            html,
246            text,
247        })
248        .await
249        .map_err(|e| crate::AuthError::Mail(e.to_string()))?;
250    Ok(())
251}
252
253/// Verify `email` against the submitted `code`.
254///
255/// Look up the user by email (None → `InvalidChallenge`); find their
256/// active `email_verify` challenge (None → `InvalidChallenge`); gate on
257/// the attempt cap (≥ 5 → burn + `InvalidChallenge`); compare hashes
258/// (mismatch → bump attempts + `InvalidChallenge`); on match: mark used
259/// and stamp `email_verified_at = now` on the user row.
260///
261/// All failure arms return the same opaque `InvalidChallenge` error —
262/// no account enumeration through the verification surface.
263pub async fn verify_email(email: &str, code: &str) -> Result<(), crate::AuthError> {
264    let Some(user) = AuthUser::objects()
265        .filter(crate::auth_user::EMAIL.eq(email.to_string()))
266        .first()
267        .await?
268    else {
269        return Err(crate::AuthError::InvalidChallenge);
270    };
271
272    let Some(challenge) =
273        AuthChallenge::find_active_for_user(user.id, PURPOSE_EMAIL_VERIFY).await?
274    else {
275        return Err(crate::AuthError::InvalidChallenge);
276    };
277
278    if challenge.attempts >= MAX_CODE_ATTEMPTS {
279        challenge.mark_used().await?; // burn the exhausted challenge
280        return Err(crate::AuthError::InvalidChallenge);
281    }
282
283    if hash_secret(code) != challenge.secret_hash {
284        challenge.bump_attempts().await?;
285        return Err(crate::AuthError::InvalidChallenge);
286    }
287
288    // Consume the challenge and stamp email_verified_at in one atomic
289    // transaction. Without this, a crash between the two writes would
290    // leave either (a) a still-live, replayable challenge while the
291    // user is already verified, or (b) a consumed challenge while the
292    // user is still unverified. Both are security bugs; the transaction
293    // prevents both.
294    let challenge_id = challenge.id;
295    let user_id = user.id;
296    transaction(|tx| {
297        Box::pin(async move {
298            let mut mark_delta = serde_json::Map::new();
299            mark_delta.insert("used_at".to_string(), serde_json::json!(Utc::now()));
300            AuthChallenge::objects()
301                .filter(auth_challenge::ID.eq(challenge_id))
302                .on_tx(tx)
303                .update_values(mark_delta)
304                .await?;
305
306            let mut verify_delta = serde_json::Map::new();
307            verify_delta.insert(
308                "email_verified_at".to_string(),
309                serde_json::json!(Utc::now()),
310            );
311            AuthUser::objects()
312                .filter(crate::auth_user::ID.eq(user_id))
313                .on_tx(tx)
314                .update_values(verify_delta)
315                .await?;
316
317            Ok::<_, crate::AuthError>(())
318        })
319    })
320    .await?;
321
322    Ok(())
323}
324
325// =========================================================================
326// Password-reset flow helpers (Task 9)
327// =========================================================================
328
329/// How long a password-reset token stays valid after issue.
330const RESET_TTL: Duration = Duration::from_secs(60 * 60); // 1 hour
331
332/// Issue a password-reset token for the account that owns `email`,
333/// render `auth/email/reset_link.{html,txt}` with the full reset URL,
334/// and send via the ambient mailer.
335///
336/// If no account exists for `email`, returns `Ok(())` silently — the
337/// caller gets no information about whether an account was found. This
338/// prevents account enumeration through the password-reset surface: an
339/// attacker who submits an arbitrary email address sees the same outcome
340/// as a legitimate user.
341///
342/// The token is an opaque [`generate_reset_token`] value stored
343/// (hashed) in `auth_challenge`. It expires in 1 hour.
344pub async fn start_password_reset(
345    email: &str,
346    reset_url_base: &str,
347) -> Result<(), crate::AuthError> {
348    // Silent on unknown email — never reveal whether an account exists.
349    let Some(user) = crate::AuthUser::objects()
350        .filter(crate::auth_user::EMAIL.eq(email.to_string()))
351        .first()
352        .await?
353    else {
354        return Ok(());
355    };
356    let token = generate_reset_token();
357    AuthChallenge::issue(user.id, PURPOSE_PASSWORD_RESET, &token, RESET_TTL).await?;
358    let reset_url = format!("{reset_url_base}?token={token}");
359    let ctx = context! { reset_url => reset_url.clone(), username => user.username.clone() };
360    let html = render("auth/email/reset_link.html", &ctx)
361        .map_err(|e| crate::AuthError::Template(e.to_string()))?;
362    let text = render("auth/email/reset_link.txt", &ctx)
363        .map_err(|e| crate::AuthError::Template(e.to_string()))?;
364    active_mailer()
365        .send(OutgoingMail {
366            to: user.email.clone(),
367            username: user.username.clone(),
368            kind: crate::mailer::MailKind::PasswordReset { reset_url },
369            subject: "Reset your password".into(),
370            html,
371            text,
372        })
373        .await
374        .map_err(|e| crate::AuthError::Mail(e.to_string()))?;
375    Ok(())
376}
377
378/// Consume a password-reset token and set the account's password to
379/// `new_password`.
380///
381/// # Validation
382///
383/// The candidate password must pass the ambient [`crate::PasswordPolicy`]
384/// (checked via [`crate::validate_password`]). On failure the error is
385/// [`crate::AuthError::WeakPassword`] with every rejection reason; the
386/// challenge is NOT consumed so the user can retry with a stronger password.
387///
388/// # Atomicity
389///
390/// The password-hash update and the challenge consume are written in a
391/// single transaction so a crash between the two cannot leave a window
392/// where the old password still works on an already-consumed token or,
393/// conversely, a live token against an already-updated password.
394///
395/// # Revocations (best-effort, post-commit)
396///
397/// After the transaction commits, all bearer tokens and sessions for the
398/// user are revoked ("log out everywhere"). A revocation failure does
399/// **not** roll back the password change — the password is already
400/// updated at that point, and failing the call would confuse the caller.
401/// Revocation failures are logged at ERROR level (via `tracing::error!`) so
402/// they are observable in prod without aborting the successful reset.
403///
404/// # Errors
405///
406/// Returns [`crate::AuthError::InvalidChallenge`] for ALL failure arms
407/// that involve the token (not found, expired, already used, no user) —
408/// the same opaque error prevents oracle attacks.
409pub async fn reset_password(token: &str, new_password: &str) -> Result<(), crate::AuthError> {
410    let Some(challenge) =
411        AuthChallenge::find_active_by_secret(token, PURPOSE_PASSWORD_RESET).await?
412    else {
413        return Err(crate::AuthError::InvalidChallenge);
414    };
415    // ForeignKey<AuthUser>::id() returns the i64 primary key.
416    let user_id: i64 = challenge.user_id.id();
417    let Some(user) = crate::AuthUser::objects()
418        .filter(crate::auth_user::ID.eq(user_id))
419        .first()
420        .await?
421    else {
422        // The user row was deleted after the challenge was issued.
423        return Err(crate::AuthError::InvalidChallenge);
424    };
425    // Enforce the strength policy before touching the DB.
426    crate::validate_password(
427        new_password,
428        &crate::PasswordContext::new(Some(&user.username), Some(&user.email)),
429    )
430    .map_err(crate::AuthError::WeakPassword)?;
431    let hash = crate::hash_password(new_password)?;
432
433    // Atomically: update the password hash AND consume the challenge.
434    let challenge_id = challenge.id;
435    transaction(|tx| {
436        let hash = hash.clone();
437        Box::pin(async move {
438            let mut pw_delta = serde_json::Map::new();
439            pw_delta.insert("password_hash".to_string(), serde_json::json!(hash));
440            crate::AuthUser::objects()
441                .filter(crate::auth_user::ID.eq(user_id))
442                .on_tx(tx)
443                .update_values(pw_delta)
444                .await?;
445
446            let mut mark_delta = serde_json::Map::new();
447            mark_delta.insert("used_at".to_string(), serde_json::json!(Utc::now()));
448            AuthChallenge::objects()
449                .filter(auth_challenge::ID.eq(challenge_id))
450                .on_tx(tx)
451                .update_values(mark_delta)
452                .await?;
453
454            Ok::<_, crate::AuthError>(())
455        })
456    })
457    .await?;
458
459    // Post-commit best-effort revocations. A reset implies possible account
460    // compromise; "log out everywhere" is the safe response. Failures here
461    // do NOT un-change the password — the hash is already updated. Errors are
462    // logged so a failed revocation is observable in production.
463    if let Err(e) = crate::token::AuthToken::objects()
464        .filter(crate::token::auth_token::USER_ID.eq(user_id))
465        .delete()
466        .await
467    {
468        tracing::error!(user_id, error = %e, "password reset: failed to revoke bearer tokens");
469    }
470    if let Err(e) = umbral_sessions::revoke_user_sessions(&user_id.to_string()).await {
471        tracing::error!(user_id, error = %e, "password reset: failed to revoke sessions");
472    }
473
474    Ok(())
475}
476
477// =========================================================================
478// Unit tests — keep pub(crate) helpers exercised inside the module
479// =========================================================================
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn generate_code_is_six_ascii_digits() {
487        for _ in 0..20 {
488            let code = generate_code();
489            assert_eq!(
490                code.len(),
491                6,
492                "generate_code must produce exactly 6 chars; got {code:?}"
493            );
494            assert!(
495                code.chars().all(|c| c.is_ascii_digit()),
496                "generate_code must contain only ASCII digits; got {code:?}"
497            );
498        }
499    }
500
501    #[test]
502    fn generate_code_zero_pads_small_numbers() {
503        // Deterministic proof that the {:06} format spec zero-pads small values.
504        assert_eq!(format!("{:06}", 48u32), "000048");
505        // Also confirm the invariant holds across random samples.
506        let code = generate_code();
507        assert_eq!(code.len(), 6);
508    }
509
510    #[test]
511    fn generate_reset_token_has_prefix_and_length() {
512        let tok = generate_reset_token();
513        assert!(
514            tok.starts_with("umbral_"),
515            "token must start with 'umbral_'; got {tok:?}"
516        );
517        // prefix (7) + base64(32 bytes, no padding) = 7 + 43 = 50
518        assert_eq!(
519            tok.len(),
520            50,
521            "token must be 50 chars (7 prefix + 43 base64); got {tok:?}"
522        );
523    }
524
525    #[test]
526    fn generate_reset_token_is_unique_per_call() {
527        let a = generate_reset_token();
528        let b = generate_reset_token();
529        assert_ne!(a, b, "two consecutive tokens must not collide");
530    }
531
532    #[test]
533    fn hash_secret_is_deterministic_and_delegates_to_digest_token() {
534        let a = hash_secret("483920");
535        let b = hash_secret("483920");
536        let c = hash_secret("999999");
537        assert_eq!(a, b, "hash_secret must be deterministic");
538        assert_ne!(a, c, "different inputs must produce different digests");
539        assert_eq!(
540            a.len(),
541            43,
542            "SHA-256 in URL-safe base64 (no pad) is 43 chars"
543        );
544    }
545}