mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
165561f6a2
commit
fe1b12042d
6 files changed
+188
-21
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user