Skip to main content

fraiseql_auth/totp_mfa/
mod.rs

1//! `TOTP` `MFA` (RFC 6238) — enroll, challenge, verify, unenroll.
2//!
3//! Provides a full `TOTP`-based multi-factor authentication flow:
4//!
5//! 1. **Enroll** (`POST /auth/v1/mfa/enroll`) — generates a `TOTP` secret and 8 single-use recovery
6//!    codes. Returns an `otpauth://` `URI` for the authenticator app.
7//! 2. **Challenge** (`POST /auth/v1/mfa/challenge`) — creates a short-lived challenge token after
8//!    the first authentication factor is verified.
9//! 3. **Verify** (`POST /auth/v1/mfa/verify`) — verifies a `TOTP` code or recovery code and issues
10//!    a full session token pair.
11//! 4. **Unenroll** (`POST /auth/v1/mfa/unenroll`) — removes `MFA` from an account (requires the
12//!    current `TOTP` code or a recovery code for re-authentication).
13//!
14//! # Security
15//!
16//! - `TOTP` uses `SHA-1`, 6-digit codes, and a 30-second window with `±1` step tolerance (RFC 6238
17//!   §5.2).
18//! - Recovery codes are 16 random hex characters (64 bits of entropy) and are `bcrypt`-hashed at
19//!   rest.
20//! - Challenge tokens are 32-byte random values with a 5-minute `TTL`.
21
22use std::sync::Arc;
23
24use async_trait::async_trait;
25use axum::{
26    Json,
27    extract::State,
28    http::StatusCode,
29    response::{IntoResponse, Response},
30};
31use dashmap::DashMap;
32use rand::RngCore as _;
33use serde::{Deserialize, Serialize};
34use totp_rs::{Algorithm, Secret, TOTP};
35
36use crate::{
37    audit::logger::{AuditEventType, SecretType, get_audit_logger},
38    error::{AuthError, Result},
39    session::{SessionStore, unix_now},
40};
41
42// ─── Constants ────────────────────────────────────────────────────────────────
43
44/// Number of recovery codes generated at enrollment.
45const RECOVERY_CODE_COUNT: usize = 8;
46
47/// Length of each recovery code (16 hex chars = 64-bit entropy).
48const RECOVERY_CODE_HEX_LEN: usize = 16;
49
50/// Challenge token `TTL` in seconds (5 minutes).
51const CHALLENGE_TTL_SECS: u64 = 300;
52
53/// `TOTP` tolerance: ±1 step around the current 30-second window (RFC 6238 §5.2).
54const TOTP_STEP_TOLERANCE: u8 = 1;
55
56/// `bcrypt` cost factor.
57///
58/// 12 is the recommended production minimum; lowered to 4 in tests to keep the
59/// suite fast.  This is the only deviation from the prod constant.
60#[cfg(not(test))]
61const BCRYPT_COST: u32 = 12;
62#[cfg(test)]
63const BCRYPT_COST: u32 = 4;
64
65// ─── Domain types ─────────────────────────────────────────────────────────────
66
67/// `TOTP` enrollment record for a single user.
68#[derive(Debug, Clone)]
69pub struct TotpEnrollment {
70    /// Base32-encoded `TOTP` secret.
71    pub secret_base32:        String,
72    /// `bcrypt` hashes of the 8 recovery codes.
73    pub recovery_code_hashes: Vec<String>,
74    /// Whether enrollment has been confirmed (first `TOTP` code verified).
75    pub confirmed:            bool,
76}
77
78/// Pending `MFA` challenge record.
79#[derive(Debug, Clone)]
80struct ChallengeRecord {
81    /// Which user the challenge was issued for.
82    user_id: String,
83    /// Unix timestamp when the challenge expires.
84    expires: u64,
85}
86
87// ─── MfaStore trait ───────────────────────────────────────────────────────────
88
89/// Storage backend for `TOTP` `MFA` state.
90// Reason: used as dyn Trait (Arc<dyn MfaStore>); async_trait ensures Send bounds and
91// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
92#[async_trait]
93pub trait MfaStore: Send + Sync {
94    /// Begin enrollment: generate and store a `TOTP` secret + recovery codes.
95    ///
96    /// Returns `(secret_base32, otpauth_uri, recovery_codes_plaintext)`.
97    /// The plaintext recovery codes are returned **once** and never stored; only
98    /// their `bcrypt` hashes are persisted.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`AuthError::DatabaseError`] if the store fails.
103    async fn begin_enrollment(
104        &self,
105        user_id: &str,
106        issuer: &str,
107        account_name: &str,
108    ) -> Result<EnrollmentResponse>;
109
110    /// Complete enrollment by verifying the first `TOTP` code.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`AuthError::InvalidToken`] if no pending enrollment exists or
115    /// if the code is wrong.
116    async fn confirm_enrollment(&self, user_id: &str, totp_code: &str) -> Result<()>;
117
118    /// Issue a `MFA` challenge token for the given user after first-factor auth.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`AuthError::DatabaseError`] if the store fails.
123    async fn create_challenge(&self, user_id: &str) -> Result<String>;
124
125    /// Verify a challenge token + `TOTP`/recovery code and consume the challenge.
126    ///
127    /// Returns the `user_id` on success.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`AuthError::InvalidToken`] if the challenge or code is wrong/expired.
132    async fn verify_challenge(&self, challenge_token: &str, code: &str) -> Result<String>;
133
134    /// Remove `MFA` enrollment for a user (requires valid `TOTP` or recovery code).
135    ///
136    /// # Errors
137    ///
138    /// Returns [`AuthError::InvalidToken`] if the code is wrong or `MFA` is not enrolled.
139    async fn unenroll(&self, user_id: &str, code: &str) -> Result<()>;
140
141    /// Return `true` if the user has an active (confirmed) `MFA` enrollment.
142    async fn is_enrolled(&self, user_id: &str) -> bool;
143}
144
145/// Response from [`MfaStore::begin_enrollment`].
146#[derive(Debug)]
147pub struct EnrollmentResponse {
148    /// Base32-encoded `TOTP` secret (to display in QR code).
149    pub secret_base32:  String,
150    /// `otpauth://` `URI` for authenticator apps.
151    pub otpauth_uri:    String,
152    /// Plaintext recovery codes — show to the user **once**, never stored.
153    pub recovery_codes: Vec<String>,
154}
155
156// ─── In-memory MFA store ──────────────────────────────────────────────────────
157
158/// Thread-safe in-memory `MFA` store.
159pub struct InMemoryMfaStore {
160    /// user_id → TotpEnrollment
161    enrollments: DashMap<String, TotpEnrollment>,
162    /// challenge_token → ChallengeRecord
163    challenges:  DashMap<String, ChallengeRecord>,
164}
165
166impl InMemoryMfaStore {
167    /// Create a new empty `MFA` store.
168    #[must_use]
169    pub fn new() -> Self {
170        Self {
171            enrollments: DashMap::new(),
172            challenges:  DashMap::new(),
173        }
174    }
175
176    /// Return whether the user has a pending (unconfirmed) enrollment.
177    #[must_use]
178    pub fn has_pending_enrollment(&self, user_id: &str) -> bool {
179        self.enrollments.get(user_id).is_some_and(|e| !e.confirmed)
180    }
181}
182
183impl Default for InMemoryMfaStore {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189// ─── TOTP helpers ─────────────────────────────────────────────────────────────
190
191/// Build a [`TOTP`] instance from a base32 secret string.
192///
193/// Uses `SHA-1`, 6 digits, 30-second step (RFC 6238 defaults).
194/// `issuer` and `account_name` are embedded in the `otpauth://` URI; for
195/// verification-only callers pass `None` and `""`.
196fn build_totp(secret_base32: &str, issuer: Option<&str>, account_name: &str) -> Result<TOTP> {
197    let secret_bytes =
198        Secret::Encoded(secret_base32.to_string())
199            .to_bytes()
200            .map_err(|e| AuthError::Internal {
201                message: format!("bad TOTP secret: {e}"),
202            })?;
203    TOTP::new(
204        Algorithm::SHA1,
205        6, // digits
206        TOTP_STEP_TOLERANCE,
207        30, // period (seconds)
208        secret_bytes,
209        issuer.map(str::to_string),
210        account_name.to_string(),
211    )
212    .map_err(|e| AuthError::Internal {
213        message: format!("TOTP init error: {e}"),
214    })
215}
216
217/// Verify a `TOTP` code with `±1` step tolerance.
218fn verify_totp_code(secret_base32: &str, code: &str) -> Result<bool> {
219    let totp = build_totp(secret_base32, None, "")?;
220    Ok(totp.check_current(code).unwrap_or(false))
221}
222
223/// Generate a random recovery code (`RECOVERY_CODE_HEX_LEN` lowercase hex chars).
224fn generate_recovery_code() -> String {
225    // SECURITY: rand::rng() uses OS-level entropy for recovery codes.
226    // Each byte encodes as 2 hex chars, so RECOVERY_CODE_HEX_LEN / 2 bytes.
227    let byte_count = RECOVERY_CODE_HEX_LEN / 2;
228    let mut bytes = vec![0u8; byte_count];
229    rand::rng().fill_bytes(&mut bytes);
230    bytes.iter().fold(String::new(), |mut s, b| {
231        use std::fmt::Write as _;
232        let _ = write!(s, "{b:02x}");
233        s
234    })
235}
236
237/// Generate a 32-byte random challenge token (URL-safe base64).
238fn generate_challenge_token() -> String {
239    use base64::Engine as _;
240    // SECURITY: rand::rng() uses OS-level entropy for MFA challenge tokens.
241    let mut bytes = [0u8; 32];
242    rand::rng().fill_bytes(&mut bytes);
243    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
244}
245
246/// Check a candidate code against a list of `bcrypt` hashes.
247///
248/// Returns the index of the matching hash if found.
249fn check_recovery_code(candidate: &str, hashes: &[String]) -> Option<usize> {
250    for (i, hash) in hashes.iter().enumerate() {
251        if bcrypt::verify(candidate, hash).unwrap_or(false) {
252            return Some(i);
253        }
254    }
255    None
256}
257
258// Reason: async_trait required for dyn-compatibility; remove when RTN + Send is stable
259#[async_trait]
260impl MfaStore for InMemoryMfaStore {
261    async fn begin_enrollment(
262        &self,
263        user_id: &str,
264        issuer: &str,
265        account_name: &str,
266    ) -> Result<EnrollmentResponse> {
267        // Generate a new TOTP secret.
268        let secret = Secret::generate_secret();
269        let secret_base32 = secret.to_encoded().to_string();
270
271        // Build the otpauth:// URI (issuer + account_name are embedded in the URI).
272        let totp = build_totp(&secret_base32, Some(issuer), account_name)?;
273        let otpauth_uri = totp.get_url();
274
275        // Generate 8 recovery codes and bcrypt-hash them.
276        let mut recovery_codes_plain = Vec::with_capacity(RECOVERY_CODE_COUNT);
277        let mut recovery_code_hashes = Vec::with_capacity(RECOVERY_CODE_COUNT);
278        for _ in 0..RECOVERY_CODE_COUNT {
279            let code = generate_recovery_code();
280            let hash = bcrypt::hash(&code, BCRYPT_COST).map_err(|e| AuthError::Internal {
281                message: format!("bcrypt error: {e}"),
282            })?;
283            recovery_codes_plain.push(code);
284            recovery_code_hashes.push(hash);
285        }
286
287        self.enrollments.insert(
288            user_id.to_string(),
289            TotpEnrollment {
290                secret_base32: secret_base32.clone(),
291                recovery_code_hashes,
292                confirmed: false,
293            },
294        );
295
296        Ok(EnrollmentResponse {
297            secret_base32,
298            otpauth_uri,
299            recovery_codes: recovery_codes_plain,
300        })
301    }
302
303    async fn confirm_enrollment(&self, user_id: &str, totp_code: &str) -> Result<()> {
304        let mut record =
305            self.enrollments.get_mut(user_id).ok_or_else(|| AuthError::InvalidToken {
306                reason: "no pending MFA enrollment for user".into(),
307            })?;
308
309        if !verify_totp_code(&record.secret_base32, totp_code)? {
310            return Err(AuthError::InvalidToken {
311                reason: "invalid TOTP code".into(),
312            });
313        }
314        record.confirmed = true;
315        Ok(())
316    }
317
318    async fn create_challenge(&self, user_id: &str) -> Result<String> {
319        let expires = unix_now()? + CHALLENGE_TTL_SECS;
320        let token = generate_challenge_token();
321        self.challenges.insert(
322            token.clone(),
323            ChallengeRecord {
324                user_id: user_id.to_string(),
325                expires,
326            },
327        );
328        Ok(token)
329    }
330
331    async fn verify_challenge(&self, challenge_token: &str, code: &str) -> Result<String> {
332        let now = unix_now()?;
333
334        let record =
335            self.challenges.get(challenge_token).ok_or_else(|| AuthError::InvalidToken {
336                reason: "unknown challenge token".into(),
337            })?;
338
339        if now >= record.expires {
340            drop(record);
341            self.challenges.remove(challenge_token);
342            return Err(AuthError::InvalidToken {
343                reason: "challenge token expired".into(),
344            });
345        }
346
347        let user_id = record.user_id.clone();
348        drop(record);
349
350        // Look up the user's TOTP enrollment.
351        let mut enrollment =
352            self.enrollments.get_mut(&user_id).ok_or_else(|| AuthError::InvalidToken {
353                reason: "user has no MFA enrollment".into(),
354            })?;
355
356        if !enrollment.confirmed {
357            return Err(AuthError::InvalidToken {
358                reason: "MFA enrollment not confirmed".into(),
359            });
360        }
361
362        // Try TOTP first, then recovery codes.
363        if verify_totp_code(&enrollment.secret_base32, code)? {
364            drop(enrollment);
365            self.challenges.remove(challenge_token);
366            return Ok(user_id);
367        }
368
369        // Try recovery codes (bcrypt, slow — intentional).
370        let idx = check_recovery_code(code, &enrollment.recovery_code_hashes);
371        if let Some(i) = idx {
372            // Consume (remove) the used recovery code.
373            enrollment.recovery_code_hashes.remove(i);
374            drop(enrollment);
375            self.challenges.remove(challenge_token);
376            return Ok(user_id);
377        }
378
379        Err(AuthError::InvalidToken {
380            reason: "invalid TOTP or recovery code".into(),
381        })
382    }
383
384    async fn unenroll(&self, user_id: &str, code: &str) -> Result<()> {
385        let enrollment = self.enrollments.get(user_id).ok_or_else(|| AuthError::InvalidToken {
386            reason: "user has no MFA enrollment".into(),
387        })?;
388
389        if !enrollment.confirmed {
390            return Err(AuthError::InvalidToken {
391                reason: "MFA enrollment not confirmed".into(),
392            });
393        }
394
395        // Re-authenticate: accept TOTP or a recovery code.
396        let totp_ok = verify_totp_code(&enrollment.secret_base32, code)?;
397        let recovery_ok =
398            !totp_ok && check_recovery_code(code, &enrollment.recovery_code_hashes).is_some();
399
400        if !totp_ok && !recovery_ok {
401            return Err(AuthError::InvalidToken {
402                reason: "re-authentication failed — invalid TOTP or recovery code".into(),
403            });
404        }
405
406        drop(enrollment);
407        self.enrollments.remove(user_id);
408        Ok(())
409    }
410
411    async fn is_enrolled(&self, user_id: &str) -> bool {
412        self.enrollments.get(user_id).map_or(false, |e| e.confirmed)
413    }
414}
415
416// ─── Route state ─────────────────────────────────────────────────────────────
417
418/// Axum route state for `MFA` endpoints.
419#[derive(Clone)]
420pub struct MfaRouteState {
421    /// `MFA` storage backend.
422    pub mfa_store:     Arc<dyn MfaStore>,
423    /// Session store (to issue full sessions after `MFA` verification).
424    pub session_store: Arc<dyn SessionStore>,
425    /// Service / issuer name shown in authenticator apps.
426    pub issuer:        String,
427}
428
429// ─── Request / Response types ─────────────────────────────────────────────────
430
431/// Request for `POST /auth/v1/mfa/enroll`.
432#[derive(Debug, Deserialize)]
433pub struct MfaEnrollRequest {
434    /// Authenticated user identifier.
435    pub user_id:      String,
436    /// Display name shown in the authenticator app.
437    pub account_name: String,
438}
439
440/// Response for `POST /auth/v1/mfa/enroll`.
441#[derive(Debug, Serialize)]
442pub struct MfaEnrollResponse {
443    /// `otpauth://` `URI` — encode as a `QR` code for the authenticator app.
444    pub otpauth_uri:    String,
445    /// 8 single-use recovery codes (shown **once**, store securely).
446    pub recovery_codes: Vec<String>,
447}
448
449/// Request for `POST /auth/v1/mfa/challenge`.
450#[derive(Debug, Deserialize)]
451pub struct MfaChallengeRequest {
452    /// User whose `MFA` challenge to initiate.
453    pub user_id: String,
454}
455
456/// Response for `POST /auth/v1/mfa/challenge`.
457#[derive(Debug, Serialize)]
458pub struct MfaChallengeResponse {
459    /// Short-lived challenge token (5 minutes).
460    pub challenge_token: String,
461}
462
463/// Request for `POST /auth/v1/mfa/verify`.
464#[derive(Debug, Deserialize)]
465pub struct MfaVerifyRequest {
466    /// Challenge token from the `/challenge` step.
467    pub challenge_token: String,
468    /// 6-digit `TOTP` code or one of the 8-digit recovery codes.
469    pub code:            String,
470}
471
472/// Request for `POST /auth/v1/mfa/unenroll`.
473#[derive(Debug, Deserialize)]
474pub struct MfaUnenrollRequest {
475    /// User to unenroll.
476    pub user_id: String,
477    /// Current `TOTP` code or a recovery code (re-authentication).
478    pub code:    String,
479}
480
481// ─── Handlers ─────────────────────────────────────────────────────────────────
482
483/// `POST /auth/v1/mfa/enroll`
484///
485/// Generates a `TOTP` secret and recovery codes for the given user.
486///
487/// # Errors
488///
489/// Returns 500 if the `MFA` store fails.
490pub async fn mfa_enroll(
491    State(state): State<Arc<MfaRouteState>>,
492    Json(req): Json<MfaEnrollRequest>,
493) -> Response {
494    match state
495        .mfa_store
496        .begin_enrollment(&req.user_id, &state.issuer, &req.account_name)
497        .await
498    {
499        Ok(resp) => (
500            StatusCode::OK,
501            Json(MfaEnrollResponse {
502                otpauth_uri:    resp.otpauth_uri,
503                recovery_codes: resp.recovery_codes,
504            }),
505        )
506            .into_response(),
507        Err(e) => {
508            tracing::error!(error = %e, "MFA enroll error");
509            (StatusCode::INTERNAL_SERVER_ERROR, "enrollment failed").into_response()
510        },
511    }
512}
513
514/// `POST /auth/v1/mfa/challenge`
515///
516/// Initiates a `MFA` challenge for the given user (called after first-factor auth).
517///
518/// # Errors
519///
520/// Returns 404 if the user has no confirmed `MFA` enrollment.
521pub async fn mfa_challenge(
522    State(state): State<Arc<MfaRouteState>>,
523    Json(req): Json<MfaChallengeRequest>,
524) -> Response {
525    if !state.mfa_store.is_enrolled(&req.user_id).await {
526        return (
527            StatusCode::NOT_FOUND,
528            Json(serde_json::json!({
529                "error": "not_enrolled",
530                "message": "user has no active MFA enrollment"
531            })),
532        )
533            .into_response();
534    }
535
536    match state.mfa_store.create_challenge(&req.user_id).await {
537        Ok(token) => (
538            StatusCode::OK,
539            Json(MfaChallengeResponse {
540                challenge_token: token,
541            }),
542        )
543            .into_response(),
544        Err(e) => {
545            tracing::error!(error = %e, "MFA challenge error");
546            (StatusCode::INTERNAL_SERVER_ERROR, "challenge creation failed").into_response()
547        },
548    }
549}
550
551/// `POST /auth/v1/mfa/verify`
552///
553/// Verifies the `TOTP` code or recovery code and issues a full session token pair.
554///
555/// # Errors
556///
557/// Returns 422 if the code is wrong or the challenge is expired.
558pub async fn mfa_verify(
559    State(state): State<Arc<MfaRouteState>>,
560    Json(req): Json<MfaVerifyRequest>,
561) -> Response {
562    let logger = get_audit_logger();
563
564    let user_id = match state.mfa_store.verify_challenge(&req.challenge_token, &req.code).await {
565        Ok(uid) => uid,
566        Err(e) => {
567            logger.log_failure(
568                AuditEventType::AuthFailure,
569                SecretType::SessionToken,
570                None,
571                "mfa_verify",
572                &e.to_string(),
573            );
574            return (
575                StatusCode::UNPROCESSABLE_ENTITY,
576                Json(serde_json::json!({
577                    "error":   "invalid_mfa",
578                    "message": "invalid or expired MFA code"
579                })),
580            )
581                .into_response();
582        },
583    };
584
585    let expires_at = match unix_now() {
586        Ok(now) => now + 3_600, // 1-hour session
587        Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(),
588    };
589
590    let tokens = match state.session_store.create_session(&user_id, expires_at).await {
591        Ok(t) => t,
592        Err(e) => {
593            tracing::error!(error = %e, "Session creation failed after MFA verify");
594            return (StatusCode::INTERNAL_SERVER_ERROR, "session creation failed").into_response();
595        },
596    };
597
598    logger.log_success(
599        AuditEventType::AuthSuccess,
600        SecretType::SessionToken,
601        Some(user_id),
602        "mfa_verify",
603    );
604
605    (StatusCode::OK, Json(tokens)).into_response()
606}
607
608/// `POST /auth/v1/mfa/unenroll`
609///
610/// Removes `MFA` from the account after re-authentication.
611///
612/// # Errors
613///
614/// Returns 422 if re-authentication fails.
615pub async fn mfa_unenroll(
616    State(state): State<Arc<MfaRouteState>>,
617    Json(req): Json<MfaUnenrollRequest>,
618) -> Response {
619    let logger = get_audit_logger();
620
621    match state.mfa_store.unenroll(&req.user_id, &req.code).await {
622        Ok(()) => {
623            logger.log_success(
624                AuditEventType::SessionTokenRevoked,
625                SecretType::SessionToken,
626                Some(req.user_id),
627                "mfa_unenroll",
628            );
629            StatusCode::OK.into_response()
630        },
631        Err(e) => {
632            logger.log_failure(
633                AuditEventType::AuthFailure,
634                SecretType::SessionToken,
635                Some(req.user_id.clone()),
636                "mfa_unenroll",
637                &e.to_string(),
638            );
639            (
640                StatusCode::UNPROCESSABLE_ENTITY,
641                Json(serde_json::json!({
642                    "error":   "invalid_code",
643                    "message": "re-authentication failed"
644                })),
645            )
646                .into_response()
647        },
648    }
649}
650
651// ─── Tests ────────────────────────────────────────────────────────────────────
652
653#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
654#[cfg(test)]
655mod tests;