mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
feat: slow down repeated failed logins
The login endpoint answered guesses as fast as they arrived, against a single static password. Wrong attempts now back off — 1s, 2s, 4s, up to 30s — and a correct one clears the count. It is a single counter rather than a table keyed by address: there is only one account, so an attacker gains nothing by rotating source IPs, and there is no per-client map to grow. The cap is there so that someone hammering the login delays the owner by half a minute rather than locking them out. Wrong credentials now answer 401 rather than 400. The server also warns at startup if DIARY_PASSWORD is still the value from .env.example.
This commit is contained in:
1 parent
f0135d34ac
commit
41d2d7ef77
7 files changed
+119
-3
No files matched your search
@@ -32,6 +32,13 @@ impl Config {
|
|||||||
anyhow::bail!("DIARY_SECRET must be at least 16 characters");
|
anyhow::bail!("DIARY_SECRET must be at least 16 characters");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if password == "change-me" {
|
||||||
|
tracing::warn!(
|
||||||
|
"DIARY_PASSWORD is still the example value from .env.example — \
|
||||||
|
anyone who can reach this server can read the diary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let bind = var("DIARY_BIND")
|
let bind = var("DIARY_BIND")
|
||||||
.unwrap_or_else(|| "127.0.0.1:4242".to_string())
|
.unwrap_or_else(|| "127.0.0.1:4242".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ pub enum AppError {
|
|||||||
Unauthorized,
|
Unauthorized,
|
||||||
NotFound,
|
NotFound,
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
/// The caller must wait this many seconds before trying again.
|
||||||
|
TooManyRequests(i64),
|
||||||
Internal(anyhow::Error),
|
Internal(anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +23,10 @@ impl IntoResponse for AppError {
|
|||||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
|
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
|
||||||
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
|
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
|
||||||
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
|
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
|
||||||
|
AppError::TooManyRequests(wait) => (
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
format!("too many attempts — wait {wait}s"),
|
||||||
|
),
|
||||||
AppError::Internal(e) => {
|
AppError::Internal(e) => {
|
||||||
tracing::error!("internal error: {e:#}");
|
tracing::error!("internal error: {e:#}");
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod error;
|
|||||||
mod routes;
|
mod routes;
|
||||||
mod state;
|
mod state;
|
||||||
mod static_files;
|
mod static_files;
|
||||||
|
mod throttle;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ async fn main() -> Result<()> {
|
|||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
|
login_throttle: Arc::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|||||||
@@ -23,9 +23,17 @@ pub async fn login(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<LoginRequest>,
|
Json(body): Json<LoginRequest>,
|
||||||
) -> AppResult<impl IntoResponse> {
|
) -> AppResult<impl IntoResponse> {
|
||||||
|
state.login_throttle.check().map_err(|wait| {
|
||||||
|
tracing::warn!("login refused, {wait}s of backoff remaining");
|
||||||
|
AppError::TooManyRequests(wait)
|
||||||
|
})?;
|
||||||
|
|
||||||
if !auth::credentials_match(&state.config, &body.username, &body.password) {
|
if !auth::credentials_match(&state.config, &body.username, &body.password) {
|
||||||
return Err(AppError::BadRequest("invalid credentials".into()));
|
state.login_throttle.record_failure();
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.login_throttle.record_success();
|
||||||
let token = auth::issue_token(&state.config);
|
let token = auth::issue_token(&state.config);
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
|
|||||||
+2
-1
@@ -2,10 +2,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::{config::Config, throttle::LoginThrottle};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
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>,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// Failed logins get progressively slower to answer.
|
||||||
|
///
|
||||||
|
/// The diary 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.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LoginThrottle {
|
||||||
|
state: Mutex<State>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct State {
|
||||||
|
failures: u32,
|
||||||
|
next_allowed: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrong passwords happen; the first few are answered at full speed.
|
||||||
|
const FREE_ATTEMPTS: u32 = 3;
|
||||||
|
const MAX_DELAY: i64 = 30;
|
||||||
|
|
||||||
|
/// 1s, 2s, 4s, 8s… up to `MAX_DELAY`, starting once the free attempts are used.
|
||||||
|
fn delay_after(failures: u32) -> i64 {
|
||||||
|
match failures.checked_sub(FREE_ATTEMPTS) {
|
||||||
|
None | Some(0) => 0,
|
||||||
|
Some(over) => 1i64.checked_shl(over - 1).unwrap_or(MAX_DELAY).min(MAX_DELAY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginThrottle {
|
||||||
|
/// `Err(seconds)` when this attempt is too soon after the last failure.
|
||||||
|
pub fn check(&self) -> Result<(), i64> {
|
||||||
|
let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
match state.next_allowed - crate::now() {
|
||||||
|
wait if wait > 0 => Err(wait),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_failure(&self) {
|
||||||
|
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
state.failures = state.failures.saturating_add(1);
|
||||||
|
state.next_allowed = crate::now() + delay_after(state.failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_success(&self) {
|
||||||
|
*self.state.lock().unwrap_or_else(|e| e.into_inner()) = State::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{delay_after, LoginThrottle, MAX_DELAY};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_first_few_mistakes_are_free() {
|
||||||
|
assert_eq!(delay_after(0), 0);
|
||||||
|
assert_eq!(delay_after(3), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn then_the_wait_doubles_up_to_the_cap() {
|
||||||
|
assert_eq!(delay_after(4), 1);
|
||||||
|
assert_eq!(delay_after(5), 2);
|
||||||
|
assert_eq!(delay_after(6), 4);
|
||||||
|
assert_eq!(delay_after(9), 30);
|
||||||
|
// Never overflows, however long the attack runs.
|
||||||
|
assert_eq!(delay_after(u32::MAX), MAX_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_correct_password_clears_the_debt() {
|
||||||
|
let throttle = LoginThrottle::default();
|
||||||
|
for _ in 0..10 {
|
||||||
|
throttle.record_failure();
|
||||||
|
}
|
||||||
|
assert!(throttle.check().is_err());
|
||||||
|
throttle.record_success();
|
||||||
|
assert!(throttle.check().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ApiError } from './api'
|
||||||
import { diary } from './store.svelte'
|
import { diary } from './store.svelte'
|
||||||
|
|
||||||
let username = $state('')
|
let username = $state('')
|
||||||
@@ -18,7 +19,14 @@
|
|||||||
try {
|
try {
|
||||||
await diary.login(username, password)
|
await diary.login(username, password)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'login failed'
|
// The server answers a wrong password with a bare 401; the throttle's
|
||||||
|
// "wait Ns" message is worth showing verbatim.
|
||||||
|
error =
|
||||||
|
e instanceof ApiError && e.status === 401
|
||||||
|
? 'invalid credentials'
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: 'login failed'
|
||||||
password = ''
|
password = ''
|
||||||
} finally {
|
} finally {
|
||||||
busy = false
|
busy = false
|
||||||
|
|||||||
Reference in new issue
Block a user