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}