Tahinli/forge
public0 watching
★ 0
| 1 | //! Pages: login (GET/POST) and logout (POST). |
| 2 | |
| 3 | use std::time::{Duration, Instant}; |
| 4 | |
| 5 | use forge_core::AuditAction; |
| 6 | use forge_core::names::normalize_name; |
| 7 | use http::header::{HeaderValue, RETRY_AFTER}; |
| 8 | use serde::Deserialize; |
| 9 | use serde_json::json; |
| 10 | use topcoat::Result; |
| 11 | use topcoat::context::{Cx, app_context}; |
| 12 | use topcoat::router::content::Form; |
| 13 | use topcoat::router::error::{SeeOther, forbidden, see_other}; |
| 14 | use topcoat::router::{HeaderMap, StatusCode, page, query_params, route}; |
| 15 | use topcoat::view::{View, component, view}; |
| 16 | |
| 17 | use crate::audit::{self, Actor, Target}; |
| 18 | use 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 | }; |
| 23 | use crate::client_ip::client_ip; |
| 24 | use crate::i18n::{self, Key, Lang, t}; |
| 25 | use crate::state::AppState; |
| 26 | use crate::throttle::{self, LOGIN_IP_LIMIT, LOGIN_USER_LIMIT, TWO_FACTOR_USER_LIMIT}; |
| 27 | use 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. |
| 31 | async 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)] |
| 40 | struct LoginQuery { |
| 41 | next: Option<String>, |
| 42 | } |
| 43 | |
| 44 | #[derive(Deserialize)] |
| 45 | struct 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)] |
| 57 | struct CsrfForm { |
| 58 | #[serde(default)] |
| 59 | csrf: String, |
| 60 | } |
| 61 | |
| 62 | #[derive(Deserialize)] |
| 63 | struct TwoFactorForm { |
| 64 | #[serde(default)] |
| 65 | code: String, |
| 66 | #[serde(default)] |
| 67 | precsrf: String, |
| 68 | } |
| 69 | |
| 70 | #[component] |
| 71 | async 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")] |
| 102 | async 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")] |
| 116 | async 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] |
| 235 | async 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")] |
| 257 | async 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")] |
| 270 | async 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. |
| 347 | fn retry_after_secs(retry: Duration) -> u64 { |
| 348 | retry.as_secs().max(1) |
| 349 | } |
| 350 | |
| 351 | #[route(POST "/logout")] |
| 352 | async 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)] |
| 373 | mod 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 | } |