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(crate::normalize_email(email)))
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(crate::normalize_email(email)))
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.
409/// Change an authenticated user's password: verify `current`, enforce the
410/// strength policy on `new`, then rotate the hash. Backs the
411/// `POST {prefix}/change-password` route (gaps3 #20).
412///
413/// Returns [`crate::AuthError::InvalidCredentials`] when `current` doesn't
414/// match, and [`crate::AuthError::WeakPassword`] when `new` fails the policy.
415/// Unlike [`reset_password`], this does NOT revoke sessions/tokens — the user
416/// proved knowledge of the current password, so no compromise is implied.
417pub async fn change_password(
418 user: &crate::AuthUser,
419 current: &str,
420 new: &str,
421) -> Result<(), crate::AuthError> {
422 // 1. The caller must know the current password.
423 if !crate::verify_password_async(current, &user.password_hash).await? {
424 return Err(crate::AuthError::InvalidCredentials);
425 }
426 // 2. The new password must pass the strength policy (the same
427 // `validate_password` the register/reset boundaries use).
428 crate::validate_password(
429 new,
430 &crate::PasswordContext::new(Some(&user.username), Some(&user.email)),
431 )
432 .map_err(crate::AuthError::WeakPassword)?;
433 // 3. Rotate the hash.
434 let hash = crate::hash_password(new)?;
435 let mut delta = serde_json::Map::new();
436 delta.insert("password_hash".to_string(), serde_json::json!(hash));
437 crate::AuthUser::objects()
438 .filter(crate::auth_user::ID.eq(user.id))
439 .update_values(delta)
440 .await?;
441 Ok(())
442}
443
444pub async fn reset_password(token: &str, new_password: &str) -> Result<(), crate::AuthError> {
445 let Some(challenge) =
446 AuthChallenge::find_active_by_secret(token, PURPOSE_PASSWORD_RESET).await?
447 else {
448 return Err(crate::AuthError::InvalidChallenge);
449 };
450 // ForeignKey<AuthUser>::id() returns the i64 primary key.
451 let user_id: i64 = challenge.user_id.id();
452 let Some(user) = crate::AuthUser::objects()
453 .filter(crate::auth_user::ID.eq(user_id))
454 .first()
455 .await?
456 else {
457 // The user row was deleted after the challenge was issued.
458 return Err(crate::AuthError::InvalidChallenge);
459 };
460 // Enforce the strength policy before touching the DB.
461 crate::validate_password(
462 new_password,
463 &crate::PasswordContext::new(Some(&user.username), Some(&user.email)),
464 )
465 .map_err(crate::AuthError::WeakPassword)?;
466 let hash = crate::hash_password(new_password)?;
467
468 // Atomically: update the password hash AND consume the challenge.
469 let challenge_id = challenge.id;
470 transaction(|tx| {
471 let hash = hash.clone();
472 Box::pin(async move {
473 let mut pw_delta = serde_json::Map::new();
474 pw_delta.insert("password_hash".to_string(), serde_json::json!(hash));
475 crate::AuthUser::objects()
476 .filter(crate::auth_user::ID.eq(user_id))
477 .on_tx(tx)
478 .update_values(pw_delta)
479 .await?;
480
481 let mut mark_delta = serde_json::Map::new();
482 mark_delta.insert("used_at".to_string(), serde_json::json!(Utc::now()));
483 AuthChallenge::objects()
484 .filter(auth_challenge::ID.eq(challenge_id))
485 .on_tx(tx)
486 .update_values(mark_delta)
487 .await?;
488
489 Ok::<_, crate::AuthError>(())
490 })
491 })
492 .await?;
493
494 // Post-commit best-effort revocations. A reset implies possible account
495 // compromise; "log out everywhere" is the safe response. Failures here
496 // do NOT un-change the password — the hash is already updated. Errors are
497 // logged so a failed revocation is observable in production.
498 if let Err(e) = crate::token::AuthToken::objects()
499 .filter(crate::token::auth_token::USER_ID.eq(user_id))
500 .delete()
501 .await
502 {
503 tracing::error!(user_id, error = %e, "password reset: failed to revoke bearer tokens");
504 }
505 if let Err(e) = umbral_sessions::revoke_user_sessions(&user_id.to_string()).await {
506 tracing::error!(user_id, error = %e, "password reset: failed to revoke sessions");
507 }
508
509 Ok(())
510}
511
512// =========================================================================
513// Unit tests — keep pub(crate) helpers exercised inside the module
514// =========================================================================
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn generate_code_is_six_ascii_digits() {
522 for _ in 0..20 {
523 let code = generate_code();
524 assert_eq!(
525 code.len(),
526 6,
527 "generate_code must produce exactly 6 chars; got {code:?}"
528 );
529 assert!(
530 code.chars().all(|c| c.is_ascii_digit()),
531 "generate_code must contain only ASCII digits; got {code:?}"
532 );
533 }
534 }
535
536 #[test]
537 fn generate_code_zero_pads_small_numbers() {
538 // Deterministic proof that the {:06} format spec zero-pads small values.
539 assert_eq!(format!("{:06}", 48u32), "000048");
540 // Also confirm the invariant holds across random samples.
541 let code = generate_code();
542 assert_eq!(code.len(), 6);
543 }
544
545 #[test]
546 fn generate_reset_token_has_prefix_and_length() {
547 let tok = generate_reset_token();
548 assert!(
549 tok.starts_with("umbral_"),
550 "token must start with 'umbral_'; got {tok:?}"
551 );
552 // prefix (7) + base64(32 bytes, no padding) = 7 + 43 = 50
553 assert_eq!(
554 tok.len(),
555 50,
556 "token must be 50 chars (7 prefix + 43 base64); got {tok:?}"
557 );
558 }
559
560 #[test]
561 fn generate_reset_token_is_unique_per_call() {
562 let a = generate_reset_token();
563 let b = generate_reset_token();
564 assert_ne!(a, b, "two consecutive tokens must not collide");
565 }
566
567 #[test]
568 fn hash_secret_is_deterministic_and_delegates_to_digest_token() {
569 let a = hash_secret("483920");
570 let b = hash_secret("483920");
571 let c = hash_secret("999999");
572 assert_eq!(a, b, "hash_secret must be deterministic");
573 assert_ne!(a, c, "different inputs must produce different digests");
574 assert_eq!(
575 a.len(),
576 43,
577 "SHA-256 in URL-safe base64 (no pad) is 43 chars"
578 );
579 }
580}