1use std::sync::Arc;
2
3use axum::Form;
4use axum::Router;
5use axum::extract::{Extension, Query};
6use axum::http::HeaderMap;
7use axum::http::StatusCode;
8use axum::http::Uri;
9use axum::http::header::{LOCATION, SET_COOKIE, USER_AGENT};
10use axum::response::{IntoResponse, Response};
11use axum::routing::{get, post};
12use axum_htmx::{HxBoosted, HxRequest};
13use chrono::Utc;
14use minijinja::{Environment, context};
15use serde::Deserialize;
16
17use allowthem_core::applications::BrandingConfig;
18use allowthem_core::totp::totp_uri;
19use allowthem_core::{AllowThem, AuditEvent, AuthError, sessions};
20
21use crate::branding::{DefaultBranding, branding_context, default_branding_ref, resolve_branding};
22use crate::browser_error::BrowserError;
23use crate::csrf::CsrfToken;
24use crate::error::BrowserAuthRedirect;
25
26const SETUP_INVALID_CODE: &str = "Invalid TOTP code";
28
29const CHALLENGE_INVALID_TOTP: &str = "Invalid TOTP or recovery code";
31
32const CHALLENGE_INVALID_RECOVERY: &str = "Invalid recovery code";
34
35#[derive(Clone)]
36struct MfaPageConfig {
37 templates: Arc<Environment<'static>>,
38 is_production: bool,
39 base_url: String,
40}
41
42fn client_ip(headers: &HeaderMap) -> Option<String> {
47 headers
48 .get("x-forwarded-for")
49 .and_then(|v| v.to_str().ok())
50 .and_then(|s| s.split(',').next())
51 .map(|s| s.trim().to_string())
52}
53
54fn derive_issuer(base_url: &str) -> String {
59 base_url
60 .trim_start_matches("https://")
61 .trim_start_matches("http://")
62 .split('/')
63 .next()
64 .unwrap_or("allowthem")
65 .split(':')
66 .next()
67 .unwrap_or("allowthem")
68 .to_string()
69}
70
71async fn require_browser_user(
77 ath: &AllowThem,
78 headers: &HeaderMap,
79 path: &str,
80) -> Result<allowthem_core::types::User, Response> {
81 let cookie_header = headers
82 .get(axum::http::header::COOKIE)
83 .and_then(|v| v.to_str().ok())
84 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
85
86 let token = ath
87 .parse_session_cookie(cookie_header)
88 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
89
90 let ttl = ath.session_config().ttl;
91 let session = ath
92 .db()
93 .validate_session(&token, ttl)
94 .await
95 .map_err(|err| {
96 tracing::error!("session validation error: {err}");
97 BrowserAuthRedirect::new(path).into_response()
98 })?
99 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
100
101 match ath.db().get_user(session.user_id).await {
102 Ok(user) if user.is_active => Ok(user),
103 Ok(_) => Err(BrowserAuthRedirect::new(path).into_response()),
104 Err(AuthError::NotFound) => Err(BrowserAuthRedirect::new(path).into_response()),
105 Err(err) => {
106 tracing::error!("user lookup error: {err}");
107 Err(BrowserAuthRedirect::new(path).into_response())
108 }
109 }
110}
111
112fn render_mfa_setup_fragment(
124 config: &MfaPageConfig,
125 csrf_token: &str,
126 totp_uri: &str,
127 secret: &str,
128 error: &str,
129 branding: Option<&BrandingConfig>,
130) -> Result<axum::response::Html<String>, BrowserError> {
131 let ctx = context! {
132 csrf_token,
133 totp_uri,
134 secret,
135 error,
136 is_production => config.is_production,
137 page_title => "Set up two-factor authentication — allowthem",
138 status_hint => "ENABLE 2FA",
139 ..branding_context(branding),
140 };
141
142 let main = crate::browser_templates::render(
143 &config.templates,
144 "_partials/_auth_main_mfa_setup.html",
145 ctx.clone(),
146 )?;
147 let oob =
148 crate::browser_templates::render(&config.templates, "_partials/_auth_oob_head.html", ctx)?;
149 Ok(axum::response::Html(format!("{}{}", main.0, oob.0)))
150}
151
152fn render_mfa_recovery_fragment(
155 config: &MfaPageConfig,
156 recovery_codes: &[String],
157 branding: Option<&BrandingConfig>,
158) -> Result<axum::response::Html<String>, BrowserError> {
159 let ctx = context! {
160 recovery_codes,
161 is_production => config.is_production,
162 page_title => "Recovery codes — allowthem",
163 status_hint => "RECOVERY CODES",
164 ..branding_context(branding),
165 };
166
167 let main = crate::browser_templates::render(
168 &config.templates,
169 "_partials/_auth_main_mfa_recovery.html",
170 ctx.clone(),
171 )?;
172 let oob =
173 crate::browser_templates::render(&config.templates, "_partials/_auth_oob_head.html", ctx)?;
174 Ok(axum::response::Html(format!("{}{}", main.0, oob.0)))
175}
176
177async fn get_mfa_setup(
182 Extension(ath): Extension<AllowThem>,
183 Extension(config): Extension<MfaPageConfig>,
184 default_branding: Option<Extension<Arc<DefaultBranding>>>,
185 uri: Uri,
186 csrf: CsrfToken,
187 headers: HeaderMap,
188 HxBoosted(boosted): HxBoosted,
189 HxRequest(request): HxRequest,
190) -> Result<Response, BrowserError> {
191 let user = match require_browser_user(&ath, &headers, uri.path()).await {
192 Ok(u) => u,
193 Err(redirect) => return Ok(redirect),
194 };
195
196 let default = default_branding_ref(&default_branding);
197 let branding = resolve_branding(&ath, None, default).await;
198
199 let secret = match ath.get_pending_mfa_secret(user.id).await? {
201 Some(s) => s,
202 None => ath.create_mfa_secret(user.id).await?,
203 };
204
205 let issuer = derive_issuer(&config.base_url);
206 let uri = totp_uri(&secret, user.email.as_str(), &issuer);
207
208 if request && !boosted {
209 let html = render_mfa_setup_fragment(
210 &config,
211 csrf.as_str(),
212 &uri,
213 &secret,
214 "",
215 branding.as_ref(),
216 )?;
217 return Ok(html.into_response());
218 }
219
220 let html = crate::browser_templates::render(
221 &config.templates,
222 "mfa_setup.html",
223 context! {
224 csrf_token => csrf.as_str(),
225 secret => &secret,
226 totp_uri => &uri,
227 error => "",
228 is_production => config.is_production,
229 ..branding_context(branding.as_ref()),
230 },
231 )?;
232 Ok(html.into_response())
233}
234
235#[derive(Deserialize)]
236pub struct MfaConfirmForm {
237 code: String,
238 #[allow(dead_code)]
239 csrf_token: String,
240}
241
242async fn post_mfa_confirm(
247 Extension(ath): Extension<AllowThem>,
248 Extension(config): Extension<MfaPageConfig>,
249 default_branding: Option<Extension<Arc<DefaultBranding>>>,
250 uri: Uri,
251 csrf: CsrfToken,
252 headers: HeaderMap,
253 HxBoosted(boosted): HxBoosted,
254 HxRequest(request): HxRequest,
255 Form(form): Form<MfaConfirmForm>,
256) -> Result<Response, BrowserError> {
257 let user = match require_browser_user(&ath, &headers, uri.path()).await {
258 Ok(u) => u,
259 Err(redirect) => return Ok(redirect),
260 };
261
262 let default = default_branding_ref(&default_branding);
263 let branding = resolve_branding(&ath, None, default).await;
264
265 let ip = client_ip(&headers);
266 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
267
268 match ath.enable_mfa(user.id, &form.code).await {
269 Ok(recovery_codes) => {
270 let _ = ath
271 .db()
272 .log_audit(
273 AuditEvent::MfaEnabled,
274 Some(&user.id),
275 None,
276 ip.as_deref(),
277 ua,
278 None,
279 )
280 .await;
281
282 if request && !boosted {
283 let html =
284 render_mfa_recovery_fragment(&config, &recovery_codes, branding.as_ref())?;
285 return Ok(html.into_response());
286 }
287
288 let html = crate::browser_templates::render(
289 &config.templates,
290 "mfa_recovery.html",
291 context! {
292 recovery_codes => &recovery_codes,
293 is_production => config.is_production,
294 ..branding_context(branding.as_ref()),
295 },
296 )?;
297 Ok(html.into_response())
298 }
299 Err(allowthem_core::AuthError::InvalidTotpCode) => {
300 let secret = ath
302 .get_pending_mfa_secret(user.id)
303 .await?
304 .unwrap_or_default();
305 let issuer = derive_issuer(&config.base_url);
306 let uri = totp_uri(&secret, user.email.as_str(), &issuer);
307
308 let html = crate::browser_templates::render(
309 &config.templates,
310 "mfa_setup.html",
311 context! {
312 csrf_token => csrf.as_str(),
313 secret => &secret,
314 totp_uri => &uri,
315 error => SETUP_INVALID_CODE,
316 is_production => config.is_production,
317 ..branding_context(branding.as_ref()),
318 },
319 )?;
320 Ok(html.into_response())
321 }
322 Err(e) => Err(BrowserError::Auth(e)),
323 }
324}
325
326#[derive(Deserialize)]
327pub struct MfaDisableForm {
328 #[allow(dead_code)]
329 csrf_token: String,
330}
331
332async fn post_mfa_disable(
334 Extension(ath): Extension<AllowThem>,
335 uri: Uri,
336 headers: HeaderMap,
337 Form(_form): Form<MfaDisableForm>,
338) -> Result<Response, BrowserError> {
339 let user = match require_browser_user(&ath, &headers, uri.path()).await {
340 Ok(u) => u,
341 Err(redirect) => return Ok(redirect),
342 };
343
344 let ip = client_ip(&headers);
345 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
346
347 ath.disable_mfa(user.id).await?;
348
349 let _ = ath
350 .db()
351 .log_audit(
352 AuditEvent::MfaDisabled,
353 Some(&user.id),
354 None,
355 ip.as_deref(),
356 ua,
357 None,
358 )
359 .await;
360
361 Ok((StatusCode::SEE_OTHER, [(LOCATION, "/settings".to_string())]).into_response())
362}
363
364#[derive(Deserialize)]
369pub struct ChallengeQuery {
370 token: String,
371}
372
373fn render_mfa_challenge_fragment(
382 config: &MfaPageConfig,
383 mfa_token: &str,
384 error: &str,
385 branding: Option<&BrandingConfig>,
386) -> Result<axum::response::Html<String>, BrowserError> {
387 let ctx = context! {
388 mfa_token,
389 error,
390 is_production => config.is_production,
391 page_title => "Two-factor authentication — allowthem",
392 status_hint => "TWO-FACTOR",
393 ..branding_context(branding),
394 };
395
396 let main = crate::browser_templates::render(
397 &config.templates,
398 "_partials/_auth_main_mfa_challenge.html",
399 ctx.clone(),
400 )?;
401 let oob =
402 crate::browser_templates::render(&config.templates, "_partials/_auth_oob_head.html", ctx)?;
403 Ok(axum::response::Html(format!("{}{}", main.0, oob.0)))
404}
405
406async fn get_mfa_challenge(
408 Extension(ath): Extension<AllowThem>,
409 Extension(config): Extension<MfaPageConfig>,
410 default_branding: Option<Extension<Arc<DefaultBranding>>>,
411 Query(query): Query<ChallengeQuery>,
412 HxBoosted(boosted): HxBoosted,
413 HxRequest(request): HxRequest,
414) -> Result<Response, BrowserError> {
415 let user_id = ath.db().validate_mfa_challenge(&query.token).await?;
417 if user_id.is_none() {
418 return Ok((StatusCode::SEE_OTHER, [(LOCATION, "/login".to_string())]).into_response());
420 }
421
422 let default = default_branding_ref(&default_branding);
423 let branding = resolve_branding(&ath, None, default).await;
424
425 if request && !boosted {
426 let html = render_mfa_challenge_fragment(&config, &query.token, "", branding.as_ref())?;
427 return Ok(html.into_response());
428 }
429
430 let html = crate::browser_templates::render(
431 &config.templates,
432 "mfa_challenge.html",
433 context! {
434 mfa_token => &query.token,
435 error => "",
436 is_production => config.is_production,
437 ..branding_context(branding.as_ref()),
438 },
439 )?;
440 Ok(html.into_response())
441}
442
443#[derive(Deserialize)]
444pub struct MfaChallengeForm {
445 mfa_token: String,
446 #[serde(default)]
447 code: Option<String>,
448 #[serde(default)]
449 recovery_code: Option<String>,
450 #[serde(default)]
451 use_recovery: Option<String>,
452}
453
454async fn post_mfa_challenge(
456 Extension(ath): Extension<AllowThem>,
457 Extension(config): Extension<MfaPageConfig>,
458 default_branding: Option<Extension<Arc<DefaultBranding>>>,
459 headers: HeaderMap,
460 Form(form): Form<MfaChallengeForm>,
461) -> Result<Response, BrowserError> {
462 let default = default_branding_ref(&default_branding);
463 let branding = resolve_branding(&ath, None, default).await;
464 let ip = headers
465 .get("x-forwarded-for")
466 .and_then(|v| v.to_str().ok())
467 .and_then(|s| s.split(',').next())
468 .map(|s| s.trim().to_string());
469 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
470
471 let user_id = match ath.db().validate_mfa_challenge(&form.mfa_token).await? {
473 Some(uid) => uid,
474 None => {
475 return Ok((StatusCode::SEE_OTHER, [(LOCATION, "/login".to_string())]).into_response());
476 }
477 };
478
479 let use_recovery = form.use_recovery.is_some();
481 let verified = if use_recovery {
482 let code = form.recovery_code.as_deref().unwrap_or("");
483 ath.verify_recovery_code(user_id, code).await?
484 } else {
485 let code = form.code.as_deref().unwrap_or("");
486 ath.verify_totp(user_id, code).await?
487 };
488
489 if !verified {
490 let _ = ath
492 .db()
493 .log_audit(
494 AuditEvent::MfaChallengeFailed,
495 Some(&user_id),
496 None,
497 ip.as_deref(),
498 ua,
499 None,
500 )
501 .await;
502
503 let error_msg = if use_recovery {
504 CHALLENGE_INVALID_RECOVERY
505 } else {
506 CHALLENGE_INVALID_TOTP
507 };
508
509 let html = crate::browser_templates::render(
510 &config.templates,
511 "mfa_challenge.html",
512 context! {
513 mfa_token => &form.mfa_token,
514 error => error_msg,
515 is_production => config.is_production,
516 ..branding_context(branding.as_ref()),
517 },
518 )?;
519 return Ok(html.into_response());
520 }
521
522 ath.db().consume_mfa_challenge(&form.mfa_token).await?;
524
525 let _ = ath
526 .db()
527 .log_audit(
528 AuditEvent::MfaChallengeSuccess,
529 Some(&user_id),
530 None,
531 ip.as_deref(),
532 ua,
533 None,
534 )
535 .await;
536
537 let _ = ath
540 .db()
541 .log_audit(
542 AuditEvent::Login,
543 Some(&user_id),
544 None,
545 ip.as_deref(),
546 ua,
547 None,
548 )
549 .await;
550
551 let token = sessions::generate_token();
552 let token_hash = sessions::hash_token(&token);
553 let ttl = ath.session_config().ttl;
554 let expires_at = Utc::now() + ttl;
555 ath.db()
556 .create_session(user_id, token_hash, ip.as_deref(), ua, expires_at)
557 .await?;
558
559 let cookie = ath.session_cookie(&token);
560
561 Ok((
562 StatusCode::SEE_OTHER,
563 [(SET_COOKIE, cookie), (LOCATION, "/".to_string())],
564 )
565 .into_response())
566}
567
568pub fn mfa_setup_routes(
579 templates: Arc<Environment<'static>>,
580 is_production: bool,
581 base_url: String,
582) -> Router<()> {
583 let cfg = MfaPageConfig {
584 templates,
585 is_production,
586 base_url,
587 };
588 Router::new()
589 .route("/settings/mfa/setup", get(get_mfa_setup))
590 .route("/settings/mfa/confirm", post(post_mfa_confirm))
591 .route("/settings/mfa/disable", post(post_mfa_disable))
592 .layer(Extension(cfg))
593}
594
595pub fn mfa_challenge_routes(
601 templates: Arc<Environment<'static>>,
602 is_production: bool,
603) -> Router<()> {
604 let cfg = MfaPageConfig {
605 templates,
606 is_production,
607 base_url: String::new(),
608 };
609 Router::new()
610 .route(
611 "/mfa/challenge",
612 get(get_mfa_challenge).post(post_mfa_challenge),
613 )
614 .layer(Extension(cfg))
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 use axum::body::Body;
622 use axum::http::{Request, StatusCode, header};
623 use chrono::{Duration, Utc};
624 use totp_rs::{Algorithm, Secret, TOTP};
625 use tower::ServiceExt;
626
627 use allowthem_core::{AllowThemBuilder, Email, generate_token, hash_token};
628
629 const TEST_MFA_KEY: [u8; 32] = [0x42; 32];
630
631 async fn setup() -> AllowThem {
636 AllowThemBuilder::new("sqlite::memory:")
637 .cookie_secure(false)
638 .mfa_key(TEST_MFA_KEY)
639 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
640 .build()
641 .await
642 .unwrap()
643 }
644
645 fn test_app(ath: AllowThem) -> Router {
648 let templates = crate::browser_templates::build_default_browser_env();
649 Router::new()
650 .merge(mfa_setup_routes(
651 templates.clone(),
652 false,
653 "http://127.0.0.1:3100".into(),
654 ))
655 .layer(axum::middleware::from_fn(crate::csrf::csrf_middleware))
656 .merge(mfa_challenge_routes(templates, false))
657 .layer(axum::middleware::from_fn_with_state(
658 ath.clone(),
659 crate::cors::inject_ath_into_extensions,
660 ))
661 }
662
663 async fn create_session(ath: &AllowThem) -> (allowthem_core::types::UserId, String) {
664 let email = Email::new("mfa-test@example.com".into()).unwrap();
665 let user = ath
666 .db()
667 .create_user(email, "pass", None, None)
668 .await
669 .unwrap();
670 let token = generate_token();
671 let token_hash = hash_token(&token);
672 let expires = Utc::now() + Duration::hours(24);
673 ath.db()
674 .create_session(user.id, token_hash, None, None, expires)
675 .await
676 .unwrap();
677 let cookie = ath.session_cookie(&token);
678 let cookie_val = cookie.split(';').next().unwrap().to_string();
679 (user.id, cookie_val)
680 }
681
682 async fn get_csrf(app: &Router, session_cookie: &str) -> String {
684 let req = Request::builder()
685 .uri("/settings/mfa/setup")
686 .header(header::COOKIE, session_cookie)
687 .body(Body::empty())
688 .unwrap();
689 let resp = app.clone().oneshot(req).await.unwrap();
690 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
691 .await
692 .unwrap();
693 let html = String::from_utf8(bytes.to_vec()).unwrap();
694 let marker = "name=\"csrf_token\" value=\"";
695 let start = html.find(marker).expect("csrf_token not found in HTML") + marker.len();
696 let end = html[start..].find('"').unwrap() + start;
697 html[start..end].to_string()
698 }
699
700 async fn enable_mfa_for_user(
702 ath: &AllowThem,
703 user_id: allowthem_core::types::UserId,
704 ) -> (TOTP, Vec<String>) {
705 let secret_b32 = ath.create_mfa_secret(user_id).await.unwrap();
706 let totp = TOTP::new(
707 Algorithm::SHA1,
708 6,
709 1,
710 30,
711 Secret::Encoded(secret_b32).to_bytes().unwrap(),
712 None,
713 String::new(),
714 )
715 .unwrap();
716 let code = totp.generate_current().unwrap();
717 let recovery_codes = ath.enable_mfa(user_id, &code).await.unwrap();
718 (totp, recovery_codes)
719 }
720
721 #[test]
726 fn derive_issuer_strips_http_scheme() {
727 assert_eq!(derive_issuer("http://example.com"), "example.com");
728 }
729
730 #[test]
731 fn derive_issuer_strips_https_scheme() {
732 assert_eq!(
733 derive_issuer("https://auth.example.com"),
734 "auth.example.com"
735 );
736 }
737
738 #[test]
739 fn derive_issuer_strips_port() {
740 assert_eq!(derive_issuer("http://127.0.0.1:3100"), "127.0.0.1");
742 }
743
744 #[test]
745 fn derive_issuer_strips_path() {
746 assert_eq!(
747 derive_issuer("https://auth.example.com/some/path"),
748 "auth.example.com"
749 );
750 }
751
752 #[tokio::test]
757 async fn get_mfa_setup_renders_secret() {
758 let ath = setup().await;
759 let app = test_app(ath.clone());
760 let (_, cookie) = create_session(&ath).await;
761
762 let csrf = get_csrf(&app, &cookie).await;
763 let req = Request::builder()
764 .uri("/settings/mfa/setup")
765 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
766 .body(Body::empty())
767 .unwrap();
768 let resp = app.oneshot(req).await.unwrap();
769
770 assert_eq!(resp.status(), StatusCode::OK);
771 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
772 .await
773 .unwrap();
774 let html = String::from_utf8(body.to_vec()).unwrap();
775 assert!(
776 html.contains("totp-secret"),
777 "setup page must show secret element"
778 );
779 assert!(
781 html.contains("totp-uri"),
782 "setup page must show QR URI container"
783 );
784 }
785
786 #[tokio::test]
787 async fn get_mfa_setup_is_idempotent() {
788 let ath = setup().await;
790 let app = test_app(ath.clone());
791 let (_, cookie) = create_session(&ath).await;
792 let csrf = get_csrf(&app, &cookie).await;
793
794 let secret_of = |html: String| -> String {
795 let after_attr = html
800 .split("data-testid=\"totp-secret\"")
801 .nth(1)
802 .expect("totp-secret element not found in HTML");
803 let after_tag_close = after_attr
804 .splitn(2, '>')
805 .nth(1)
806 .expect("closing > of totp-secret element not found");
807 after_tag_close.split('<').next().unwrap_or("").to_string()
808 };
809
810 let req1 = Request::builder()
811 .uri("/settings/mfa/setup")
812 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
813 .body(Body::empty())
814 .unwrap();
815 let resp1 = app.clone().oneshot(req1).await.unwrap();
816 let html1 = String::from_utf8(
817 axum::body::to_bytes(resp1.into_body(), usize::MAX)
818 .await
819 .unwrap()
820 .to_vec(),
821 )
822 .unwrap();
823
824 let req2 = Request::builder()
825 .uri("/settings/mfa/setup")
826 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
827 .body(Body::empty())
828 .unwrap();
829 let resp2 = app.clone().oneshot(req2).await.unwrap();
830 let html2 = String::from_utf8(
831 axum::body::to_bytes(resp2.into_body(), usize::MAX)
832 .await
833 .unwrap()
834 .to_vec(),
835 )
836 .unwrap();
837
838 assert_eq!(
839 secret_of(html1),
840 secret_of(html2),
841 "repeated GET /settings/mfa/setup must return the same pending secret"
842 );
843 }
844
845 #[tokio::test]
850 async fn post_mfa_confirm_invalid_code_shows_error_and_does_not_enable() {
851 let ath = setup().await;
852 let app = test_app(ath.clone());
853 let (user_id, cookie) = create_session(&ath).await;
854
855 let csrf = get_csrf(&app, &cookie).await;
857
858 let body_str = format!("code=000000&csrf_token={csrf}");
859 let req = Request::builder()
860 .method("POST")
861 .uri("/settings/mfa/confirm")
862 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
863 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
864 .body(Body::from(body_str))
865 .unwrap();
866 let resp = app.oneshot(req).await.unwrap();
867
868 assert_eq!(resp.status(), StatusCode::OK);
869 let html = String::from_utf8(
870 axum::body::to_bytes(resp.into_body(), usize::MAX)
871 .await
872 .unwrap()
873 .to_vec(),
874 )
875 .unwrap();
876 assert!(
877 html.contains(SETUP_INVALID_CODE),
878 "wrong code must show setup error"
879 );
880 assert!(
881 !ath.has_mfa_enabled(user_id).await.unwrap(),
882 "MFA must not be enabled after wrong code"
883 );
884 }
885
886 #[tokio::test]
887 async fn post_mfa_confirm_valid_code_enables_mfa_and_renders_recovery_codes() {
888 let ath = setup().await;
889 let app = test_app(ath.clone());
890 let (user_id, cookie) = create_session(&ath).await;
891
892 let csrf = get_csrf(&app, &cookie).await;
893
894 let secret = ath.create_mfa_secret(user_id).await.unwrap();
896 let totp = TOTP::new(
897 Algorithm::SHA1,
898 6,
899 1,
900 30,
901 Secret::Encoded(secret).to_bytes().unwrap(),
902 None,
903 String::new(),
904 )
905 .unwrap();
906 let code = totp.generate_current().unwrap();
907
908 let body_str = format!("code={code}&csrf_token={csrf}");
909 let req = Request::builder()
910 .method("POST")
911 .uri("/settings/mfa/confirm")
912 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
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("recovery-code"),
928 "success must render recovery codes"
929 );
930 assert!(
931 ath.has_mfa_enabled(user_id).await.unwrap(),
932 "MFA must be enabled after valid confirm"
933 );
934 }
935
936 #[tokio::test]
941 async fn post_mfa_disable_removes_mfa_and_redirects() {
942 let ath = setup().await;
943 let app = test_app(ath.clone());
944 let (user_id, cookie) = create_session(&ath).await;
945 enable_mfa_for_user(&ath, user_id).await;
946
947 let session_token_val = cookie.split('=').nth(1).unwrap().to_string();
949 let session_token = allowthem_core::types::SessionToken::from_encoded(session_token_val);
950 let csrf =
951 allowthem_core::derive_csrf_token(&session_token, b"test-csrf-key-for-binary-tests!!");
952
953 let body_str = format!("csrf_token={csrf}");
954 let req = Request::builder()
955 .method("POST")
956 .uri("/settings/mfa/disable")
957 .header(header::COOKIE, &cookie)
958 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
959 .body(Body::from(body_str))
960 .unwrap();
961 let resp = app.oneshot(req).await.unwrap();
962
963 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
964 assert_eq!(resp.headers().get("location").unwrap(), "/settings");
965 assert!(
966 !ath.has_mfa_enabled(user_id).await.unwrap(),
967 "MFA must be disabled after disable POST"
968 );
969 }
970
971 #[tokio::test]
976 async fn get_mfa_challenge_with_invalid_token_redirects_to_login() {
977 let ath = setup().await;
978 let app = test_app(ath);
979
980 let req = Request::builder()
981 .uri("/mfa/challenge?token=not-a-real-token")
982 .body(Body::empty())
983 .unwrap();
984 let resp = app.oneshot(req).await.unwrap();
985
986 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
987 assert_eq!(resp.headers().get("location").unwrap(), "/login");
988 }
989
990 #[tokio::test]
991 async fn get_mfa_challenge_with_valid_token_renders_form() {
992 let ath = setup().await;
993 let app = test_app(ath.clone());
994 let (user_id, _) = create_session(&ath).await;
995 enable_mfa_for_user(&ath, user_id).await;
996
997 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
998 let req = Request::builder()
999 .uri(format!("/mfa/challenge?token={token}"))
1000 .body(Body::empty())
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("name=\"code\""),
1014 "challenge form must have code input"
1015 );
1016 assert!(
1017 html.contains("mfa_token"),
1018 "challenge form must embed mfa_token hidden field"
1019 );
1020 }
1021
1022 #[tokio::test]
1023 async fn get_mfa_challenge_hx_request_returns_fragment() {
1024 let ath = setup().await;
1025 let app = test_app(ath.clone());
1026 let (user_id, _) = create_session(&ath).await;
1027 enable_mfa_for_user(&ath, user_id).await;
1028
1029 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
1030 let req = Request::builder()
1031 .uri(format!("/mfa/challenge?token={token}"))
1032 .header("HX-Request", "true")
1033 .body(Body::empty())
1034 .unwrap();
1035 let resp = app.oneshot(req).await.unwrap();
1036
1037 assert_eq!(resp.status(), StatusCode::OK);
1038 let html = String::from_utf8(
1039 axum::body::to_bytes(resp.into_body(), usize::MAX)
1040 .await
1041 .unwrap()
1042 .to_vec(),
1043 )
1044 .unwrap();
1045 assert!(
1046 html.contains("<main class=\"wf-auth-form\">"),
1047 "HX response must be a fragment starting at <main>"
1048 );
1049 assert!(
1050 !html.contains("<html"),
1051 "HX response must not render the full shell"
1052 );
1053 }
1054
1055 #[test]
1056 fn render_mfa_setup_fragment_composes_main_and_oob_head() {
1057 let templates = crate::browser_templates::build_default_browser_env();
1058 let config = MfaPageConfig {
1059 templates,
1060 is_production: false,
1061 base_url: "http://127.0.0.1:3100".into(),
1062 };
1063 let html = render_mfa_setup_fragment(
1064 &config,
1065 "csrf-tok",
1066 "otpauth://totp/allowthem:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=allowthem",
1067 "JBSWY3DPEHPK3PXP",
1068 "",
1069 None,
1070 )
1071 .unwrap()
1072 .0;
1073 assert!(
1074 html.contains("<main class=\"wf-auth-form\">"),
1075 "fragment must include the <main> root"
1076 );
1077 assert!(
1078 html.contains("<title hx-swap-oob=\"true\">"),
1079 "fragment must include the OOB <title> tag"
1080 );
1081 assert!(
1082 html.contains("id=\"wf-screen-label\""),
1083 "fragment must include the OOB #wf-screen-label span"
1084 );
1085 assert!(
1086 html.contains("ENABLE 2FA"),
1087 "fragment must include the ENABLE 2FA status hint"
1088 );
1089 assert!(
1090 html.contains("JBSWY3DPEHPK3PXP"),
1091 "fragment must include the base32 secret"
1092 );
1093 }
1094
1095 #[tokio::test]
1096 async fn get_mfa_setup_hx_request_returns_fragment() {
1097 let ath = setup().await;
1098 let app = test_app(ath.clone());
1099 let (_, cookie) = create_session(&ath).await;
1100 let csrf = get_csrf(&app, &cookie).await;
1101
1102 let req = Request::builder()
1103 .uri("/settings/mfa/setup")
1104 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
1105 .header("HX-Request", "true")
1106 .body(Body::empty())
1107 .unwrap();
1108 let resp = app.oneshot(req).await.unwrap();
1109
1110 assert_eq!(resp.status(), StatusCode::OK);
1111 let html = String::from_utf8(
1112 axum::body::to_bytes(resp.into_body(), usize::MAX)
1113 .await
1114 .unwrap()
1115 .to_vec(),
1116 )
1117 .unwrap();
1118 assert!(
1119 html.contains("<main class=\"wf-auth-form\">"),
1120 "HX response must be a fragment starting at <main>"
1121 );
1122 assert!(
1123 !html.contains("<html"),
1124 "HX response must not render the full shell"
1125 );
1126 }
1127
1128 #[test]
1129 fn render_mfa_recovery_fragment_composes_main_and_oob_head() {
1130 let templates = crate::browser_templates::build_default_browser_env();
1131 let config = MfaPageConfig {
1132 templates,
1133 is_production: false,
1134 base_url: "http://127.0.0.1:3100".into(),
1135 };
1136 let codes = vec!["AAAA-BBBB".to_string(), "CCCC-DDDD".to_string()];
1137 let html = render_mfa_recovery_fragment(&config, &codes, None)
1138 .unwrap()
1139 .0;
1140 assert!(
1141 html.contains("<main class=\"wf-auth-form\">"),
1142 "fragment must include the <main> root"
1143 );
1144 assert!(
1145 html.contains("<title hx-swap-oob=\"true\">"),
1146 "fragment must include the OOB <title> tag"
1147 );
1148 assert!(
1149 html.contains("id=\"wf-screen-label\""),
1150 "fragment must include the OOB #wf-screen-label span"
1151 );
1152 assert!(
1153 html.contains("RECOVERY CODES"),
1154 "fragment must include the RECOVERY CODES status hint"
1155 );
1156 assert!(
1157 html.contains("AAAA-BBBB"),
1158 "fragment must include the rendered recovery codes"
1159 );
1160 assert!(
1161 html.contains("wf-grid"),
1162 "fragment must include the recovery code grid"
1163 );
1164 }
1165
1166 #[tokio::test]
1167 async fn post_mfa_confirm_hx_request_returns_recovery_fragment() {
1168 let ath = setup().await;
1169 let app = test_app(ath.clone());
1170 let (user_id, cookie) = create_session(&ath).await;
1171 let csrf = get_csrf(&app, &cookie).await;
1172
1173 let secret = ath.create_mfa_secret(user_id).await.unwrap();
1174 let totp = TOTP::new(
1175 Algorithm::SHA1,
1176 6,
1177 1,
1178 30,
1179 Secret::Encoded(secret).to_bytes().unwrap(),
1180 None,
1181 String::new(),
1182 )
1183 .unwrap();
1184 let code = totp.generate_current().unwrap();
1185
1186 let body_str = format!("code={code}&csrf_token={csrf}");
1187 let req = Request::builder()
1188 .method("POST")
1189 .uri("/settings/mfa/confirm")
1190 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
1191 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1192 .header("HX-Request", "true")
1193 .body(Body::from(body_str))
1194 .unwrap();
1195 let resp = app.oneshot(req).await.unwrap();
1196
1197 assert_eq!(resp.status(), StatusCode::OK);
1198 let html = String::from_utf8(
1199 axum::body::to_bytes(resp.into_body(), usize::MAX)
1200 .await
1201 .unwrap()
1202 .to_vec(),
1203 )
1204 .unwrap();
1205 assert!(
1206 html.contains("<main class=\"wf-auth-form\">"),
1207 "HX response must be a fragment starting at <main>"
1208 );
1209 assert!(
1210 !html.contains("<html"),
1211 "HX response must not render the full shell"
1212 );
1213 assert!(
1214 html.contains("recovery-code"),
1215 "HX response must render the recovery codes"
1216 );
1217 }
1218
1219 #[test]
1220 fn render_mfa_challenge_fragment_composes_main_and_oob_head() {
1221 let templates = crate::browser_templates::build_default_browser_env();
1222 let config = MfaPageConfig {
1223 templates,
1224 is_production: false,
1225 base_url: String::new(),
1226 };
1227 let html = render_mfa_challenge_fragment(&config, "mfa-token-abc", "", None)
1228 .unwrap()
1229 .0;
1230 assert!(
1231 html.contains("<main class=\"wf-auth-form\">"),
1232 "fragment must include the <main> root"
1233 );
1234 assert!(
1235 html.contains("<title hx-swap-oob=\"true\">"),
1236 "fragment must include the OOB <title> tag"
1237 );
1238 assert!(
1239 html.contains("id=\"wf-screen-label\""),
1240 "fragment must include the OOB #wf-screen-label span"
1241 );
1242 assert!(
1243 html.contains("TWO-FACTOR"),
1244 "fragment must include the TWO-FACTOR status hint"
1245 );
1246 }
1247
1248 #[tokio::test]
1253 async fn post_mfa_challenge_invalid_token_redirects_to_login() {
1254 let ath = setup().await;
1255 let app = test_app(ath);
1256
1257 let body_str = "mfa_token=garbage&code=123456";
1258 let req = Request::builder()
1259 .method("POST")
1260 .uri("/mfa/challenge")
1261 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1262 .body(Body::from(body_str))
1263 .unwrap();
1264 let resp = app.oneshot(req).await.unwrap();
1265
1266 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1267 assert_eq!(resp.headers().get("location").unwrap(), "/login");
1268 }
1269
1270 #[tokio::test]
1271 async fn post_mfa_challenge_wrong_totp_does_not_consume_challenge() {
1272 let ath = setup().await;
1274 let app = test_app(ath.clone());
1275 let (user_id, _) = create_session(&ath).await;
1276 enable_mfa_for_user(&ath, user_id).await;
1277
1278 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
1279
1280 let body_str = format!("mfa_token={token}&code=000000");
1281 let req = Request::builder()
1282 .method("POST")
1283 .uri("/mfa/challenge")
1284 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1285 .body(Body::from(body_str))
1286 .unwrap();
1287 let resp = app.oneshot(req).await.unwrap();
1288
1289 assert_eq!(resp.status(), StatusCode::OK);
1290 let html = String::from_utf8(
1291 axum::body::to_bytes(resp.into_body(), usize::MAX)
1292 .await
1293 .unwrap()
1294 .to_vec(),
1295 )
1296 .unwrap();
1297 assert!(
1298 html.contains(CHALLENGE_INVALID_TOTP),
1299 "wrong code must show TOTP error"
1300 );
1301
1302 let still_valid = ath.db().validate_mfa_challenge(&token).await.unwrap();
1304 assert!(
1305 still_valid.is_some(),
1306 "challenge must survive a failed attempt"
1307 );
1308 }
1309
1310 #[tokio::test]
1311 async fn post_mfa_challenge_valid_totp_creates_session_and_emits_login() {
1312 let ath = setup().await;
1313 let app = test_app(ath.clone());
1314 let (user_id, _) = create_session(&ath).await;
1315 let (totp, _) = enable_mfa_for_user(&ath, user_id).await;
1316
1317 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
1318 let code = totp.generate_current().unwrap();
1319
1320 let body_str = format!("mfa_token={token}&code={code}");
1321 let req = Request::builder()
1322 .method("POST")
1323 .uri("/mfa/challenge")
1324 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1325 .body(Body::from(body_str))
1326 .unwrap();
1327 let resp = app.oneshot(req).await.unwrap();
1328
1329 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1330 assert_eq!(resp.headers().get("location").unwrap(), "/");
1331 assert!(
1332 resp.headers().get(header::SET_COOKIE).is_some(),
1333 "session cookie must be set on success"
1334 );
1335
1336 let consumed = ath.db().validate_mfa_challenge(&token).await.unwrap();
1338 assert!(
1339 consumed.is_none(),
1340 "challenge must be consumed after success"
1341 );
1342
1343 let entries = ath.db().get_audit_log(Some(&user_id), 50, 0).await.unwrap();
1345 let event_types: Vec<&allowthem_core::AuditEvent> =
1346 entries.iter().map(|e| &e.event_type).collect();
1347 assert!(
1348 event_types.contains(&&allowthem_core::AuditEvent::MfaChallengeSuccess),
1349 "MfaChallengeSuccess must be in audit log"
1350 );
1351 assert!(
1352 event_types.contains(&&allowthem_core::AuditEvent::Login),
1353 "Login must be in audit log after MFA challenge success"
1354 );
1355 }
1356
1357 #[tokio::test]
1358 async fn post_mfa_challenge_wrong_recovery_code_shows_error() {
1359 let ath = setup().await;
1360 let app = test_app(ath.clone());
1361 let (user_id, _) = create_session(&ath).await;
1362 enable_mfa_for_user(&ath, user_id).await;
1363
1364 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
1365
1366 let body_str = format!("mfa_token={token}&recovery_code=AAAAAAAA&use_recovery=on");
1367 let req = Request::builder()
1368 .method("POST")
1369 .uri("/mfa/challenge")
1370 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1371 .body(Body::from(body_str))
1372 .unwrap();
1373 let resp = app.oneshot(req).await.unwrap();
1374
1375 assert_eq!(resp.status(), StatusCode::OK);
1376 let html = String::from_utf8(
1377 axum::body::to_bytes(resp.into_body(), usize::MAX)
1378 .await
1379 .unwrap()
1380 .to_vec(),
1381 )
1382 .unwrap();
1383 assert!(
1384 html.contains(CHALLENGE_INVALID_RECOVERY),
1385 "wrong recovery code must show recovery error"
1386 );
1387 }
1388}