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
+190
-23
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
|
while `WORKSPACE_SECRET` or `WORKSPACE_PASSWORD` is still the example from
|
||||||
`.env.example`: with the example secret anyone could sign themselves a session.
|
`.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
|
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
|
`__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
|
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}")
|
format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> {
|
pub fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||||
parts
|
headers
|
||||||
.headers
|
|
||||||
.get_all(header::COOKIE)
|
.get_all(header::COOKIE)
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|v| v.to_str().ok())
|
.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())
|
.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.
|
/// Extractor that rejects the request unless a valid session cookie is present.
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
/// The `sessions` row the cookie belongs to.
|
/// The `sessions` row the cookie belongs to.
|
||||||
@@ -292,8 +326,8 @@ impl FromRequestParts<AppState> for Session {
|
|||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
let token =
|
let token = cookie_value(&parts.headers, cookie_name(&state.config))
|
||||||
cookie_value(parts, cookie_name(&state.config)).ok_or(AppError::Unauthorized)?;
|
.ok_or(AppError::Unauthorized)?;
|
||||||
let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst);
|
let epoch = state.epoch.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
let id = verify_token(&state.config, token, epoch).ok_or(AppError::Unauthorized)?;
|
let id = verify_token(&state.config, token, epoch).ok_or(AppError::Unauthorized)?;
|
||||||
let due = state.sessions.seen(&id).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());
|
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]
|
#[test]
|
||||||
fn a_cookie_without_a_session_id_is_refused() {
|
fn a_cookie_without_a_session_id_is_refused() {
|
||||||
let config = config(30);
|
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
|
/// 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.
|
/// 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") {
|
if !self.config.mail.sends("login") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -327,8 +327,14 @@ impl Notifier {
|
|||||||
&format!("login_failed:{hour}"),
|
&format!("login_failed:{hour}"),
|
||||||
"workspace — repeated failed logins",
|
"workspace — repeated failed logins",
|
||||||
&format!(
|
&format!(
|
||||||
"{failures} failed login attempts. The login is being answered more \
|
"{failures} failed login attempts {}. The login is being answered more \
|
||||||
slowly with every one of them.\n"
|
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;
|
.await;
|
||||||
|
|||||||
+24
-8
@@ -1,7 +1,7 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{header, HeaderMap, StatusCode},
|
http::{header, HeaderMap, StatusCode},
|
||||||
response::IntoResponse,
|
response::{AppendHeaders, IntoResponse},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -25,8 +25,13 @@ pub async fn login(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<LoginRequest>,
|
Json(body): Json<LoginRequest>,
|
||||||
) -> AppResult<impl IntoResponse> {
|
) -> AppResult<impl IntoResponse> {
|
||||||
let attempt = state.login_throttle.begin().map_err(|wait| {
|
let device = auth::device_id(&state.config, &headers);
|
||||||
tracing::warn!("login refused, {wait}s of backoff remaining");
|
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)
|
AppError::TooManyRequests(wait)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -34,7 +39,10 @@ pub async fn login(
|
|||||||
let failures = attempt.failed();
|
let failures = attempt.failed();
|
||||||
// Worth a mail once someone is past guessing and into trying.
|
// Worth a mail once someone is past guessing and into trying.
|
||||||
if failures > FREE_ATTEMPTS {
|
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);
|
return Err(AppError::Unauthorized);
|
||||||
}
|
}
|
||||||
@@ -54,12 +62,20 @@ pub async fn login(
|
|||||||
&id,
|
&id,
|
||||||
&state.config,
|
&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((
|
Ok((
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
[(
|
AppendHeaders([
|
||||||
header::SET_COOKIE,
|
(
|
||||||
auth::session_cookie(&state.config, &token),
|
header::SET_COOKIE,
|
||||||
)],
|
auth::session_cookie(&state.config, &token),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
auth::device_cookie(&state.config, &device),
|
||||||
|
),
|
||||||
|
]),
|
||||||
Json(json!({ "user": state.config.username })),
|
Json(json!({ "user": state.config.username })),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -3,7 +3,11 @@ use std::sync::{atomic::AtomicI64, Arc};
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::{
|
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,
|
vault::Vault,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,7 +15,7 @@ use crate::{
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: SqlitePool,
|
pub db: SqlitePool,
|
||||||
pub config: Arc<Config>,
|
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
|
/// The vault password gets the same slowing-down as the login, kept apart
|
||||||
/// so guessing at one does not slow the other.
|
/// so guessing at one does not slow the other.
|
||||||
pub vault_throttle: Arc<LoginThrottle>,
|
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.
|
/// Failed logins get progressively slower to answer.
|
||||||
///
|
///
|
||||||
/// The workspace has exactly one account, so this is one counter rather than a map
|
/// 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,
|
/// 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
|
/// and there is no per-client table to grow. Someone hammering it slows down
|
||||||
/// someone hammering the login can slow the owner's own attempt down — which is
|
/// every browser it covers, so the login keeps one for browsers it does not
|
||||||
/// why the wait is capped at `MAX_DELAY` seconds instead of locking the account.
|
/// know and one apiece for those it does; see [`LoginThrottles`].
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct LoginThrottle {
|
pub struct LoginThrottle {
|
||||||
state: Mutex<State>,
|
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
|
/// One attempt that [`LoginThrottle::begin`] let through. Dropping it without
|
||||||
/// a verdict — a request that failed for some other reason — counts for
|
/// a verdict — a request that failed for some other reason — counts for
|
||||||
/// nothing either way.
|
/// nothing either way.
|
||||||
@@ -94,7 +141,7 @@ impl Drop for Attempt<'_> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{delay_after, Attempt, LoginThrottle, MAX_DELAY};
|
use super::{delay_after, Attempt, LoginThrottle, LoginThrottles, KNOWN_DEVICES, MAX_DELAY};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_first_few_mistakes_are_free() {
|
fn the_first_few_mistakes_are_free() {
|
||||||
@@ -138,4 +185,35 @@ mod tests {
|
|||||||
drop(first);
|
drop(first);
|
||||||
assert!(throttle.begin().is_ok());
|
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