use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; use argon2::Argon2; use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}; use axum::extract::FromRequestParts; use axum::http::header; use axum::http::request::Parts; use rusqlite::{OptionalExtension, params}; use sha2::{Digest, Sha256}; use crate::{AppState, Error, now}; pub const SESSION_COOKIE: &str = "ot_session"; pub const SESSION_SECS: i64 = 30 * 86400; pub fn hash_password(password: &str) -> String { Argon2::default() .hash_password(password.as_bytes()) .expect("argon2 with default parameters") .to_string() } pub fn verify_password(password: &str, hash: &str) -> bool { PasswordHash::new(hash).is_ok_and(|h| { Argon2::default() .verify_password(password.as_bytes(), &h) .is_ok() }) } pub fn check_new_password(password: &str) -> Result<(), &'static str> { if password.chars().count() < 8 { return Err("password needs at least 8 characters"); } Ok(()) } /// A random secret for a session cookie or a device token, and the hash to store for it. pub fn new_secret() -> (String, Vec) { let mut bytes = [0u8; 32]; getrandom::fill(&mut bytes).expect("OS random number generator"); let secret = hex::encode(bytes); let hash = hash_secret(&secret); (secret, hash) } /// The secrets are 256 random bits, so a fast hash is enough. Argon2 is only for passwords. fn hash_secret(secret: &str) -> Vec { Sha256::digest(secret.as_bytes()).to_vec() } static DUMMY_HASH: LazyLock = LazyLock::new(|| hash_password("not a real password")); pub struct PasswordOk { pub id: i64, pub two_factor: bool, } /// Checks a username and password. An account without a password always fails. pub async fn check_password( state: &AppState, username: &str, password: &str, ) -> Result { let key = username.to_lowercase(); state.limiter.check(&key)?; let row: Option<(i64, Option, bool)> = state .db() .query_row( "SELECT id, pw_hash, two_factor FROM users WHERE username = ?1", [username], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .optional()?; let password = password.to_owned(); let user = tokio::task::spawn_blocking(move || match row { Some((id, Some(hash), two_factor)) => { verify_password(&password, &hash).then_some(PasswordOk { id, two_factor }) } _ => { // Do the same work as for a real password, so timing does not reveal which accounts exist. verify_password(&password, &DUMMY_HASH); None } }) .await .map_err(|e| Error::Internal(e.to_string()))?; match user { Some(user) => { state.limiter.clear(&key); Ok(user) } None => { state.limiter.fail(&key); Err(Error::Unauthorized) } } } pub async fn hash_password_async(password: String) -> Result { tokio::task::spawn_blocking(move || hash_password(&password)) .await .map_err(|e| Error::Internal(e.to_string())) } fn cookie(state: &AppState, value: &str, max_age: i64) -> String { let secure = if state.https() { "; Secure" } else { "" }; format!( "{SESSION_COOKIE}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Strict{secure}" ) } /// Creates a session and returns its Set-Cookie value. pub fn create_session(state: &AppState, user_id: i64) -> Result { let (token, hash) = new_secret(); state.db().execute( "INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)", params![hash, user_id, now() + SESSION_SECS], )?; Ok(cookie(state, &token, SESSION_SECS)) } pub fn clear_session(state: &AppState) -> String { cookie(state, "", 0) } /// A credential changed. Sessions that existed before must not outlive it. pub fn end_other_sessions(state: &AppState, user: &User) -> Result<(), Error> { state.db().execute( "DELETE FROM sessions WHERE user_id = ?1 AND token_hash <> ?2", params![user.id, user.session_hash], )?; Ok(()) } const MAX_FAILURES: u32 = 5; const LOCKOUT_SECS: i64 = 15 * 60; /// Failed login attempts per username. #[derive(Default)] pub struct Limiter(Mutex>); impl Limiter { pub fn check(&self, key: &str) -> Result<(), Error> { let map = self.0.lock().unwrap(); if let Some(&(failures, since)) = map.get(key) && failures >= MAX_FAILURES && now() - since < LOCKOUT_SECS { return Err(Error::TooManyRequests); } Ok(()) } pub fn fail(&self, key: &str) { let now = now(); let mut map = self.0.lock().unwrap(); let entry = map.entry(key.to_owned()).or_insert((0, now)); if now - entry.1 >= LOCKOUT_SECS { *entry = (0, now); } entry.0 += 1; } pub fn clear(&self, key: &str) { self.0.lock().unwrap().remove(key); } /// Attackers choose the usernames, so old entries must go. pub fn prune(&self) { let now = now(); self.0 .lock() .unwrap() .retain(|_, (_, since)| now - *since < LOCKOUT_SECS); } } /// A logged-in web user, from the session cookie. pub struct User { pub id: i64, pub username: String, pub is_admin: bool, pub session_hash: Vec, } impl FromRequestParts for User { type Rejection = Error; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { let token = parts .headers .get_all(header::COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .flat_map(|v| v.split(';')) .find_map(|c| c.trim().strip_prefix(SESSION_COOKIE)?.strip_prefix('=')) .ok_or(Error::Unauthorized)?; let session_hash = hash_secret(token); let (id, username, is_admin) = state .db() .query_row( "SELECT u.id, u.username, u.is_admin FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?1 AND s.expires_at > ?2", params![session_hash, now()], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .optional()? .ok_or(Error::Unauthorized)?; Ok(User { id, username, is_admin, session_hash, }) } } /// A logged-in admin. pub struct Admin(pub User); impl FromRequestParts for Admin { type Rejection = Error; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { let user = User::from_request_parts(parts, state).await?; if !user.is_admin { return Err(Error::Forbidden); } Ok(Admin(user)) } } /// Who uploads points: a device with its `Authorization: Bearer` token, or the web UI with its session. pub struct Uploader { pub user_id: i64, pub device_id: i64, } impl FromRequestParts for Uploader { type Rejection = Error; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { if parts.headers.contains_key(header::AUTHORIZATION) { let d = Device::from_request_parts(parts, state).await?; return Ok(Uploader { user_id: d.user_id, device_id: d.id, }); } { let user = User::from_request_parts(parts, state).await?; let db = state.db(); db.execute( "INSERT OR IGNORE INTO devices (user_id, name, created_at) VALUES (?1, 'Web', ?2)", params![user.id, now()], )?; let device_id = db.query_row( "SELECT id FROM devices WHERE user_id = ?1 AND token_hash IS NULL", [user.id], |r| r.get(0), )?; Ok(Uploader { user_id: user.id, device_id, }) } } } /// A device, from its `Authorization: Bearer` token. pub struct Device { pub id: i64, pub user_id: i64, } impl FromRequestParts for Device { type Rejection = Error; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { let token = parts .headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .ok_or(Error::Unauthorized)?; let (id, user_id) = state .db() .query_row( "SELECT id, user_id FROM devices WHERE token_hash = ?1", [hash_secret(token)], |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()? .ok_or(Error::Unauthorized)?; Ok(Device { id, user_id }) } } #[cfg(test)] mod tests { use super::*; #[test] fn limiter_locks_after_max_failures() { let l = Limiter::default(); for _ in 0..MAX_FAILURES { assert!(l.check("a").is_ok()); l.fail("a"); } assert!(matches!(l.check("a"), Err(Error::TooManyRequests))); assert!(l.check("b").is_ok()); l.clear("a"); assert!(l.check("a").is_ok()); } #[test] fn password_roundtrip() { let h = hash_password("correct horse"); assert!(verify_password("correct horse", &h)); assert!(!verify_password("wrong horse", &h)); } }