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.
This commit is contained in:
nvrl committed 2026-10-20 12:25:36 +02:00
1 parent 165561f6a2
commit fe1b12042d
6 files changed
+188 -21

No files matched your search

+8
View File
@@ -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
+60 -5
View File
@@ -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<String> {
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<AppState> for Session {
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
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);
+9 -3
View File
@@ -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;
+22 -6
View File
@@ -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<LoginRequest>,
) -> AppResult<impl IntoResponse> {
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,
[(
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 })),
))
}
+6 -2
View File
@@ -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<Config>,
pub login_throttle: Arc<LoginThrottle>,
pub login_throttle: Arc<LoginThrottles>,
/// 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<LoginThrottle>,
+83 -5
View File
@@ -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<State>,
@@ -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<LoginThrottle>,
known: Mutex<HashMap<String, (Arc<LoginThrottle>, 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<LoginThrottle> {
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::<LoginThrottle>::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);
}
}