//! Pages: login (GET/POST) and logout (POST). use std::time::{Duration, Instant}; use forge_core::AuditAction; use forge_core::names::normalize_name; use http::header::{HeaderValue, RETRY_AFTER}; use serde::Deserialize; use serde_json::json; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, see_other}; use topcoat::router::{HeaderMap, StatusCode, page, query_params, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{ check_csrf, check_pre_csrf, clear_pending_login, clear_pre_csrf, current_user, hash_password_async, login, logout, pending_login, pre_csrf_token, safe_next, set_pending_login, two_factor_enabled, verify_password_async, verify_second_factor, }; use crate::client_ip::client_ip; use crate::i18n::{self, Key, Lang, t}; use crate::state::AppState; use crate::throttle::{self, LOGIN_IP_LIMIT, LOGIN_USER_LIMIT, TWO_FACTOR_USER_LIMIT}; use crate::views::{alert, shell}; /// Hash checked when the username is unknown, so both paths spend one argon2 verify. /// Computed once, on the blocking pool. async fn dummy_hash() -> forge_core::Result<&'static str> { static DUMMY: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); DUMMY .get_or_try_init(|| hash_password_async("forge-dummy-password".to_owned())) .await .map(String::as_str) } #[query_params(error = bad_request)] struct LoginQuery { next: Option, } #[derive(Deserialize)] struct LoginForm { #[serde(default)] username: String, #[serde(default)] password: String, #[serde(default)] next: String, #[serde(default)] precsrf: String, } #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } #[derive(Deserialize)] struct TwoFactorForm { #[serde(default)] code: String, #[serde(default)] precsrf: String, } #[component] async fn login_form( lang: Lang, next: &str, username: &str, precsrf: &str, error: Option, ) -> Result { Ok(view! {

(t(lang, Key::LogIn))

if let Some(message) = error { alert(message: message.as_str()) }
}) } #[page("/login")] async fn login_page(cx: &Cx) -> Result { if current_user(cx).await?.is_some() { return Err(see_other("/").into()); } let next = query_params::(cx)?.next.clone(); let next = safe_next(next.as_deref()).to_owned(); let precsrf = pre_csrf_token(cx); let lang = i18n::lang(cx, None); Ok(view! { shell(title: t(lang, Key::LogIn), viewer: None, login_form(lang: lang, next: next.as_str(), username: "", precsrf: precsrf.as_str(), error: None)) }) } #[page(POST "/login")] async fn login_submit(cx: &Cx, Form(form): Form) -> Result { // Login CSRF: the pre-session token must match the cookie before anything else runs. if !check_pre_csrf(cx, &form.precsrf) { return Err(forbidden().into()); } let state = app_context::(cx); let lang = i18n::lang(cx, None); let limiter = &state.throttle; let now = Instant::now(); let ip = client_ip(cx); let folded = normalize_name(form.username.trim()); let user_key = throttle::user_key(&folded); let ip_key = throttle::ip_key("login-ip", ip); // Throttled attempts are rejected before any lookup or argon2 work, for known and unknown // usernames alike, so the response does not reveal which names exist. let blocked = limiter .check(&user_key, LOGIN_USER_LIMIT, now) .or_else(|| limiter.check(&ip_key, LOGIN_IP_LIMIT, now)); let mut headers = HeaderMap::new(); let (status, message) = if let Some(retry) = blocked { tracing::warn!( ip = %ip, username = %folded, retry_after_secs = retry.as_secs(), "login throttled" ); headers.insert(RETRY_AFTER, HeaderValue::from(retry_after_secs(retry))); audit::record( &state.store, Some(cx), Actor::Anonymous, AuditAction::LoginFailed, Target { name: form.username.trim(), ..Default::default() }, json!({ "reason": "throttled" }), ) .await; (StatusCode::TOO_MANY_REQUESTS, t(lang, Key::ErrThrottled)) } else { // The store folds the name, so the lookup is case-insensitive. // An organization is treated like an unknown name: it never signs in. let user = state .store .user_by_username(form.username.trim()) .await? .filter(|user| !user.is_org()); let hash = match &user { Some(user) => user.password_hash.clone(), None => dummy_hash().await?.to_owned(), }; let password_ok = verify_password_async(form.password.clone(), hash).await?; match user { Some(user) if password_ok => { limiter.clear(&user_key); // With two-factor on, no session yet: park the login until the code is checked. if two_factor_enabled(&state.store, &user.id).await? { let next = safe_next(Some(&form.next)).to_owned(); set_pending_login(cx, &state.key, &user.id, &next)?; return Err(see_other("/login/2fa").into()); } login(cx, &user).await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::LoginSuccess, Target::default(), serde_json::Value::Null, ) .await; clear_pre_csrf(cx); return Err(see_other(safe_next(Some(&form.next))).into()); } _ => { limiter.record_failure(&user_key, now); limiter.record_failure(&ip_key, now); // A known username is the target, so the account owner sees the attempt. let target = match &user { Some(user) => Target { kind: "user", id: &user.id, name: &user.username, }, None => Target { name: form.username.trim(), ..Default::default() }, }; audit::record( &state.store, Some(cx), Actor::Anonymous, AuditAction::LoginFailed, target, serde_json::Value::Null, ) .await; (StatusCode::OK, t(lang, Key::ErrInvalidLogin)) } } }; let next = safe_next(Some(&form.next)).to_owned(); let username = form.username.clone(); let precsrf = form.precsrf.clone(); // The status and headers are view parts, so they apply to this page's response. Ok(view! { (status) (headers) shell(title: t(lang, Key::LogIn), viewer: None, login_form(lang: lang, next: next.as_str(), username: username.as_str(), precsrf: precsrf.as_str(), error: Some(message.to_owned()))) }) } #[component] async fn two_factor_form(lang: Lang, precsrf: &str, error: Option) -> Result { Ok(view! {

