From fe1b12042d4686a4465472f8ec47c743f4778191 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 12:25:36 +0200 Subject: [PATCH] login: guessing from elsewhere no longer slows a browser already in use The login throttle was one counter for everyone, so a stranger failing on purpose every thirty seconds kept the owner on 429s too. A browser that logs in now gets a signed diary_device cookie (__Host- over HTTPS, SameSite=Strict, 400 days), and a login carrying a valid one is slowed only by its own throttle. Everything else shares the anonymous one, as before. At most 64 known browsers are tracked; the least recently used is forgotten first. The failed-login mail says which kind of browser the failures came from. --- README.md | 8 ++++ src/auth.rs | 65 +++++++++++++++++++++++++++++--- src/notify/mod.rs | 12 ++++-- src/routes/session.rs | 32 ++++++++++++---- src/state.rs | 8 +++- src/throttle.rs | 88 ++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 190 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9b8cefa..6b6c5f1 100644 --- a/README.md +++ b/README.md @@ -604,6 +604,14 @@ runtime dependencies, no build tools on the host. The server will not start while `WORKSPACE_SECRET` or `WORKSPACE_PASSWORD` is still the example from `.env.example`: with the example secret anyone could sign themselves a session. +Wrong passwords are answered more and more slowly — free for three, then 1 s, +2 s, 4 s… up to 30 s. A browser that has logged in before keeps a signed +`diary_device` cookie (`__Host-diary_device` over HTTPS) and is slowed only by +its own mistakes, so someone guessing at the login from elsewhere cannot keep +you out of a browser you already use. A browser that has never logged in waits +in the same line as the guesses. The cookie opens nothing and survives +logging out. + With `WORKSPACE_SECURE_COOKIE=1` the session cookie is `Secure` and named `__Host-diary_session`, so the browser keeps it to this host and HTTPS, and every response carries `Strict-Transport-Security` for a year. Turning it on logs diff --git a/src/auth.rs b/src/auth.rs index 86e9b05..b8cc7cf 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -267,9 +267,8 @@ pub fn clear_cookie(config: &Config) -> String { format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}") } -fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> { - parts - .headers +pub fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers .get_all(header::COOKIE) .iter() .filter_map(|v| v.to_str().ok()) @@ -279,6 +278,41 @@ fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> { .map(|(_, v)| v.trim()) } +/// The cookie a browser keeps once it has logged in, so the login can tell +/// it from a stranger; see [`crate::throttle::LoginThrottles`]. It opens +/// nothing, outlives logging out, and only ever picks a throttle. +const DEVICE_COOKIE: &str = "diary_device"; +const SECURE_DEVICE_COOKIE: &str = "__Host-diary_device"; +const DEVICE_DAYS: i64 = 400; + +fn device_cookie_name(config: &Config) -> &'static str { + if config.secure_cookie { + SECURE_DEVICE_COOKIE + } else { + DEVICE_COOKIE + } +} + +/// The device id a request's cookie carries, if it is one this server signed. +pub fn device_id(config: &Config, headers: &HeaderMap) -> Option { + let (id, signature) = cookie_value(headers, device_cookie_name(config))?.split_once('.')?; + let expected = sign(&config.secret, &format!("device:{id}")); + bool::from(expected.as_bytes().ct_eq(signature.as_bytes())).then(|| id.to_string()) +} + +/// A fresh device id, for a browser that logged in without one. +pub fn new_device_id() -> String { + B64.encode(rand::random::<[u8; 16]>()) +} + +pub fn device_cookie(config: &Config, id: &str) -> String { + let name = device_cookie_name(config); + let signature = sign(&config.secret, &format!("device:{id}")); + let max_age = DEVICE_DAYS * 86_400; + let secure = if config.secure_cookie { "; Secure" } else { "" }; + format!("{name}={id}.{signature}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}{secure}") +} + /// Extractor that rejects the request unless a valid session cookie is present. pub struct Session { /// The `sessions` row the cookie belongs to. @@ -292,8 +326,8 @@ impl FromRequestParts for Session { parts: &mut Parts, state: &AppState, ) -> Result { - let token = - cookie_value(parts, cookie_name(&state.config)).ok_or(AppError::Unauthorized)?; + let token = cookie_value(&parts.headers, cookie_name(&state.config)) + .ok_or(AppError::Unauthorized)?; let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst); let id = verify_token(&state.config, token, epoch).ok_or(AppError::Unauthorized)?; let due = state.sessions.seen(&id).ok_or(AppError::Unauthorized)?; @@ -507,6 +541,27 @@ mod tests { assert!(verify_token(&config, &token, 4).is_none()); } + #[test] + fn only_a_device_cookie_this_server_signed_counts() { + use axum::http::{header, HeaderMap}; + let config = config(30); + let with = |cookie: &str| { + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, cookie.parse().unwrap()); + super::device_id(&config, &headers) + }; + let set = super::device_cookie(&config, "laptop"); + let pair = set.split(';').next().unwrap(); + assert_eq!(with(pair).as_deref(), Some("laptop")); + assert_eq!(with("diary_device=laptop"), None); + assert_eq!(with(&pair.replace("laptop.", "phone.")), None); + + let mut other = Config::for_tests(30); + other.secret = b"a-different-secret-entirely".to_vec(); + let foreign = super::device_cookie(&other, "laptop"); + assert_eq!(with(foreign.split(';').next().unwrap()), None); + } + #[test] fn a_cookie_without_a_session_id_is_refused() { let config = config(30); diff --git a/src/notify/mod.rs b/src/notify/mod.rs index 34c7fa4..ad59046 100644 --- a/src/notify/mod.rs +++ b/src/notify/mod.rs @@ -315,7 +315,7 @@ impl Notifier { /// A run of wrong passwords. Enqueued at most once an hour, because the /// point is to notice an attempt, not to be mailed by the attacker. - pub async fn note_failed_logins(&self, failures: u32) { + pub async fn note_failed_logins(&self, failures: u32, known_device: bool) { if !self.config.mail.sends("login") { return; } @@ -327,8 +327,14 @@ impl Notifier { &format!("login_failed:{hour}"), "workspace — repeated failed logins", &format!( - "{failures} failed login attempts. The login is being answered more \ - slowly with every one of them.\n" + "{failures} failed login attempts {}. The login is being answered more \ + slowly with every one of them.\n", + if known_device { + "from a browser that has logged in before" + } else { + "from browsers that have not logged in before, which do not slow \ + down the browsers that have" + } ), ) .await; diff --git a/src/routes/session.rs b/src/routes/session.rs index b37405e..e7b6e2d 100644 --- a/src/routes/session.rs +++ b/src/routes/session.rs @@ -1,7 +1,7 @@ use axum::{ extract::{Path, Query, State}, http::{header, HeaderMap, StatusCode}, - response::IntoResponse, + response::{AppendHeaders, IntoResponse}, Json, }; use serde::{Deserialize, Serialize}; @@ -25,8 +25,13 @@ pub async fn login( headers: HeaderMap, Json(body): Json, ) -> AppResult { - let attempt = state.login_throttle.begin().map_err(|wait| { - tracing::warn!("login refused, {wait}s of backoff remaining"); + let device = auth::device_id(&state.config, &headers); + let throttle = state.login_throttle.for_device(device.as_deref()); + let attempt = throttle.begin().map_err(|wait| { + tracing::warn!( + known_device = device.is_some(), + "login refused, {wait}s of backoff remaining" + ); AppError::TooManyRequests(wait) })?; @@ -34,7 +39,10 @@ pub async fn login( let failures = attempt.failed(); // Worth a mail once someone is past guessing and into trying. if failures > FREE_ATTEMPTS { - state.notify.note_failed_logins(failures).await; + state + .notify + .note_failed_logins(failures, device.is_some()) + .await; } return Err(AppError::Unauthorized); } @@ -54,12 +62,20 @@ pub async fn login( &id, &state.config, ); + // Signed again on every login, so a browser in use never runs out of it. + let device = device.unwrap_or_else(auth::new_device_id); Ok(( StatusCode::OK, - [( - header::SET_COOKIE, - auth::session_cookie(&state.config, &token), - )], + AppendHeaders([ + ( + header::SET_COOKIE, + auth::session_cookie(&state.config, &token), + ), + ( + header::SET_COOKIE, + auth::device_cookie(&state.config, &device), + ), + ]), Json(json!({ "user": state.config.username })), )) } diff --git a/src/state.rs b/src/state.rs index f962894..30a2358 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,7 +3,11 @@ use std::sync::{atomic::AtomicI64, Arc}; use sqlx::SqlitePool; use crate::{ - auth::Sessions, backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, + auth::Sessions, + backup::Backup, + config::Config, + notify::Notifier, + throttle::{LoginThrottle, LoginThrottles}, vault::Vault, }; @@ -11,7 +15,7 @@ use crate::{ pub struct AppState { pub db: SqlitePool, pub config: Arc, - pub login_throttle: Arc, + pub login_throttle: Arc, /// The vault password gets the same slowing-down as the login, kept apart /// so guessing at one does not slow the other. pub vault_throttle: Arc, diff --git a/src/throttle.rs b/src/throttle.rs index 25579fe..dd4af1f 100644 --- a/src/throttle.rs +++ b/src/throttle.rs @@ -1,12 +1,15 @@ -use std::sync::Mutex; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; /// Failed logins get progressively slower to answer. /// /// The workspace has exactly one account, so this is one counter rather than a map /// keyed by address: an attacker cannot step around it by changing source IP, -/// and there is no per-client table to grow. The cost of that choice is that -/// someone hammering the login can slow the owner's own attempt down — which is -/// why the wait is capped at `MAX_DELAY` seconds instead of locking the account. +/// and there is no per-client table to grow. Someone hammering it slows down +/// every browser it covers, so the login keeps one for browsers it does not +/// know and one apiece for those it does; see [`LoginThrottles`]. #[derive(Debug, Default)] pub struct LoginThrottle { state: Mutex, @@ -62,6 +65,50 @@ impl LoginThrottle { } } +/// How many browsers that have logged in before get a throttle of their own. +/// Past that, the one used longest ago is forgotten, and falls back to +/// sharing the anonymous one until it next logs in. +const KNOWN_DEVICES: usize = 64; + +/// The login's throttles. A browser that has logged in before carries a +/// signed device cookie and is slowed only by its own mistakes; every other +/// request shares the anonymous throttle. A stranger guessing at the +/// password therefore slows strangers, never a browser the owner already +/// uses — a new one still waits in line with the guesses. +#[derive(Debug, Default)] +pub struct LoginThrottles { + anonymous: Arc, + known: Mutex, i64)>>, +} + +impl LoginThrottles { + /// The throttle for a login from `device`, a device id whose signature + /// has already been checked, or from a browser without one. + pub fn for_device(&self, device: Option<&str>) -> Arc { + let Some(device) = device else { + return Arc::clone(&self.anonymous); + }; + let mut known = self.known.lock().unwrap_or_else(|e| e.into_inner()); + let now = crate::now(); + if let Some((throttle, used)) = known.get_mut(device) { + *used = now; + return Arc::clone(throttle); + } + if known.len() >= KNOWN_DEVICES { + let oldest = known + .iter() + .min_by_key(|(_, (_, used))| *used) + .map(|(id, _)| id.clone()); + if let Some(oldest) = oldest { + known.remove(&oldest); + } + } + let throttle = Arc::::default(); + known.insert(device.to_string(), (Arc::clone(&throttle), now)); + throttle + } +} + /// One attempt that [`LoginThrottle::begin`] let through. Dropping it without /// a verdict — a request that failed for some other reason — counts for /// nothing either way. @@ -94,7 +141,7 @@ impl Drop for Attempt<'_> { #[cfg(test)] mod tests { - use super::{delay_after, Attempt, LoginThrottle, MAX_DELAY}; + use super::{delay_after, Attempt, LoginThrottle, LoginThrottles, KNOWN_DEVICES, MAX_DELAY}; #[test] fn the_first_few_mistakes_are_free() { @@ -138,4 +185,35 @@ mod tests { drop(first); assert!(throttle.begin().is_ok()); } + + #[test] + fn guesses_from_strangers_do_not_slow_a_known_browser() { + let throttles = LoginThrottles::default(); + let anonymous = throttles.for_device(None); + for _ in 0..10 { + Attempt { + throttle: &anonymous, + } + .failed(); + } + assert!(throttles.for_device(None).begin().is_err()); + assert!(throttles.for_device(Some("laptop")).begin().is_ok()); + + // And a known browser's own mistakes stay its own. + let laptop = throttles.for_device(Some("laptop")); + for _ in 0..10 { + Attempt { throttle: &laptop }.failed(); + } + assert!(throttles.for_device(Some("laptop")).begin().is_err()); + assert!(throttles.for_device(Some("phone")).begin().is_ok()); + } + + #[test] + fn known_browsers_are_capped() { + let throttles = LoginThrottles::default(); + for n in 0..KNOWN_DEVICES * 3 { + throttles.for_device(Some(&format!("device-{n}"))); + } + assert_eq!(throttles.known.lock().unwrap().len(), KNOWN_DEVICES); + } }