//! Username/password accounts, for servers without an identity provider. //! //! Accounts are created by whoever runs the server (`mtgc-server user add`); //! there is no sign-up page, so a server exposed to the internet can't be //! filled with accounts by strangers. use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant}; use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use argon2::Argon2; use axum::extract::{Form, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::Response; use serde::Deserialize; use crate::{auth, db, pages, AppState}; /// Failures allowed before each further attempt has to wait. const FREE_FAILURES: u32 = 5; const MAX_DELAY: Duration = Duration::from_secs(15 * 60); pub struct LocalAuth { /// A real hash of nothing, verified against when the username doesn't /// exist, so a wrong username takes as long to reject as a wrong password /// and response times don't reveal which accounts exist. dummy_hash: String, failures: Mutex>, } struct Failures { count: u32, locked_until: Instant, } impl LocalAuth { pub fn new() -> Self { Self { dummy_hash: hash_password("not a real password").expect("hashing works"), failures: Mutex::new(HashMap::new()), } } /// How long until this username may try again, if it must wait. fn wait(&self, username: &str) -> Option { let map = self.failures.lock().unwrap_or_else(|e| e.into_inner()); let f = map.get(username)?; f.locked_until.checked_duration_since(Instant::now()) } fn record_failure(&self, username: &str) { let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner()); let f = map .entry(username.to_string()) .or_insert(Failures { count: 0, locked_until: Instant::now() }); f.count += 1; if f.count >= FREE_FAILURES { // 1s, 2s, 4s, ... capped. Slows guessing to a crawl without // locking the real owner out for long. let exp = (f.count - FREE_FAILURES).min(20); let delay = Duration::from_secs(1u64 << exp).min(MAX_DELAY); f.locked_until = Instant::now() + delay; } } fn record_success(&self, username: &str) { self.failures.lock().unwrap_or_else(|e| e.into_inner()).remove(username); } } impl Default for LocalAuth { fn default() -> Self { Self::new() } } /// Argon2id with the crate's defaults (19 MiB, 2 passes): OWASP's baseline. pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); Argon2::default() .hash_password(password.as_bytes(), &salt) .map(|h| h.to_string()) .map_err(|e| e.to_string()) } fn verify_password(password: &str, hash: &str) -> bool { PasswordHash::new(hash) .map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok()) .unwrap_or(false) } /// Usernames are compared case-insensitively, as people expect them to be. pub fn normalize_username(name: &str) -> String { name.trim().to_lowercase() } #[derive(Deserialize)] pub struct LoginForm { pending: String, username: String, password: String, } /// The sign-in form's target. pub async fn submit( State(state): State, headers: HeaderMap, Form(form): Form, ) -> Response { if !state.config().local_auth { return pages::error(StatusCode::NOT_FOUND, "Password sign-in is turned off on this server."); } // The form only exists inside a sign-in the app started, in this browser; // without one there is nowhere to send the result. let pending = match auth::bound_pending(&state, &headers, &form.pending) { Ok(p) => p, Err(r) => return r, }; let oidc_name = state.config().oidc.as_ref().map(|o| o.display_name.clone()); let username = normalize_username(&form.username); if let Some(wait) = state.0.local.wait(&username) { let msg = format!("Too many attempts. Try again in {} seconds.", wait.as_secs().max(1)); return pages::login(&pending, oidc_name.as_deref(), Some(&msg), &form.username); } let account = match db::local_user(&state.db(), &username) { Ok(a) => a, Err(e) => { tracing::error!("load local user: {e}"); return pages::error(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong — try again."); } }; // Hashing is deliberately slow; keep it off the async workers. let hash = account .as_ref() .map(|(_, h)| h.clone()) .unwrap_or_else(|| state.0.local.dummy_hash.clone()); let password = form.password.clone(); let ok = tokio::task::spawn_blocking(move || verify_password(&password, &hash)) .await .unwrap_or(false); match account { Some((user, _)) if ok => { state.0.local.record_success(&username); auth::finish(&state, &pending, &user) } _ => { state.0.local.record_failure(&username); tracing::warn!(username = %username, "failed password sign-in"); pages::login(&pending, oidc_name.as_deref(), Some("Invalid username or password."), &form.username) } } } #[cfg(test)] mod tests { use super::*; #[test] fn a_password_verifies_against_its_own_hash_only() { let h = hash_password("correct horse").unwrap(); assert!(h.starts_with("$argon2id$")); assert!(verify_password("correct horse", &h)); assert!(!verify_password("wrong", &h)); assert!(!verify_password("correct horse", "not a hash")); } #[test] fn repeated_failures_make_a_username_wait() { let a = LocalAuth::new(); for _ in 0..FREE_FAILURES - 1 { a.record_failure("me"); } assert!(a.wait("me").is_none(), "a few typos are free"); a.record_failure("me"); assert!(a.wait("me").is_some()); assert!(a.wait("someone-else").is_none(), "only that username waits"); a.record_success("me"); assert!(a.wait("me").is_none()); } }