Skip to main content

allowthem_server/
mfa_page_routes.rs

1use std::sync::Arc;
2
3use axum::Extension;
4use axum::Form;
5use axum::Router;
6use axum::extract::{Query, State};
7use axum::http::HeaderMap;
8use axum::http::StatusCode;
9use axum::http::Uri;
10use axum::http::header::{LOCATION, SET_COOKIE, USER_AGENT};
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use chrono::Utc;
14use minijinja::{Environment, context};
15use serde::Deserialize;
16
17use allowthem_core::totp::totp_uri;
18use allowthem_core::{AllowThem, AuditEvent, AuthError, sessions};
19
20use crate::browser_error::BrowserError;
21use crate::csrf::CsrfToken;
22use crate::error::BrowserAuthRedirect;
23
24/// Error shown when a wrong TOTP code is entered during MFA setup confirmation.
25const SETUP_INVALID_CODE: &str = "Invalid TOTP code";
26
27/// Error shown when a wrong TOTP code is entered on the MFA challenge page.
28const CHALLENGE_INVALID_TOTP: &str = "Invalid TOTP or recovery code";
29
30/// Error shown when a wrong recovery code is entered on the MFA challenge page.
31const CHALLENGE_INVALID_RECOVERY: &str = "Invalid recovery code";
32
33#[derive(Clone)]
34struct MfaPageConfig {
35    templates: Arc<Environment<'static>>,
36    is_production: bool,
37    base_url: String,
38}
39
40// ---------------------------------------------------------------------------
41// Helpers
42// ---------------------------------------------------------------------------
43
44fn client_ip(headers: &HeaderMap) -> Option<String> {
45    headers
46        .get("x-forwarded-for")
47        .and_then(|v| v.to_str().ok())
48        .and_then(|s| s.split(',').next())
49        .map(|s| s.trim().to_string())
50}
51
52/// Extract the host from a base URL for use as the TOTP issuer.
53///
54/// Strips the scheme and path, and also strips the port (the totp-rs
55/// library rejects issuer strings containing colons).
56fn derive_issuer(base_url: &str) -> String {
57    base_url
58        .trim_start_matches("https://")
59        .trim_start_matches("http://")
60        .split('/')
61        .next()
62        .unwrap_or("allowthem")
63        .split(':')
64        .next()
65        .unwrap_or("allowthem")
66        .to_string()
67}
68
69/// Validate session cookie and return the authenticated user.
70///
71/// On failure, returns a 303 redirect to `/login?next={path}` — matching
72/// `BrowserAuthUser` rejection semantics without requiring `Arc<dyn AuthClient>`
73/// in the router state.
74async fn require_browser_user(
75    ath: &AllowThem,
76    headers: &HeaderMap,
77    path: &str,
78) -> Result<allowthem_core::types::User, Response> {
79    let cookie_header = headers
80        .get(axum::http::header::COOKIE)
81        .and_then(|v| v.to_str().ok())
82        .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
83
84    let token = ath
85        .parse_session_cookie(cookie_header)
86        .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
87
88    let ttl = ath.session_config().ttl;
89    let session = ath
90        .db()
91        .validate_session(&token, ttl)
92        .await
93        .map_err(|err| {
94            tracing::error!("session validation error: {err}");
95            BrowserAuthRedirect::new(path).into_response()
96        })?
97        .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
98
99    match ath.db().get_user(session.user_id).await {
100        Ok(user) if user.is_active => Ok(user),
101        Ok(_) => Err(BrowserAuthRedirect::new(path).into_response()),
102        Err(AuthError::NotFound) => Err(BrowserAuthRedirect::new(path).into_response()),
103        Err(err) => {
104            tracing::error!("user lookup error: {err}");
105            Err(BrowserAuthRedirect::new(path).into_response())
106        }
107    }
108}
109
110// ---------------------------------------------------------------------------
111// Setup-side routes (authenticated, CSRF-protected)
112// ---------------------------------------------------------------------------
113
114/// GET /settings/mfa/setup — show QR URI, base32 secret, and TOTP code input.
115///
116/// Idempotent: if a pending (non-enabled) secret exists, reuses it.
117/// Only creates a new secret on first visit.
118async fn get_mfa_setup(
119    State(ath): State<AllowThem>,
120    Extension(config): Extension<MfaPageConfig>,
121    uri: Uri,
122    csrf: CsrfToken,
123    headers: HeaderMap,
124) -> Result<Response, BrowserError> {
125    let user = match require_browser_user(&ath, &headers, uri.path()).await {
126        Ok(u) => u,
127        Err(redirect) => return Ok(redirect),
128    };
129
130    // Reuse pending secret if one exists; create only on first visit
131    let secret = match ath.get_pending_mfa_secret(user.id).await? {
132        Some(s) => s,
133        None => ath.create_mfa_secret(user.id).await?,
134    };
135
136    let issuer = derive_issuer(&config.base_url);
137    let uri = totp_uri(&secret, user.email.as_str(), &issuer);
138
139    let html = crate::browser_templates::render(
140        &config.templates,
141        "mfa_setup.html",
142        context! {
143            csrf_token => csrf.as_str(),
144            secret => &secret,
145            totp_uri => &uri,
146            error => "",
147            is_production => config.is_production,
148        },
149    )?;
150    Ok(html.into_response())
151}
152
153#[derive(Deserialize)]
154pub struct MfaConfirmForm {
155    code: String,
156    #[allow(dead_code)]
157    csrf_token: String,
158}
159
160/// POST /settings/mfa/confirm — verify TOTP code and enable MFA.
161///
162/// On success, renders recovery codes page directly (no redirect).
163/// On failure, re-renders setup page with error.
164async fn post_mfa_confirm(
165    State(ath): State<AllowThem>,
166    Extension(config): Extension<MfaPageConfig>,
167    uri: Uri,
168    csrf: CsrfToken,
169    headers: HeaderMap,
170    Form(form): Form<MfaConfirmForm>,
171) -> Result<Response, BrowserError> {
172    let user = match require_browser_user(&ath, &headers, uri.path()).await {
173        Ok(u) => u,
174        Err(redirect) => return Ok(redirect),
175    };
176
177    let ip = client_ip(&headers);
178    let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
179
180    match ath.enable_mfa(user.id, &form.code).await {
181        Ok(recovery_codes) => {
182            let _ = ath
183                .db()
184                .log_audit(
185                    AuditEvent::MfaEnabled,
186                    Some(&user.id),
187                    None,
188                    ip.as_deref(),
189                    ua,
190                    None,
191                )
192                .await;
193
194            let html = crate::browser_templates::render(
195                &config.templates,
196                "mfa_recovery.html",
197                context! {
198                    recovery_codes => &recovery_codes,
199                    is_production => config.is_production,
200                },
201            )?;
202            Ok(html.into_response())
203        }
204        Err(allowthem_core::AuthError::InvalidTotpCode) => {
205            // Re-render setup page with error
206            let secret = ath
207                .get_pending_mfa_secret(user.id)
208                .await?
209                .unwrap_or_default();
210            let issuer = derive_issuer(&config.base_url);
211            let uri = totp_uri(&secret, user.email.as_str(), &issuer);
212
213            let html = crate::browser_templates::render(
214                &config.templates,
215                "mfa_setup.html",
216                context! {
217                    csrf_token => csrf.as_str(),
218                    secret => &secret,
219                    totp_uri => &uri,
220                    error => SETUP_INVALID_CODE,
221                    is_production => config.is_production,
222                },
223            )?;
224            Ok(html.into_response())
225        }
226        Err(e) => Err(BrowserError::Auth(e)),
227    }
228}
229
230#[derive(Deserialize)]
231pub struct MfaDisableForm {
232    #[allow(dead_code)]
233    csrf_token: String,
234}
235
236/// POST /settings/mfa/disable — disable MFA and redirect to settings.
237async fn post_mfa_disable(
238    State(ath): State<AllowThem>,
239    uri: Uri,
240    headers: HeaderMap,
241    Form(_form): Form<MfaDisableForm>,
242) -> Result<Response, BrowserError> {
243    let user = match require_browser_user(&ath, &headers, uri.path()).await {
244        Ok(u) => u,
245        Err(redirect) => return Ok(redirect),
246    };
247
248    let ip = client_ip(&headers);
249    let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
250
251    ath.disable_mfa(user.id).await?;
252
253    let _ = ath
254        .db()
255        .log_audit(
256            AuditEvent::MfaDisabled,
257            Some(&user.id),
258            None,
259            ip.as_deref(),
260            ua,
261            None,
262        )
263        .await;
264
265    Ok((StatusCode::SEE_OTHER, [(LOCATION, "/settings".to_string())]).into_response())
266}
267
268// ---------------------------------------------------------------------------
269// Challenge routes (mid-login, no session — outside CSRF layer)
270// ---------------------------------------------------------------------------
271
272#[derive(Deserialize)]
273pub struct ChallengeQuery {
274    token: String,
275}
276
277/// GET /mfa/challenge — render TOTP code input form.
278async fn get_mfa_challenge(
279    State(ath): State<AllowThem>,
280    Extension(config): Extension<MfaPageConfig>,
281    Query(query): Query<ChallengeQuery>,
282) -> Result<Response, BrowserError> {
283    // Validate token is still alive (don't consume it)
284    let user_id = ath.db().validate_mfa_challenge(&query.token).await?;
285    if user_id.is_none() {
286        // Invalid or expired token — redirect to login
287        return Ok((StatusCode::SEE_OTHER, [(LOCATION, "/login".to_string())]).into_response());
288    }
289
290    let html = crate::browser_templates::render(
291        &config.templates,
292        "mfa_challenge.html",
293        context! {
294            mfa_token => &query.token,
295            error => "",
296            is_production => config.is_production,
297        },
298    )?;
299    Ok(html.into_response())
300}
301
302#[derive(Deserialize)]
303pub struct MfaChallengeForm {
304    mfa_token: String,
305    #[serde(default)]
306    code: Option<String>,
307    #[serde(default)]
308    recovery_code: Option<String>,
309    #[serde(default)]
310    use_recovery: Option<String>,
311}
312
313/// POST /mfa/challenge — verify TOTP code or recovery code, create session.
314async fn post_mfa_challenge(
315    State(ath): State<AllowThem>,
316    Extension(config): Extension<MfaPageConfig>,
317    headers: HeaderMap,
318    Form(form): Form<MfaChallengeForm>,
319) -> Result<Response, BrowserError> {
320    let ip = headers
321        .get("x-forwarded-for")
322        .and_then(|v| v.to_str().ok())
323        .and_then(|s| s.split(',').next())
324        .map(|s| s.trim().to_string());
325    let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
326
327    // 1. Validate challenge token
328    let user_id = match ath.db().validate_mfa_challenge(&form.mfa_token).await? {
329        Some(uid) => uid,
330        None => {
331            return Ok((StatusCode::SEE_OTHER, [(LOCATION, "/login".to_string())]).into_response());
332        }
333    };
334
335    // 2. Branch: recovery code vs TOTP
336    let use_recovery = form.use_recovery.is_some();
337    let verified = if use_recovery {
338        let code = form.recovery_code.as_deref().unwrap_or("");
339        ath.verify_recovery_code(user_id, code).await?
340    } else {
341        let code = form.code.as_deref().unwrap_or("");
342        ath.verify_totp(user_id, code).await?
343    };
344
345    if !verified {
346        // Log failure
347        let _ = ath
348            .db()
349            .log_audit(
350                AuditEvent::MfaChallengeFailed,
351                Some(&user_id),
352                None,
353                ip.as_deref(),
354                ua,
355                None,
356            )
357            .await;
358
359        let error_msg = if use_recovery {
360            CHALLENGE_INVALID_RECOVERY
361        } else {
362            CHALLENGE_INVALID_TOTP
363        };
364
365        let html = crate::browser_templates::render(
366            &config.templates,
367            "mfa_challenge.html",
368            context! {
369                mfa_token => &form.mfa_token,
370                error => error_msg,
371                is_production => config.is_production,
372            },
373        )?;
374        return Ok(html.into_response());
375    }
376
377    // 3. Success: consume challenge, create session
378    ath.db().consume_mfa_challenge(&form.mfa_token).await?;
379
380    let _ = ath
381        .db()
382        .log_audit(
383            AuditEvent::MfaChallengeSuccess,
384            Some(&user_id),
385            None,
386            ip.as_deref(),
387            ua,
388            None,
389        )
390        .await;
391
392    // Emit Login to maintain the invariant that every session creation
393    // produces a Login audit event, consistent with the non-MFA login path.
394    let _ = ath
395        .db()
396        .log_audit(
397            AuditEvent::Login,
398            Some(&user_id),
399            None,
400            ip.as_deref(),
401            ua,
402            None,
403        )
404        .await;
405
406    let token = sessions::generate_token();
407    let token_hash = sessions::hash_token(&token);
408    let ttl = ath.session_config().ttl;
409    let expires_at = Utc::now() + ttl;
410    ath.db()
411        .create_session(user_id, token_hash, ip.as_deref(), ua, expires_at)
412        .await?;
413
414    let cookie = ath.session_cookie(&token);
415
416    Ok((
417        StatusCode::SEE_OTHER,
418        [(SET_COOKIE, cookie), (LOCATION, "/".to_string())],
419    )
420        .into_response())
421}
422
423// ---------------------------------------------------------------------------
424// Public router constructors
425// ---------------------------------------------------------------------------
426
427/// Build a router for MFA setup routes (authenticated, CSRF-protected).
428///
429/// Mounts:
430/// - GET  /settings/mfa/setup
431/// - POST /settings/mfa/confirm
432/// - POST /settings/mfa/disable
433pub fn mfa_setup_routes(
434    templates: Arc<Environment<'static>>,
435    is_production: bool,
436    base_url: String,
437) -> Router<AllowThem> {
438    let cfg = MfaPageConfig {
439        templates,
440        is_production,
441        base_url,
442    };
443    Router::new()
444        .route("/settings/mfa/setup", get(get_mfa_setup))
445        .route("/settings/mfa/confirm", post(post_mfa_confirm))
446        .route("/settings/mfa/disable", post(post_mfa_disable))
447        .layer(Extension(cfg))
448}
449
450/// Build a router for the MFA challenge route (mid-login, no session).
451///
452/// Mounts:
453/// - GET  /mfa/challenge
454/// - POST /mfa/challenge
455pub fn mfa_challenge_routes(
456    templates: Arc<Environment<'static>>,
457    is_production: bool,
458) -> Router<AllowThem> {
459    let cfg = MfaPageConfig {
460        templates,
461        is_production,
462        base_url: String::new(),
463    };
464    Router::new()
465        .route(
466            "/mfa/challenge",
467            get(get_mfa_challenge).post(post_mfa_challenge),
468        )
469        .layer(Extension(cfg))
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    use axum::body::Body;
477    use axum::http::{Request, StatusCode, header};
478    use chrono::{Duration, Utc};
479    use totp_rs::{Algorithm, Secret, TOTP};
480    use tower::ServiceExt;
481
482    use allowthem_core::{AllowThemBuilder, Email, generate_token, hash_token};
483
484    const TEST_MFA_KEY: [u8; 32] = [0x42; 32];
485
486    // ---------------------------------------------------------------------------
487    // Helpers
488    // ---------------------------------------------------------------------------
489
490    async fn setup() -> AllowThem {
491        AllowThemBuilder::new("sqlite::memory:")
492            .cookie_secure(false)
493            .mfa_key(TEST_MFA_KEY)
494            .csrf_key(*b"test-csrf-key-for-binary-tests!!")
495            .build()
496            .await
497            .unwrap()
498    }
499
500    /// Build a router that exercises only the MFA routes (no login).
501    /// Setup-side routes are CSRF-protected; challenge routes are not.
502    fn test_app(ath: AllowThem) -> Router {
503        let templates = crate::browser_templates::build_default_browser_env();
504        Router::new()
505            .merge(mfa_setup_routes(
506                templates.clone(),
507                false,
508                "http://127.0.0.1:3100".into(),
509            ))
510            .layer(axum::middleware::from_fn_with_state(
511                ath.clone(),
512                crate::csrf::csrf_middleware,
513            ))
514            .merge(mfa_challenge_routes(templates, false))
515            .with_state(ath)
516    }
517
518    async fn create_session(ath: &AllowThem) -> (allowthem_core::types::UserId, String) {
519        let email = Email::new("mfa-test@example.com".into()).unwrap();
520        let user = ath
521            .db()
522            .create_user(email, "pass", None, None)
523            .await
524            .unwrap();
525        let token = generate_token();
526        let token_hash = hash_token(&token);
527        let expires = Utc::now() + Duration::hours(24);
528        ath.db()
529            .create_session(user.id, token_hash, None, None, expires)
530            .await
531            .unwrap();
532        let cookie = ath.session_cookie(&token);
533        let cookie_val = cookie.split(';').next().unwrap().to_string();
534        (user.id, cookie_val)
535    }
536
537    /// Acquire a CSRF token by hitting the setup GET endpoint and parsing it from HTML.
538    async fn get_csrf(app: &Router, session_cookie: &str) -> String {
539        let req = Request::builder()
540            .uri("/settings/mfa/setup")
541            .header(header::COOKIE, session_cookie)
542            .body(Body::empty())
543            .unwrap();
544        let resp = app.clone().oneshot(req).await.unwrap();
545        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
546            .await
547            .unwrap();
548        let html = String::from_utf8(bytes.to_vec()).unwrap();
549        let marker = "name=\"csrf_token\" value=\"";
550        let start = html.find(marker).expect("csrf_token not found in HTML") + marker.len();
551        let end = html[start..].find('"').unwrap() + start;
552        html[start..end].to_string()
553    }
554
555    /// Create a user with MFA enabled. Returns (totp, recovery_codes).
556    async fn enable_mfa_for_user(
557        ath: &AllowThem,
558        user_id: allowthem_core::types::UserId,
559    ) -> (TOTP, Vec<String>) {
560        let secret_b32 = ath.create_mfa_secret(user_id).await.unwrap();
561        let totp = TOTP::new(
562            Algorithm::SHA1,
563            6,
564            1,
565            30,
566            Secret::Encoded(secret_b32).to_bytes().unwrap(),
567            None,
568            String::new(),
569        )
570        .unwrap();
571        let code = totp.generate_current().unwrap();
572        let recovery_codes = ath.enable_mfa(user_id, &code).await.unwrap();
573        (totp, recovery_codes)
574    }
575
576    // ---------------------------------------------------------------------------
577    // derive_issuer — pure function, no I/O
578    // ---------------------------------------------------------------------------
579
580    #[test]
581    fn derive_issuer_strips_http_scheme() {
582        assert_eq!(derive_issuer("http://example.com"), "example.com");
583    }
584
585    #[test]
586    fn derive_issuer_strips_https_scheme() {
587        assert_eq!(
588            derive_issuer("https://auth.example.com"),
589            "auth.example.com"
590        );
591    }
592
593    #[test]
594    fn derive_issuer_strips_port() {
595        // totp-rs rejects issuer strings containing colons; port must be removed.
596        assert_eq!(derive_issuer("http://127.0.0.1:3100"), "127.0.0.1");
597    }
598
599    #[test]
600    fn derive_issuer_strips_path() {
601        assert_eq!(
602            derive_issuer("https://auth.example.com/some/path"),
603            "auth.example.com"
604        );
605    }
606
607    // ---------------------------------------------------------------------------
608    // GET /settings/mfa/setup — idempotency
609    // ---------------------------------------------------------------------------
610
611    #[tokio::test]
612    async fn get_mfa_setup_renders_secret() {
613        let ath = setup().await;
614        let app = test_app(ath.clone());
615        let (_, cookie) = create_session(&ath).await;
616
617        let csrf = get_csrf(&app, &cookie).await;
618        let req = Request::builder()
619            .uri("/settings/mfa/setup")
620            .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
621            .body(Body::empty())
622            .unwrap();
623        let resp = app.oneshot(req).await.unwrap();
624
625        assert_eq!(resp.status(), StatusCode::OK);
626        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
627            .await
628            .unwrap();
629        let html = String::from_utf8(body.to_vec()).unwrap();
630        assert!(
631            html.contains("totp-secret"),
632            "setup page must show secret element"
633        );
634        // The totp_uri value is HTML-escaped by MiniJinja; check the testid container exists.
635        assert!(
636            html.contains("totp-uri"),
637            "setup page must show QR URI container"
638        );
639    }
640
641    #[tokio::test]
642    async fn get_mfa_setup_is_idempotent() {
643        // Two GETs must return the same secret so wrong-code-then-retry works.
644        let ath = setup().await;
645        let app = test_app(ath.clone());
646        let (_, cookie) = create_session(&ath).await;
647        let csrf = get_csrf(&app, &cookie).await;
648
649        let secret_of = |html: String| -> String {
650            // Extract the text content of the <code data-testid="totp-secret"> element.
651            // The template renders the element with additional class attributes before >,
652            // so split on the data-testid attribute value then find the closing > to skip
653            // all attributes, then read up to </code>.
654            let after_attr = html
655                .split("data-testid=\"totp-secret\"")
656                .nth(1)
657                .expect("totp-secret element not found in HTML");
658            let after_tag_close = after_attr
659                .splitn(2, '>')
660                .nth(1)
661                .expect("closing > of totp-secret element not found");
662            after_tag_close.split('<').next().unwrap_or("").to_string()
663        };
664
665        let req1 = Request::builder()
666            .uri("/settings/mfa/setup")
667            .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
668            .body(Body::empty())
669            .unwrap();
670        let resp1 = app.clone().oneshot(req1).await.unwrap();
671        let html1 = String::from_utf8(
672            axum::body::to_bytes(resp1.into_body(), usize::MAX)
673                .await
674                .unwrap()
675                .to_vec(),
676        )
677        .unwrap();
678
679        let req2 = Request::builder()
680            .uri("/settings/mfa/setup")
681            .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
682            .body(Body::empty())
683            .unwrap();
684        let resp2 = app.clone().oneshot(req2).await.unwrap();
685        let html2 = String::from_utf8(
686            axum::body::to_bytes(resp2.into_body(), usize::MAX)
687                .await
688                .unwrap()
689                .to_vec(),
690        )
691        .unwrap();
692
693        assert_eq!(
694            secret_of(html1),
695            secret_of(html2),
696            "repeated GET /settings/mfa/setup must return the same pending secret"
697        );
698    }
699
700    // ---------------------------------------------------------------------------
701    // POST /settings/mfa/confirm
702    // ---------------------------------------------------------------------------
703
704    #[tokio::test]
705    async fn post_mfa_confirm_invalid_code_shows_error_and_does_not_enable() {
706        let ath = setup().await;
707        let app = test_app(ath.clone());
708        let (user_id, cookie) = create_session(&ath).await;
709
710        // Trigger secret creation via GET (idempotency path)
711        let csrf = get_csrf(&app, &cookie).await;
712
713        let body_str = format!("code=000000&csrf_token={csrf}");
714        let req = Request::builder()
715            .method("POST")
716            .uri("/settings/mfa/confirm")
717            .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
718            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
719            .body(Body::from(body_str))
720            .unwrap();
721        let resp = app.oneshot(req).await.unwrap();
722
723        assert_eq!(resp.status(), StatusCode::OK);
724        let html = String::from_utf8(
725            axum::body::to_bytes(resp.into_body(), usize::MAX)
726                .await
727                .unwrap()
728                .to_vec(),
729        )
730        .unwrap();
731        assert!(
732            html.contains(SETUP_INVALID_CODE),
733            "wrong code must show setup error"
734        );
735        assert!(
736            !ath.has_mfa_enabled(user_id).await.unwrap(),
737            "MFA must not be enabled after wrong code"
738        );
739    }
740
741    #[tokio::test]
742    async fn post_mfa_confirm_valid_code_enables_mfa_and_renders_recovery_codes() {
743        let ath = setup().await;
744        let app = test_app(ath.clone());
745        let (user_id, cookie) = create_session(&ath).await;
746
747        let csrf = get_csrf(&app, &cookie).await;
748
749        // Create and retrieve the pending secret
750        let secret = ath.create_mfa_secret(user_id).await.unwrap();
751        let totp = TOTP::new(
752            Algorithm::SHA1,
753            6,
754            1,
755            30,
756            Secret::Encoded(secret).to_bytes().unwrap(),
757            None,
758            String::new(),
759        )
760        .unwrap();
761        let code = totp.generate_current().unwrap();
762
763        let body_str = format!("code={code}&csrf_token={csrf}");
764        let req = Request::builder()
765            .method("POST")
766            .uri("/settings/mfa/confirm")
767            .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
768            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
769            .body(Body::from(body_str))
770            .unwrap();
771        let resp = app.oneshot(req).await.unwrap();
772
773        assert_eq!(resp.status(), StatusCode::OK);
774        let html = String::from_utf8(
775            axum::body::to_bytes(resp.into_body(), usize::MAX)
776                .await
777                .unwrap()
778                .to_vec(),
779        )
780        .unwrap();
781        assert!(
782            html.contains("recovery-code"),
783            "success must render recovery codes"
784        );
785        assert!(
786            ath.has_mfa_enabled(user_id).await.unwrap(),
787            "MFA must be enabled after valid confirm"
788        );
789    }
790
791    // ---------------------------------------------------------------------------
792    // POST /settings/mfa/disable
793    // ---------------------------------------------------------------------------
794
795    #[tokio::test]
796    async fn post_mfa_disable_removes_mfa_and_redirects() {
797        let ath = setup().await;
798        let app = test_app(ath.clone());
799        let (user_id, cookie) = create_session(&ath).await;
800        enable_mfa_for_user(&ath, user_id).await;
801
802        // Derive CSRF token from the session token (HMAC path — no Set-Cookie on GET).
803        let session_token_val = cookie.split('=').nth(1).unwrap().to_string();
804        let session_token = allowthem_core::types::SessionToken::from_encoded(session_token_val);
805        let csrf =
806            allowthem_core::derive_csrf_token(&session_token, b"test-csrf-key-for-binary-tests!!");
807
808        let body_str = format!("csrf_token={csrf}");
809        let req = Request::builder()
810            .method("POST")
811            .uri("/settings/mfa/disable")
812            .header(header::COOKIE, &cookie)
813            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
814            .body(Body::from(body_str))
815            .unwrap();
816        let resp = app.oneshot(req).await.unwrap();
817
818        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
819        assert_eq!(resp.headers().get("location").unwrap(), "/settings");
820        assert!(
821            !ath.has_mfa_enabled(user_id).await.unwrap(),
822            "MFA must be disabled after disable POST"
823        );
824    }
825
826    // ---------------------------------------------------------------------------
827    // GET /mfa/challenge
828    // ---------------------------------------------------------------------------
829
830    #[tokio::test]
831    async fn get_mfa_challenge_with_invalid_token_redirects_to_login() {
832        let ath = setup().await;
833        let app = test_app(ath);
834
835        let req = Request::builder()
836            .uri("/mfa/challenge?token=not-a-real-token")
837            .body(Body::empty())
838            .unwrap();
839        let resp = app.oneshot(req).await.unwrap();
840
841        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
842        assert_eq!(resp.headers().get("location").unwrap(), "/login");
843    }
844
845    #[tokio::test]
846    async fn get_mfa_challenge_with_valid_token_renders_form() {
847        let ath = setup().await;
848        let app = test_app(ath.clone());
849        let (user_id, _) = create_session(&ath).await;
850        enable_mfa_for_user(&ath, user_id).await;
851
852        let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
853        let req = Request::builder()
854            .uri(format!("/mfa/challenge?token={token}"))
855            .body(Body::empty())
856            .unwrap();
857        let resp = app.oneshot(req).await.unwrap();
858
859        assert_eq!(resp.status(), StatusCode::OK);
860        let html = String::from_utf8(
861            axum::body::to_bytes(resp.into_body(), usize::MAX)
862                .await
863                .unwrap()
864                .to_vec(),
865        )
866        .unwrap();
867        assert!(
868            html.contains("name=\"code\""),
869            "challenge form must have code input"
870        );
871        assert!(
872            html.contains("mfa_token"),
873            "challenge form must embed mfa_token hidden field"
874        );
875    }
876
877    // ---------------------------------------------------------------------------
878    // POST /mfa/challenge
879    // ---------------------------------------------------------------------------
880
881    #[tokio::test]
882    async fn post_mfa_challenge_invalid_token_redirects_to_login() {
883        let ath = setup().await;
884        let app = test_app(ath);
885
886        let body_str = "mfa_token=garbage&code=123456";
887        let req = Request::builder()
888            .method("POST")
889            .uri("/mfa/challenge")
890            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
891            .body(Body::from(body_str))
892            .unwrap();
893        let resp = app.oneshot(req).await.unwrap();
894
895        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
896        assert_eq!(resp.headers().get("location").unwrap(), "/login");
897    }
898
899    #[tokio::test]
900    async fn post_mfa_challenge_wrong_totp_does_not_consume_challenge() {
901        // Retry must be possible after a wrong code.
902        let ath = setup().await;
903        let app = test_app(ath.clone());
904        let (user_id, _) = create_session(&ath).await;
905        enable_mfa_for_user(&ath, user_id).await;
906
907        let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
908
909        let body_str = format!("mfa_token={token}&code=000000");
910        let req = Request::builder()
911            .method("POST")
912            .uri("/mfa/challenge")
913            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
914            .body(Body::from(body_str))
915            .unwrap();
916        let resp = app.oneshot(req).await.unwrap();
917
918        assert_eq!(resp.status(), StatusCode::OK);
919        let html = String::from_utf8(
920            axum::body::to_bytes(resp.into_body(), usize::MAX)
921                .await
922                .unwrap()
923                .to_vec(),
924        )
925        .unwrap();
926        assert!(
927            html.contains(CHALLENGE_INVALID_TOTP),
928            "wrong code must show TOTP error"
929        );
930
931        // Challenge must still be valid (not consumed) so the user can retry
932        let still_valid = ath.db().validate_mfa_challenge(&token).await.unwrap();
933        assert!(
934            still_valid.is_some(),
935            "challenge must survive a failed attempt"
936        );
937    }
938
939    #[tokio::test]
940    async fn post_mfa_challenge_valid_totp_creates_session_and_emits_login() {
941        let ath = setup().await;
942        let app = test_app(ath.clone());
943        let (user_id, _) = create_session(&ath).await;
944        let (totp, _) = enable_mfa_for_user(&ath, user_id).await;
945
946        let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
947        let code = totp.generate_current().unwrap();
948
949        let body_str = format!("mfa_token={token}&code={code}");
950        let req = Request::builder()
951            .method("POST")
952            .uri("/mfa/challenge")
953            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
954            .body(Body::from(body_str))
955            .unwrap();
956        let resp = app.oneshot(req).await.unwrap();
957
958        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
959        assert_eq!(resp.headers().get("location").unwrap(), "/");
960        assert!(
961            resp.headers().get(header::SET_COOKIE).is_some(),
962            "session cookie must be set on success"
963        );
964
965        // Challenge must be consumed
966        let consumed = ath.db().validate_mfa_challenge(&token).await.unwrap();
967        assert!(
968            consumed.is_none(),
969            "challenge must be consumed after success"
970        );
971
972        // Both MfaChallengeSuccess and Login must be in the audit log
973        let entries = ath.db().get_audit_log(Some(&user_id), 50, 0).await.unwrap();
974        let event_types: Vec<&allowthem_core::AuditEvent> =
975            entries.iter().map(|e| &e.event_type).collect();
976        assert!(
977            event_types.contains(&&allowthem_core::AuditEvent::MfaChallengeSuccess),
978            "MfaChallengeSuccess must be in audit log"
979        );
980        assert!(
981            event_types.contains(&&allowthem_core::AuditEvent::Login),
982            "Login must be in audit log after MFA challenge success"
983        );
984    }
985
986    #[tokio::test]
987    async fn post_mfa_challenge_wrong_recovery_code_shows_error() {
988        let ath = setup().await;
989        let app = test_app(ath.clone());
990        let (user_id, _) = create_session(&ath).await;
991        enable_mfa_for_user(&ath, user_id).await;
992
993        let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
994
995        let body_str = format!("mfa_token={token}&recovery_code=AAAAAAAA&use_recovery=on");
996        let req = Request::builder()
997            .method("POST")
998            .uri("/mfa/challenge")
999            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1000            .body(Body::from(body_str))
1001            .unwrap();
1002        let resp = app.oneshot(req).await.unwrap();
1003
1004        assert_eq!(resp.status(), StatusCode::OK);
1005        let html = String::from_utf8(
1006            axum::body::to_bytes(resp.into_body(), usize::MAX)
1007                .await
1008                .unwrap()
1009                .to_vec(),
1010        )
1011        .unwrap();
1012        assert!(
1013            html.contains(CHALLENGE_INVALID_RECOVERY),
1014            "wrong recovery code must show recovery error"
1015        );
1016    }
1017}