(t(lang, Key::TwoFactorCheck))

(t(lang, Key::TwoFactorCheckSub))

if let Some(message) = error { alert(message: message.as_str()) }
}) } /// The second-factor step. Needs a live pending-login cookie; otherwise back to `/login`. #[page("/login/2fa")] async fn two_factor_page(cx: &Cx) -> Result { let state = app_context::(cx); if pending_login(cx, &state.key).is_none() { return Err(see_other("/login").into()); } let precsrf = pre_csrf_token(cx); let lang = i18n::lang(cx, None); Ok(view! { shell(title: t(lang, Key::TwoFactorCheck), viewer: None, two_factor_form(lang: lang, precsrf: precsrf.as_str(), error: None)) }) } #[page(POST "/login/2fa")] async fn two_factor_submit(cx: &Cx, Form(form): Form) -> Result { if !check_pre_csrf(cx, &form.precsrf) { return Err(forbidden().into()); } let state = app_context::(cx); let lang = i18n::lang(cx, None); let Some(pending) = pending_login(cx, &state.key) else { return Err(see_other("/login").into()); }; let now = Instant::now(); let key = throttle::two_factor_key(&pending.user_id); let mut headers = HeaderMap::new(); let (status, message) = if let Some(retry) = state.throttle.check(&key, TWO_FACTOR_USER_LIMIT, now) { tracing::warn!(user_id = %pending.user_id, "second factor throttled"); headers.insert(RETRY_AFTER, HeaderValue::from(retry_after_secs(retry))); (StatusCode::TOO_MANY_REQUESTS, t(lang, Key::ErrThrottled)) } else { let user = state .store .user_by_id(&pending.user_id) .await? .filter(|user| !user.is_org()); let verified = match &user { Some(_) => { verify_second_factor(&state.store, &state.key, &pending.user_id, &form.code).await? } None => false, }; match user { Some(user) if verified => { state.throttle.clear(&key); login(cx, &user).await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::LoginSuccess, Target::default(), json!({ "second_factor": true }), ) .await; clear_pending_login(cx); clear_pre_csrf(cx); return Err(see_other(safe_next(Some(&pending.next))).into()); } _ => { state.throttle.record_failure(&key, now); if let Some(user) = &user { audit::record( &state.store, Some(cx), Actor::User(user), AuditAction::Login2faFailed, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::Value::Null, ) .await; } (StatusCode::OK, t(lang, Key::ErrInvalidCode)) } } }; let precsrf = form.precsrf.clone(); Ok(view! { (status) (headers) shell(title: t(lang, Key::TwoFactorCheck), viewer: None, two_factor_form(lang: lang, precsrf: precsrf.as_str(), error: Some(message.to_owned()))) }) } /// Whole seconds for a `Retry-After` header, at least 1. fn retry_after_secs(retry: Duration) -> u64 { retry.as_secs().max(1) } #[route(POST "/logout")] async fn logout_submit(cx: &Cx, Form(form): Form) -> Result { if let Some((user, session)) = current_user(cx).await? { if !check_csrf(&session, &form.csrf) { return Err(forbidden().into()); } let state = app_context::(cx); logout(cx, &session).await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::Logout, Target::default(), serde_json::Value::Null, ) .await; } Ok(see_other("/")) } #[cfg(test)] mod tests { use std::net::SocketAddr; use std::path::Path; use topcoat::router::request::Request; use topcoat::router::{Body, RemoteAddr, Router, to_bytes}; use forge_core::{secret, totp}; use crate::auth::hash_password_async; use crate::state::AppState; use crate::throttle::TWO_FACTOR_USER_LIMIT; const PRECSRF_COOKIE: &str = "__Host-forge_precsrf"; const PASSWORD: &str = "correct horse battery"; async fn app(dir: &Path) -> (Router, AppState) { let state = crate::test_support::test_state(dir).await; (crate::test_support::app_router(state.clone()), state) } /// Fetches `/login` from `peer` and returns the pre-session cookie pair and CSRF token. async fn login_page(router: &Router, peer: SocketAddr) -> (String, String) { let req = Request::builder() .method("GET") .uri("/login") .extension(RemoteAddr(peer)) .body(Body::empty()) .unwrap(); let res = router.handle(req).await; let cookie = res .headers() .get_all("set-cookie") .iter() .filter_map(|v| v.to_str().ok()) .find(|raw| raw.starts_with(PRECSRF_COOKIE)) .map(|raw| raw.split(';').next().unwrap().to_owned()) .expect("precsrf cookie"); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); let html = String::from_utf8_lossy(&bytes).into_owned(); let marker = "name=\"precsrf\" value=\""; let start = html.find(marker).expect("precsrf field") + marker.len(); let end = html[start..].find('"').unwrap() + start; (cookie, html[start..end].to_owned()) } /// Posts a login form from `peer`. Returns the status and the response body. async fn post_login( router: &Router, peer: SocketAddr, (cookie, precsrf): (&str, &str), username: &str, password: &str, ) -> (u16, String) { let form = format!("username={username}&password={password}&next=%2F&precsrf={precsrf}"); let req = Request::builder() .method("POST") .uri("/login") .header("cookie", cookie) .header("content-type", "application/x-www-form-urlencoded") .extension(RemoteAddr(peer)) .body(Body::from(form)) .unwrap(); let res = router.handle(req).await; let status = res.status().as_u16(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); (status, String::from_utf8_lossy(&bytes).into_owned()) } fn peer(last: u8) -> SocketAddr { SocketAddr::from(([192, 0, 2, last], 40000)) } async fn with_user(state: &AppState, name: &str) { let hash = hash_password_async(PASSWORD.to_owned()).await.unwrap(); state .store .create_user(name, None, &hash, false) .await .unwrap(); } /// A per-test data dir. The returned guard deletes the directory when dropped, so the /// caller must keep it alive for the whole test. fn scratch(_name: &str) -> tempfile::TempDir { tempfile::tempdir().expect("tempdir") } #[tokio::test] async fn eleventh_attempt_for_a_user_is_throttled_even_with_the_right_password() { let tmp = scratch("user-limit"); let (router, state) = app(tmp.path()).await; with_user(&state, "alice").await; let from = peer(10); let token = login_page(&router, from).await; for _ in 0..10 { let (status, body) = post_login(&router, from, (&token.0, &token.1), "alice", "wrong").await; assert_eq!(status, 200); assert!(body.contains("invalid username or password")); } let (status, body) = post_login(&router, from, (&token.0, &token.1), "alice", PASSWORD).await; assert_eq!(status, 429); assert!(body.contains("Too many attempts. Try again later.")); assert!(!body.contains("invalid username or password")); } #[tokio::test] async fn failed_and_successful_logins_are_audited_without_the_password() { use forge_core::{AuditAction, AuditFilter}; let tmp = scratch("audit-login"); let (router, state) = app(tmp.path()).await; with_user(&state, "alice").await; let from = peer(40); let token = login_page(&router, from).await; let guess = "hunter2-not-the-password"; post_login(&router, from, (&token.0, &token.1), "alice", guess).await; post_login(&router, from, (&token.0, &token.1), "ghost", guess).await; let (status, _) = post_login(&router, from, (&token.0, &token.1), "alice", PASSWORD).await; assert_eq!(status, 303); let alice = state .store .user_by_username("alice") .await .unwrap() .unwrap(); let failed = AuditFilter { action: Some(AuditAction::LoginFailed), ..Default::default() }; let rows = state.store.list_audit(&failed, None, 10).await.unwrap(); assert_eq!(rows.len(), 2); assert!( rows.iter() .any(|r| r.target_id == alice.id && r.target_name == "alice") ); assert!( rows.iter() .any(|r| r.target_name == "ghost" && r.actor_id.is_none()) ); let success = AuditFilter { action: Some(AuditAction::LoginSuccess), ..Default::default() }; let ok = state.store.list_audit(&success, None, 10).await.unwrap(); assert_eq!(ok.len(), 1); assert_eq!(ok[0].actor_id.as_deref(), Some(alice.id.as_str())); // The password guess must not appear anywhere in the log. let all = state .store .list_audit(&AuditFilter::default(), None, 100) .await .unwrap(); for row in &all { assert!(!format!("{row:?}").contains(guess)); assert!(!format!("{row:?}").contains(PASSWORD)); } } #[tokio::test] async fn other_users_from_the_same_ip_still_log_in() { let tmp = scratch("other-user"); let (router, state) = app(tmp.path()).await; with_user(&state, "alice").await; with_user(&state, "bob").await; let from = peer(11); let token = login_page(&router, from).await; for _ in 0..10 { post_login(&router, from, (&token.0, &token.1), "alice", "wrong").await; } let (status, _) = post_login(&router, from, (&token.0, &token.1), "bob", PASSWORD).await; assert_eq!(status, 303); } #[tokio::test] async fn successful_login_resets_the_user_counter() { let tmp = scratch("reset"); let (router, state) = app(tmp.path()).await; with_user(&state, "alice").await; let from = peer(12); let token = login_page(&router, from).await; for _ in 0..9 { post_login(&router, from, (&token.0, &token.1), "alice", "wrong").await; } let (status, _) = post_login(&router, from, (&token.0, &token.1), "alice", PASSWORD).await; assert_eq!(status, 303); for _ in 0..9 { post_login(&router, from, (&token.0, &token.1), "alice", "wrong").await; } let (status, _) = post_login(&router, from, (&token.0, &token.1), "alice", PASSWORD).await; assert_eq!(status, 303); } #[tokio::test] async fn ip_limit_applies_across_usernames() { let tmp = scratch("ip-limit"); let (router, _state) = app(tmp.path()).await; let from = peer(13); let token = login_page(&router, from).await; for i in 0..30 { let name = format!("ghost{i}"); let (status, _) = post_login(&router, from, (&token.0, &token.1), &name, "x").await; assert_eq!(status, 200); } let (status, body) = post_login(&router, from, (&token.0, &token.1), "ghost-new", "x").await; assert_eq!(status, 429); assert!(body.contains("Too many attempts. Try again later.")); } #[tokio::test] async fn unknown_and_known_users_get_the_same_response() { let tmp = scratch("uniform"); let (router, state) = app(tmp.path()).await; with_user(&state, "alice").await; let from = peer(14); let token = login_page(&router, from).await; // The body echoes the typed username, so compare status and the generic message only. for name in ["alice", "nobody"] { let (status, body) = post_login(&router, from, (&token.0, &token.1), name, "wrong").await; assert_eq!(status, 200); assert!(body.contains("invalid username or password")); } } /// Reply parts the tests look at. struct Reply { status: u16, location: Option, cookies: Vec, body: String, } async fn send(router: &Router, req: Request) -> Reply { let res = router.handle(req).await; let status = res.status().as_u16(); let location = res .headers() .get("location") .and_then(|v| v.to_str().ok()) .map(str::to_owned); let cookies = res .headers() .get_all("set-cookie") .iter() .filter_map(|v| v.to_str().ok()) .map(|raw| raw.split(';').next().unwrap_or("").to_owned()) .collect(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); Reply { status, location, cookies, body: String::from_utf8_lossy(&bytes).into_owned(), } } /// The `name=value` pair of a cookie from a reply, if it set one. fn pair(reply: &Reply, name: &str) -> Option { reply .cookies .iter() .find(|c| c.starts_with(&format!("{name}="))) .cloned() } /// Password step for `username`. Returns the reply. async fn password_step( router: &Router, peer: SocketAddr, (cookie, precsrf): (&str, &str), username: &str, password: &str, ) -> Reply { let form = format!("username={username}&password={password}&next=%2Fdash&precsrf={precsrf}"); let req = Request::builder() .method("POST") .uri("/login") .header("cookie", cookie) .header("content-type", "application/x-www-form-urlencoded") .extension(RemoteAddr(peer)) .body(Body::from(form)) .unwrap(); send(router, req).await } /// Second-factor step with the given pending-login cookie. async fn second_factor_step( router: &Router, peer: SocketAddr, cookies: &str, precsrf: &str, code: &str, ) -> Reply { let form = format!("code={code}&precsrf={precsrf}"); let req = Request::builder() .method("POST") .uri("/login/2fa") .header("cookie", cookies) .header("content-type", "application/x-www-form-urlencoded") .extension(RemoteAddr(peer)) .body(Body::from(form)) .unwrap(); send(router, req).await } /// Creates a user with two-factor on and one recovery code, `aaaa-bbbb`. Returns the secret. async fn totp_user(state: &AppState, name: &str) -> Vec { with_user(state, name).await; let user = state.store.user_by_username(name).await.unwrap().unwrap(); let shared = totp::new_secret(); let sealed = secret::seal(&state.key, &shared).unwrap(); state .store .set_totp_secret(&user.id, &sealed) .await .unwrap(); state .store .enable_totp(&user.id, 0, &[totp::recovery_code_hash("aaaa-bbbb")]) .await .unwrap(); shared.to_vec() } /// Cookie header for the pending login plus the pre-session CSRF cookie. fn both(pending: &str, precsrf_cookie: &str) -> String { format!("{pending}; {precsrf_cookie}") } #[tokio::test] async fn totp_user_gets_a_pending_cookie_and_no_session() { let tmp = scratch("totp-pending"); let (router, state) = app(tmp.path()).await; totp_user(&state, "alice").await; let from = peer(20); let (cookie, precsrf) = login_page(&router, from).await; let reply = password_step(&router, from, (&cookie, &precsrf), "alice", PASSWORD).await; assert_eq!(reply.status, 303); assert_eq!(reply.location.as_deref(), Some("/login/2fa")); assert!(pair(&reply, "__Host-forge_2fa").is_some()); assert!(pair(&reply, "__Host-forge_session").is_none()); let page = send( &router, Request::builder() .method("GET") .uri("/login/2fa") .header( "cookie", both(&pair(&reply, "__Host-forge_2fa").unwrap(), &cookie), ) .extension(RemoteAddr(from)) .body(Body::empty()) .unwrap(), ) .await; assert_eq!(page.status, 200); assert!(page.body.contains("Two-factor check")); } #[tokio::test] async fn two_factor_page_without_pending_cookie_goes_to_login() { let tmp = scratch("totp-no-pending"); let (router, _state) = app(tmp.path()).await; let reply = send( &router, Request::builder() .method("GET") .uri("/login/2fa") .extension(RemoteAddr(peer(21))) .body(Body::empty()) .unwrap(), ) .await; assert_eq!(reply.status, 303); assert_eq!(reply.location.as_deref(), Some("/login")); } #[tokio::test] async fn wrong_code_fails_right_code_logs_in_and_replay_fails() { let tmp = scratch("totp-flow"); let (router, state) = app(tmp.path()).await; let shared = totp_user(&state, "alice").await; let from = peer(22); let (cookie, precsrf) = login_page(&router, from).await; let step = password_step(&router, from, (&cookie, &precsrf), "alice", PASSWORD).await; let pending = both(&pair(&step, "__Host-forge_2fa").unwrap(), &cookie); let wrong = second_factor_step(&router, from, &pending, &precsrf, "000000").await; // The chance that 000000 is the live code is about 1 in a million per window. if wrong.status == 200 { assert!(wrong.body.contains("That code is not right")); } let now_step = totp::step_for( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(), ); let code = totp::code_at(&shared, now_step); let ok = second_factor_step(&router, from, &pending, &precsrf, &code).await; assert_eq!(ok.status, 303, "body {}", ok.body); assert_eq!(ok.location.as_deref(), Some("/dash")); assert!(pair(&ok, "__Host-forge_session").is_some()); assert!(ok.cookies.iter().any(|c| c.starts_with("__Host-forge_2fa=") && c.len() == "__Host-forge_2fa=".len())); // The same code again, from a fresh password step, is a replay and must fail. let (cookie2, precsrf2) = login_page(&router, from).await; let step2 = password_step(&router, from, (&cookie2, &precsrf2), "alice", PASSWORD).await; let pending2 = both(&pair(&step2, "__Host-forge_2fa").unwrap(), &cookie2); let replay = second_factor_step(&router, from, &pending2, &precsrf2, &code).await; assert_eq!(replay.status, 200); assert!(replay.body.contains("That code is not right")); assert!(pair(&replay, "__Host-forge_session").is_none()); } #[tokio::test] async fn recovery_code_works_once() { let tmp = scratch("totp-recovery"); let (router, state) = app(tmp.path()).await; totp_user(&state, "alice").await; let from = peer(23); let (cookie, precsrf) = login_page(&router, from).await; let step = password_step(&router, from, (&cookie, &precsrf), "alice", PASSWORD).await; let pending = both(&pair(&step, "__Host-forge_2fa").unwrap(), &cookie); let first = second_factor_step(&router, from, &pending, &precsrf, "AAAA-bbbb").await; assert_eq!(first.status, 303); assert!(pair(&first, "__Host-forge_session").is_some()); let (cookie2, precsrf2) = login_page(&router, from).await; let step2 = password_step(&router, from, (&cookie2, &precsrf2), "alice", PASSWORD).await; let pending2 = both(&pair(&step2, "__Host-forge_2fa").unwrap(), &cookie2); let second = second_factor_step(&router, from, &pending2, &precsrf2, "aaaabbbb").await; assert_eq!(second.status, 200); assert!(pair(&second, "__Host-forge_session").is_none()); } #[tokio::test] async fn expired_pending_cookie_is_rejected() { let tmp = scratch("totp-expired"); let (router, state) = app(tmp.path()).await; let shared = totp_user(&state, "alice").await; let user = state .store .user_by_username("alice") .await .unwrap() .unwrap(); let from = peer(24); let (cookie, precsrf) = login_page(&router, from).await; // A correctly sealed pending cookie whose expiry is in the past. let stale = secret::seal(&state.key, format!("1|{}|/", user.id).as_bytes()).unwrap(); let pending = format!("__Host-forge_2fa={stale}"); let code = totp::code_at( &shared, totp::step_for( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(), ), ); let reply = second_factor_step(&router, from, &both(&pending, &cookie), &precsrf, &code).await; assert_eq!(reply.status, 303); assert_eq!(reply.location.as_deref(), Some("/login")); assert!(pair(&reply, "__Host-forge_session").is_none()); } #[tokio::test] async fn second_factor_attempts_are_throttled_per_user() { let tmp = scratch("totp-throttle"); let (router, state) = app(tmp.path()).await; let shared = totp_user(&state, "alice").await; let from = peer(25); let (cookie, precsrf) = login_page(&router, from).await; let step = password_step(&router, from, (&cookie, &precsrf), "alice", PASSWORD).await; let pending = both(&pair(&step, "__Host-forge_2fa").unwrap(), &cookie); for _ in 0..TWO_FACTOR_USER_LIMIT { let reply = second_factor_step(&router, from, &pending, &precsrf, "111111").await; assert_eq!(reply.status, 200); } let code = totp::code_at( &shared, totp::step_for( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(), ), ); let blocked = second_factor_step(&router, from, &pending, &precsrf, &code).await; assert_eq!(blocked.status, 429); assert!(blocked.body.contains("Too many attempts")); } }