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
24const SETUP_INVALID_CODE: &str = "Invalid TOTP code";
26
27const CHALLENGE_INVALID_TOTP: &str = "Invalid TOTP or recovery code";
29
30const 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
40fn 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
52fn 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
69async 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
110async 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 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
160async 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 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
236async 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#[derive(Deserialize)]
273pub struct ChallengeQuery {
274 token: String,
275}
276
277async fn get_mfa_challenge(
279 State(ath): State<AllowThem>,
280 Extension(config): Extension<MfaPageConfig>,
281 Query(query): Query<ChallengeQuery>,
282) -> Result<Response, BrowserError> {
283 let user_id = ath.db().validate_mfa_challenge(&query.token).await?;
285 if user_id.is_none() {
286 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
313async 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 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 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 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 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 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
423pub 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
450pub 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 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 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 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 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 #[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 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 #[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 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 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 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 #[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 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 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 #[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 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 #[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 #[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 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 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 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 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}