forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
32.4 KiB
1//! Pages: login (GET/POST) and logout (POST).
2
3use std::time::{Duration, Instant};
4
5use forge_core::AuditAction;
6use forge_core::names::normalize_name;
7use http::header::{HeaderValue, RETRY_AFTER};
8use serde::Deserialize;
9use serde_json::json;
10use topcoat::Result;
11use topcoat::context::{Cx, app_context};
12use topcoat::router::content::Form;
13use topcoat::router::error::{SeeOther, forbidden, see_other};
14use topcoat::router::{HeaderMap, StatusCode, page, query_params, route};
15use topcoat::view::{View, component, view};
16
17use crate::audit::{self, Actor, Target};
18use crate::auth::{
19 check_csrf, check_pre_csrf, clear_pending_login, clear_pre_csrf, current_user,
20 hash_password_async, login, logout, pending_login, pre_csrf_token, safe_next,
21 set_pending_login, two_factor_enabled, verify_password_async, verify_second_factor,
22};
23use crate::client_ip::client_ip;
24use crate::i18n::{self, Key, Lang, t};
25use crate::state::AppState;
26use crate::throttle::{self, LOGIN_IP_LIMIT, LOGIN_USER_LIMIT, TWO_FACTOR_USER_LIMIT};
27use crate::views::{alert, shell};
28
29/// Hash checked when the username is unknown, so both paths spend one argon2 verify.
30/// Computed once, on the blocking pool.
31async fn dummy_hash() -> forge_core::Result<&'static str> {
32 static DUMMY: tokio::sync::OnceCell<String> = tokio::sync::OnceCell::const_new();
33 DUMMY
34 .get_or_try_init(|| hash_password_async("forge-dummy-password".to_owned()))
35 .await
36 .map(String::as_str)
37}
38
39#[query_params(error = bad_request)]
40struct LoginQuery {
41 next: Option<String>,
42}
43
44#[derive(Deserialize)]
45struct LoginForm {
46 #[serde(default)]
47 username: String,
48 #[serde(default)]
49 password: String,
50 #[serde(default)]
51 next: String,
52 #[serde(default)]
53 precsrf: String,
54}
55
56#[derive(Deserialize)]
57struct CsrfForm {
58 #[serde(default)]
59 csrf: String,
60}
61
62#[derive(Deserialize)]
63struct TwoFactorForm {
64 #[serde(default)]
65 code: String,
66 #[serde(default)]
67 precsrf: String,
68}
69
70#[component]
71async fn login_form(
72 lang: Lang,
73 next: &str,
74 username: &str,
75 precsrf: &str,
76 error: Option<String>,
77) -> Result<impl View> {
78 Ok(view! {
79 <section class="card narrow">
80 <h1>(t(lang, Key::LogIn))</h1>
81 if let Some(message) = error {
82 alert(message: message.as_str())
83 }
84 <form data-hard="true" method="post" action="/login" class="stack">
85 <input type="hidden" name="next" value=(next)>
86 <input type="hidden" name="precsrf" value=(precsrf)>
87 <label>
88 <span>(t(lang, Key::Username))</span>
89 <input type="text" name="username" value=(username) autocomplete="username" required=(true)>
90 </label>
91 <label>
92 <span>(t(lang, Key::Password))</span>
93 <input type="password" name="password" autocomplete="current-password" required=(true)>
94 </label>
95 <button type="submit" class="primary">(t(lang, Key::LogIn))</button>
96 </form>
97 </section>
98 })
99}
100
101#[page("/login")]
102async fn login_page(cx: &Cx) -> Result<impl View> {
103 if current_user(cx).await?.is_some() {
104 return Err(see_other("/").into());
105 }
106 let next = query_params::<LoginQuery>(cx)?.next.clone();
107 let next = safe_next(next.as_deref()).to_owned();
108 let precsrf = pre_csrf_token(cx);
109 let lang = i18n::lang(cx, None);
110 Ok(view! {
111 shell(title: t(lang, Key::LogIn), viewer: None, login_form(lang: lang, next: next.as_str(), username: "", precsrf: precsrf.as_str(), error: None))
112 })
113}
114
115#[page(POST "/login")]
116async fn login_submit(cx: &Cx, Form(form): Form<LoginForm>) -> Result<impl View> {
117 // Login CSRF: the pre-session token must match the cookie before anything else runs.
118 if !check_pre_csrf(cx, &form.precsrf) {
119 return Err(forbidden().into());
120 }
121 let state = app_context::<AppState>(cx);
122 let lang = i18n::lang(cx, None);
123 let limiter = &state.throttle;
124 let now = Instant::now();
125 let ip = client_ip(cx);
126 let folded = normalize_name(form.username.trim());
127 let user_key = throttle::user_key(&folded);
128 let ip_key = throttle::ip_key("login-ip", ip);
129
130 // Throttled attempts are rejected before any lookup or argon2 work, for known and unknown
131 // usernames alike, so the response does not reveal which names exist.
132 let blocked = limiter
133 .check(&user_key, LOGIN_USER_LIMIT, now)
134 .or_else(|| limiter.check(&ip_key, LOGIN_IP_LIMIT, now));
135
136 let mut headers = HeaderMap::new();
137 let (status, message) = if let Some(retry) = blocked {
138 tracing::warn!(
139 ip = %ip,
140 username = %folded,
141 retry_after_secs = retry.as_secs(),
142 "login throttled"
143 );
144 headers.insert(RETRY_AFTER, HeaderValue::from(retry_after_secs(retry)));
145 audit::record(
146 &state.store,
147 Some(cx),
148 Actor::Anonymous,
149 AuditAction::LoginFailed,
150 Target {
151 name: form.username.trim(),
152 ..Default::default()
153 },
154 json!({ "reason": "throttled" }),
155 )
156 .await;
157 (StatusCode::TOO_MANY_REQUESTS, t(lang, Key::ErrThrottled))
158 } else {
159 // The store folds the name, so the lookup is case-insensitive.
160 // An organization is treated like an unknown name: it never signs in.
161 let user = state
162 .store
163 .user_by_username(form.username.trim())
164 .await?
165 .filter(|user| !user.is_org());
166 let hash = match &user {
167 Some(user) => user.password_hash.clone(),
168 None => dummy_hash().await?.to_owned(),
169 };
170 let password_ok = verify_password_async(form.password.clone(), hash).await?;
171
172 match user {
173 Some(user) if password_ok => {
174 limiter.clear(&user_key);
175 // With two-factor on, no session yet: park the login until the code is checked.
176 if two_factor_enabled(&state.store, &user.id).await? {
177 let next = safe_next(Some(&form.next)).to_owned();
178 set_pending_login(cx, &state.key, &user.id, &next)?;
179 return Err(see_other("/login/2fa").into());
180 }
181 login(cx, &user).await?;
182 audit::record(
183 &state.store,
184 Some(cx),
185 Actor::User(&user),
186 AuditAction::LoginSuccess,
187 Target::default(),
188 serde_json::Value::Null,
189 )
190 .await;
191 clear_pre_csrf(cx);
192 return Err(see_other(safe_next(Some(&form.next))).into());
193 }
194 _ => {
195 limiter.record_failure(&user_key, now);
196 limiter.record_failure(&ip_key, now);
197 // A known username is the target, so the account owner sees the attempt.
198 let target = match &user {
199 Some(user) => Target {
200 kind: "user",
201 id: &user.id,
202 name: &user.username,
203 },
204 None => Target {
205 name: form.username.trim(),
206 ..Default::default()
207 },
208 };
209 audit::record(
210 &state.store,
211 Some(cx),
212 Actor::Anonymous,
213 AuditAction::LoginFailed,
214 target,
215 serde_json::Value::Null,
216 )
217 .await;
218 (StatusCode::OK, t(lang, Key::ErrInvalidLogin))
219 }
220 }
221 };
222
223 let next = safe_next(Some(&form.next)).to_owned();
224 let username = form.username.clone();
225 let precsrf = form.precsrf.clone();
226 // The status and headers are view parts, so they apply to this page's response.
227 Ok(view! {
228 (status)
229 (headers)
230 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())))
231 })
232}
233
234#[component]
235async fn two_factor_form(lang: Lang, precsrf: &str, error: Option<String>) -> Result<impl View> {
236 Ok(view! {
237 <section class="card narrow">
238 <h1>(t(lang, Key::TwoFactorCheck))</h1>
239 <p class="muted">(t(lang, Key::TwoFactorCheckSub))</p>
240 if let Some(message) = error {
241 alert(message: message.as_str())
242 }
243 <form data-hard="true" method="post" action="/login/2fa" class="stack">
244 <input type="hidden" name="precsrf" value=(precsrf)>
245 <label>
246 <span>(t(lang, Key::Code))</span>
247 <input type="text" name="code" autocomplete="one-time-code" required=(true)>
248 </label>
249 <button type="submit" class="primary">(t(lang, Key::Verify))</button>
250 </form>
251 </section>
252 })
253}
254
255/// The second-factor step. Needs a live pending-login cookie; otherwise back to `/login`.
256#[page("/login/2fa")]
257async fn two_factor_page(cx: &Cx) -> Result<impl View> {
258 let state = app_context::<AppState>(cx);
259 if pending_login(cx, &state.key).is_none() {
260 return Err(see_other("/login").into());
261 }
262 let precsrf = pre_csrf_token(cx);
263 let lang = i18n::lang(cx, None);
264 Ok(view! {
265 shell(title: t(lang, Key::TwoFactorCheck), viewer: None, two_factor_form(lang: lang, precsrf: precsrf.as_str(), error: None))
266 })
267}
268
269#[page(POST "/login/2fa")]
270async fn two_factor_submit(cx: &Cx, Form(form): Form<TwoFactorForm>) -> Result<impl View> {
271 if !check_pre_csrf(cx, &form.precsrf) {
272 return Err(forbidden().into());
273 }
274 let state = app_context::<AppState>(cx);
275 let lang = i18n::lang(cx, None);
276 let Some(pending) = pending_login(cx, &state.key) else {
277 return Err(see_other("/login").into());
278 };
279 let now = Instant::now();
280 let key = throttle::two_factor_key(&pending.user_id);
281 let mut headers = HeaderMap::new();
282 let (status, message) = if let Some(retry) =
283 state.throttle.check(&key, TWO_FACTOR_USER_LIMIT, now)
284 {
285 tracing::warn!(user_id = %pending.user_id, "second factor throttled");
286 headers.insert(RETRY_AFTER, HeaderValue::from(retry_after_secs(retry)));
287 (StatusCode::TOO_MANY_REQUESTS, t(lang, Key::ErrThrottled))
288 } else {
289 let user = state
290 .store
291 .user_by_id(&pending.user_id)
292 .await?
293 .filter(|user| !user.is_org());
294 let verified = match &user {
295 Some(_) => {
296 verify_second_factor(&state.store, &state.key, &pending.user_id, &form.code).await?
297 }
298 None => false,
299 };
300 match user {
301 Some(user) if verified => {
302 state.throttle.clear(&key);
303 login(cx, &user).await?;
304 audit::record(
305 &state.store,
306 Some(cx),
307 Actor::User(&user),
308 AuditAction::LoginSuccess,
309 Target::default(),
310 json!({ "second_factor": true }),
311 )
312 .await;
313 clear_pending_login(cx);
314 clear_pre_csrf(cx);
315 return Err(see_other(safe_next(Some(&pending.next))).into());
316 }
317 _ => {
318 state.throttle.record_failure(&key, now);
319 if let Some(user) = &user {
320 audit::record(
321 &state.store,
322 Some(cx),
323 Actor::User(user),
324 AuditAction::Login2faFailed,
325 Target {
326 kind: "user",
327 id: &user.id,
328 name: &user.username,
329 },
330 serde_json::Value::Null,
331 )
332 .await;
333 }
334 (StatusCode::OK, t(lang, Key::ErrInvalidCode))
335 }
336 }
337 };
338 let precsrf = form.precsrf.clone();
339 Ok(view! {
340 (status)
341 (headers)
342 shell(title: t(lang, Key::TwoFactorCheck), viewer: None, two_factor_form(lang: lang, precsrf: precsrf.as_str(), error: Some(message.to_owned())))
343 })
344}
345
346/// Whole seconds for a `Retry-After` header, at least 1.
347fn retry_after_secs(retry: Duration) -> u64 {
348 retry.as_secs().max(1)
349}
350
351#[route(POST "/logout")]
352async fn logout_submit(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
353 if let Some((user, session)) = current_user(cx).await? {
354 if !check_csrf(&session, &form.csrf) {
355 return Err(forbidden().into());
356 }
357 let state = app_context::<AppState>(cx);
358 logout(cx, &session).await?;
359 audit::record(
360 &state.store,
361 Some(cx),
362 Actor::User(&user),
363 AuditAction::Logout,
364 Target::default(),
365 serde_json::Value::Null,
366 )
367 .await;
368 }
369 Ok(see_other("/"))
370}
371
372#[cfg(test)]
373mod tests {
374 use std::net::SocketAddr;
375 use std::path::Path;
376
377 use topcoat::router::request::Request;
378 use topcoat::router::{Body, RemoteAddr, Router, to_bytes};
379
380 use forge_core::{secret, totp};
381
382 use crate::auth::hash_password_async;
383 use crate::state::AppState;
384 use crate::throttle::TWO_FACTOR_USER_LIMIT;
385
386 const PRECSRF_COOKIE: &str = "__Host-forge_precsrf";
387 const PASSWORD: &str = "correct horse battery";
388
389 async fn app(dir: &Path) -> (Router, AppState) {
390 let state = crate::test_support::test_state(dir).await;
391 (crate::test_support::app_router(state.clone()), state)
392 }
393
394 /// Fetches `/login` from `peer` and returns the pre-session cookie pair and CSRF token.
395 async fn login_page(router: &Router, peer: SocketAddr) -> (String, String) {
396 let req = Request::builder()
397 .method("GET")
398 .uri("/login")
399 .extension(RemoteAddr(peer))
400 .body(Body::empty())
401 .unwrap();
402 let res = router.handle(req).await;
403 let cookie = res
404 .headers()
405 .get_all("set-cookie")
406 .iter()
407 .filter_map(|v| v.to_str().ok())
408 .find(|raw| raw.starts_with(PRECSRF_COOKIE))
409 .map(|raw| raw.split(';').next().unwrap().to_owned())
410 .expect("precsrf cookie");
411 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
412 let html = String::from_utf8_lossy(&bytes).into_owned();
413 let marker = "name=\"precsrf\" value=\"";
414 let start = html.find(marker).expect("precsrf field") + marker.len();
415 let end = html[start..].find('"').unwrap() + start;
416 (cookie, html[start..end].to_owned())
417 }
418
419 /// Posts a login form from `peer`. Returns the status and the response body.
420 async fn post_login(
421 router: &Router,
422 peer: SocketAddr,
423 (cookie, precsrf): (&str, &str),
424 username: &str,
425 password: &str,
426 ) -> (u16, String) {
427 let form = format!("username={username}&password={password}&next=%2F&precsrf={precsrf}");
428 let req = Request::builder()
429 .method("POST")
430 .uri("/login")
431 .header("cookie", cookie)
432 .header("content-type", "application/x-www-form-urlencoded")
433 .extension(RemoteAddr(peer))
434 .body(Body::from(form))
435 .unwrap();
436 let res = router.handle(req).await;
437 let status = res.status().as_u16();
438 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
439 (status, String::from_utf8_lossy(&bytes).into_owned())
440 }
441
442 fn peer(last: u8) -> SocketAddr {
443 SocketAddr::from(([192, 0, 2, last], 40000))
444 }
445
446 async fn with_user(state: &AppState, name: &str) {
447 let hash = hash_password_async(PASSWORD.to_owned()).await.unwrap();
448 state
449 .store
450 .create_user(name, None, &hash, false)
451 .await
452 .unwrap();
453 }
454
455 /// A per-test data dir. The returned guard deletes the directory when dropped, so the
456 /// caller must keep it alive for the whole test.
457 fn scratch(_name: &str) -> tempfile::TempDir {
458 tempfile::tempdir().expect("tempdir")
459 }
460
461 #[tokio::test]
462 async fn eleventh_attempt_for_a_user_is_throttled_even_with_the_right_password() {
463 let tmp = scratch("user-limit");
464 let (router, state) = app(tmp.path()).await;
465 with_user(&state, "alice").await;
466 let from = peer(10);
467 let token = login_page(&router, from).await;
468
469 for _ in 0..10 {
470 let (status, body) =
471 post_login(&router, from, (&token.0, &token.1), "alice", "wrong").await;
472 assert_eq!(status, 200);
473 assert!(body.contains("invalid username or password"));
474 }
475 let (status, body) =
476 post_login(&router, from, (&token.0, &token.1), "alice", PASSWORD).await;
477 assert_eq!(status, 429);
478 assert!(body.contains("Too many attempts. Try again later."));
479 assert!(!body.contains("invalid username or password"));
480 }
481
482 #[tokio::test]
483 async fn failed_and_successful_logins_are_audited_without_the_password() {
484 use forge_core::{AuditAction, AuditFilter};
485
486 let tmp = scratch("audit-login");
487 let (router, state) = app(tmp.path()).await;
488 with_user(&state, "alice").await;
489 let from = peer(40);
490 let token = login_page(&router, from).await;
491 let guess = "hunter2-not-the-password";
492
493 post_login(&router, from, (&token.0, &token.1), "alice", guess).await;
494 post_login(&router, from, (&token.0, &token.1), "ghost", guess).await;
495 let (status, _) = post_login(&router, from, (&token.0, &token.1), "alice", PASSWORD).await;
496 assert_eq!(status, 303);
497
498 let alice = state
499 .store
500 .user_by_username("alice")
501 .await
502 .unwrap()
503 .unwrap();
504 let failed = AuditFilter {
505 action: Some(AuditAction::LoginFailed),
506 ..Default::default()
507 };
508 let rows = state.store.list_audit(&failed, None, 10).await.unwrap();
509 assert_eq!(rows.len(), 2);
510 assert!(
511 rows.iter()
512 .any(|r| r.target_id == alice.id && r.target_name == "alice")
513 );
514 assert!(
515 rows.iter()
516 .any(|r| r.target_name == "ghost" && r.actor_id.is_none())
517 );
518
519 let success = AuditFilter {
520 action: Some(AuditAction::LoginSuccess),
521 ..Default::default()
522 };
523 let ok = state.store.list_audit(&success, None, 10).await.unwrap();
524 assert_eq!(ok.len(), 1);
525 assert_eq!(ok[0].actor_id.as_deref(), Some(alice.id.as_str()));
526
527 // The password guess must not appear anywhere in the log.
528 let all = state
529 .store
530 .list_audit(&AuditFilter::default(), None, 100)
531 .await
532 .unwrap();
533 for row in &all {
534 assert!(!format!("{row:?}").contains(guess));
535 assert!(!format!("{row:?}").contains(PASSWORD));
536 }
537 }
538
539 #[tokio::test]
540 async fn other_users_from_the_same_ip_still_log_in() {
541 let tmp = scratch("other-user");
542 let (router, state) = app(tmp.path()).await;
543 with_user(&state, "alice").await;
544 with_user(&state, "bob").await;
545 let from = peer(11);
546 let token = login_page(&router, from).await;
547
548 for _ in 0..10 {
549 post_login(&router, from, (&token.0, &token.1), "alice", "wrong").await;
550 }
551 let (status, _) = post_login(&router, from, (&token.0, &token.1), "bob", PASSWORD).await;
552 assert_eq!(status, 303);
553 }
554
555 #[tokio::test]
556 async fn successful_login_resets_the_user_counter() {
557 let tmp = scratch("reset");
558 let (router, state) = app(tmp.path()).await;
559 with_user(&state, "alice").await;
560 let from = peer(12);
561 let token = login_page(&router, from).await;
562
563 for _ in 0..9 {
564 post_login(&router, from, (&token.0, &token.1), "alice", "wrong").await;
565 }
566 let (status, _) = post_login(&router, from, (&token.0, &token.1), "alice", PASSWORD).await;
567 assert_eq!(status, 303);
568 for _ in 0..9 {
569 post_login(&router, from, (&token.0, &token.1), "alice", "wrong").await;
570 }
571 let (status, _) = post_login(&router, from, (&token.0, &token.1), "alice", PASSWORD).await;
572 assert_eq!(status, 303);
573 }
574
575 #[tokio::test]
576 async fn ip_limit_applies_across_usernames() {
577 let tmp = scratch("ip-limit");
578 let (router, _state) = app(tmp.path()).await;
579 let from = peer(13);
580 let token = login_page(&router, from).await;
581
582 for i in 0..30 {
583 let name = format!("ghost{i}");
584 let (status, _) = post_login(&router, from, (&token.0, &token.1), &name, "x").await;
585 assert_eq!(status, 200);
586 }
587 let (status, body) =
588 post_login(&router, from, (&token.0, &token.1), "ghost-new", "x").await;
589 assert_eq!(status, 429);
590 assert!(body.contains("Too many attempts. Try again later."));
591 }
592
593 #[tokio::test]
594 async fn unknown_and_known_users_get_the_same_response() {
595 let tmp = scratch("uniform");
596 let (router, state) = app(tmp.path()).await;
597 with_user(&state, "alice").await;
598 let from = peer(14);
599 let token = login_page(&router, from).await;
600
601 // The body echoes the typed username, so compare status and the generic message only.
602 for name in ["alice", "nobody"] {
603 let (status, body) =
604 post_login(&router, from, (&token.0, &token.1), name, "wrong").await;
605 assert_eq!(status, 200);
606 assert!(body.contains("invalid username or password"));
607 }
608 }
609
610 /// Reply parts the tests look at.
611 struct Reply {
612 status: u16,
613 location: Option<String>,
614 cookies: Vec<String>,
615 body: String,
616 }
617
618 async fn send(router: &Router, req: Request) -> Reply {
619 let res = router.handle(req).await;
620 let status = res.status().as_u16();
621 let location = res
622 .headers()
623 .get("location")
624 .and_then(|v| v.to_str().ok())
625 .map(str::to_owned);
626 let cookies = res
627 .headers()
628 .get_all("set-cookie")
629 .iter()
630 .filter_map(|v| v.to_str().ok())
631 .map(|raw| raw.split(';').next().unwrap_or("").to_owned())
632 .collect();
633 let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
634 Reply {
635 status,
636 location,
637 cookies,
638 body: String::from_utf8_lossy(&bytes).into_owned(),
639 }
640 }
641
642 /// The `name=value` pair of a cookie from a reply, if it set one.
643 fn pair(reply: &Reply, name: &str) -> Option<String> {
644 reply
645 .cookies
646 .iter()
647 .find(|c| c.starts_with(&format!("{name}=")))
648 .cloned()
649 }
650
651 /// Password step for `username`. Returns the reply.
652 async fn password_step(
653 router: &Router,
654 peer: SocketAddr,
655 (cookie, precsrf): (&str, &str),
656 username: &str,
657 password: &str,
658 ) -> Reply {
659 let form =
660 format!("username={username}&password={password}&next=%2Fdash&precsrf={precsrf}");
661 let req = Request::builder()
662 .method("POST")
663 .uri("/login")
664 .header("cookie", cookie)
665 .header("content-type", "application/x-www-form-urlencoded")
666 .extension(RemoteAddr(peer))
667 .body(Body::from(form))
668 .unwrap();
669 send(router, req).await
670 }
671
672 /// Second-factor step with the given pending-login cookie.
673 async fn second_factor_step(
674 router: &Router,
675 peer: SocketAddr,
676 cookies: &str,
677 precsrf: &str,
678 code: &str,
679 ) -> Reply {
680 let form = format!("code={code}&precsrf={precsrf}");
681 let req = Request::builder()
682 .method("POST")
683 .uri("/login/2fa")
684 .header("cookie", cookies)
685 .header("content-type", "application/x-www-form-urlencoded")
686 .extension(RemoteAddr(peer))
687 .body(Body::from(form))
688 .unwrap();
689 send(router, req).await
690 }
691
692 /// Creates a user with two-factor on and one recovery code, `aaaa-bbbb`. Returns the secret.
693 async fn totp_user(state: &AppState, name: &str) -> Vec<u8> {
694 with_user(state, name).await;
695 let user = state.store.user_by_username(name).await.unwrap().unwrap();
696 let shared = totp::new_secret();
697 let sealed = secret::seal(&state.key, &shared).unwrap();
698 state
699 .store
700 .set_totp_secret(&user.id, &sealed)
701 .await
702 .unwrap();
703 state
704 .store
705 .enable_totp(&user.id, 0, &[totp::recovery_code_hash("aaaa-bbbb")])
706 .await
707 .unwrap();
708 shared.to_vec()
709 }
710
711 /// Cookie header for the pending login plus the pre-session CSRF cookie.
712 fn both(pending: &str, precsrf_cookie: &str) -> String {
713 format!("{pending}; {precsrf_cookie}")
714 }
715
716 #[tokio::test]
717 async fn totp_user_gets_a_pending_cookie_and_no_session() {
718 let tmp = scratch("totp-pending");
719 let (router, state) = app(tmp.path()).await;
720 totp_user(&state, "alice").await;
721 let from = peer(20);
722 let (cookie, precsrf) = login_page(&router, from).await;
723
724 let reply = password_step(&router, from, (&cookie, &precsrf), "alice", PASSWORD).await;
725 assert_eq!(reply.status, 303);
726 assert_eq!(reply.location.as_deref(), Some("/login/2fa"));
727 assert!(pair(&reply, "__Host-forge_2fa").is_some());
728 assert!(pair(&reply, "__Host-forge_session").is_none());
729
730 let page = send(
731 &router,
732 Request::builder()
733 .method("GET")
734 .uri("/login/2fa")
735 .header(
736 "cookie",
737 both(&pair(&reply, "__Host-forge_2fa").unwrap(), &cookie),
738 )
739 .extension(RemoteAddr(from))
740 .body(Body::empty())
741 .unwrap(),
742 )
743 .await;
744 assert_eq!(page.status, 200);
745 assert!(page.body.contains("Two-factor check"));
746 }
747
748 #[tokio::test]
749 async fn two_factor_page_without_pending_cookie_goes_to_login() {
750 let tmp = scratch("totp-no-pending");
751 let (router, _state) = app(tmp.path()).await;
752 let reply = send(
753 &router,
754 Request::builder()
755 .method("GET")
756 .uri("/login/2fa")
757 .extension(RemoteAddr(peer(21)))
758 .body(Body::empty())
759 .unwrap(),
760 )
761 .await;
762 assert_eq!(reply.status, 303);
763 assert_eq!(reply.location.as_deref(), Some("/login"));
764 }
765
766 #[tokio::test]
767 async fn wrong_code_fails_right_code_logs_in_and_replay_fails() {
768 let tmp = scratch("totp-flow");
769 let (router, state) = app(tmp.path()).await;
770 let shared = totp_user(&state, "alice").await;
771 let from = peer(22);
772 let (cookie, precsrf) = login_page(&router, from).await;
773
774 let step = password_step(&router, from, (&cookie, &precsrf), "alice", PASSWORD).await;
775 let pending = both(&pair(&step, "__Host-forge_2fa").unwrap(), &cookie);
776 let wrong = second_factor_step(&router, from, &pending, &precsrf, "000000").await;
777 // The chance that 000000 is the live code is about 1 in a million per window.
778 if wrong.status == 200 {
779 assert!(wrong.body.contains("That code is not right"));
780 }
781
782 let now_step = totp::step_for(
783 std::time::SystemTime::now()
784 .duration_since(std::time::UNIX_EPOCH)
785 .unwrap()
786 .as_secs(),
787 );
788 let code = totp::code_at(&shared, now_step);
789 let ok = second_factor_step(&router, from, &pending, &precsrf, &code).await;
790 assert_eq!(ok.status, 303, "body {}", ok.body);
791 assert_eq!(ok.location.as_deref(), Some("/dash"));
792 assert!(pair(&ok, "__Host-forge_session").is_some());
793 assert!(ok.cookies.iter().any(|c| c.starts_with("__Host-forge_2fa=")
794 && c.len() == "__Host-forge_2fa=".len()));
795
796 // The same code again, from a fresh password step, is a replay and must fail.
797 let (cookie2, precsrf2) = login_page(&router, from).await;
798 let step2 = password_step(&router, from, (&cookie2, &precsrf2), "alice", PASSWORD).await;
799 let pending2 = both(&pair(&step2, "__Host-forge_2fa").unwrap(), &cookie2);
800 let replay = second_factor_step(&router, from, &pending2, &precsrf2, &code).await;
801 assert_eq!(replay.status, 200);
802 assert!(replay.body.contains("That code is not right"));
803 assert!(pair(&replay, "__Host-forge_session").is_none());
804 }
805
806 #[tokio::test]
807 async fn recovery_code_works_once() {
808 let tmp = scratch("totp-recovery");
809 let (router, state) = app(tmp.path()).await;
810 totp_user(&state, "alice").await;
811 let from = peer(23);
812 let (cookie, precsrf) = login_page(&router, from).await;
813
814 let step = password_step(&router, from, (&cookie, &precsrf), "alice", PASSWORD).await;
815 let pending = both(&pair(&step, "__Host-forge_2fa").unwrap(), &cookie);
816 let first = second_factor_step(&router, from, &pending, &precsrf, "AAAA-bbbb").await;
817 assert_eq!(first.status, 303);
818 assert!(pair(&first, "__Host-forge_session").is_some());
819
820 let (cookie2, precsrf2) = login_page(&router, from).await;
821 let step2 = password_step(&router, from, (&cookie2, &precsrf2), "alice", PASSWORD).await;
822 let pending2 = both(&pair(&step2, "__Host-forge_2fa").unwrap(), &cookie2);
823 let second = second_factor_step(&router, from, &pending2, &precsrf2, "aaaabbbb").await;
824 assert_eq!(second.status, 200);
825 assert!(pair(&second, "__Host-forge_session").is_none());
826 }
827
828 #[tokio::test]
829 async fn expired_pending_cookie_is_rejected() {
830 let tmp = scratch("totp-expired");
831 let (router, state) = app(tmp.path()).await;
832 let shared = totp_user(&state, "alice").await;
833 let user = state
834 .store
835 .user_by_username("alice")
836 .await
837 .unwrap()
838 .unwrap();
839 let from = peer(24);
840 let (cookie, precsrf) = login_page(&router, from).await;
841
842 // A correctly sealed pending cookie whose expiry is in the past.
843 let stale = secret::seal(&state.key, format!("1|{}|/", user.id).as_bytes()).unwrap();
844 let pending = format!("__Host-forge_2fa={stale}");
845 let code = totp::code_at(
846 &shared,
847 totp::step_for(
848 std::time::SystemTime::now()
849 .duration_since(std::time::UNIX_EPOCH)
850 .unwrap()
851 .as_secs(),
852 ),
853 );
854 let reply =
855 second_factor_step(&router, from, &both(&pending, &cookie), &precsrf, &code).await;
856 assert_eq!(reply.status, 303);
857 assert_eq!(reply.location.as_deref(), Some("/login"));
858 assert!(pair(&reply, "__Host-forge_session").is_none());
859 }
860
861 #[tokio::test]
862 async fn second_factor_attempts_are_throttled_per_user() {
863 let tmp = scratch("totp-throttle");
864 let (router, state) = app(tmp.path()).await;
865 let shared = totp_user(&state, "alice").await;
866 let from = peer(25);
867 let (cookie, precsrf) = login_page(&router, from).await;
868 let step = password_step(&router, from, (&cookie, &precsrf), "alice", PASSWORD).await;
869 let pending = both(&pair(&step, "__Host-forge_2fa").unwrap(), &cookie);
870
871 for _ in 0..TWO_FACTOR_USER_LIMIT {
872 let reply = second_factor_step(&router, from, &pending, &precsrf, "111111").await;
873 assert_eq!(reply.status, 200);
874 }
875 let code = totp::code_at(
876 &shared,
877 totp::step_for(
878 std::time::SystemTime::now()
879 .duration_since(std::time::UNIX_EPOCH)
880 .unwrap()
881 .as_secs(),
882 ),
883 );
884 let blocked = second_factor_step(&router, from, &pending, &precsrf, &code).await;
885 assert_eq!(blocked.status, 429);
886 assert!(blocked.body.contains("Too many attempts"));
887 }
888}