feat: harden auth, shutdown, and session bundle (0.35.0)
Three features bundled into one release: - rate-limit /auth/login, /register, /me/password (token bucket, 5 req/sec sustained with 10-request burst by default; 429 + Retry-After header on hit; tracing::warn! per hit so operators see attack patterns; AUTH_RATE_PER_SEC / AUTH_RATE_BURST env knobs) - handle SIGTERM for graceful container stops (replaces bare ctrl_c() with a select over ctrl_c + SignalKind::terminate() so docker compose stop runs the daemon shutdown path instead of letting Chromium leak past SIGKILL) - clear session.user on 401 from any API call (setOn401Hook in api/client.ts, registered from session.svelte.ts gated on $app/environment::browser so the SSR bundle never installs it; fixes "logged in but no bookmarks/collections" mid-session expiry state) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,7 @@ async fn register(
|
||||
jar: CookieJar,
|
||||
Json(input): Json<Credentials>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
check_auth_rate_limit(&state, "register")?;
|
||||
let username = input.username.trim();
|
||||
validate_username(username)?;
|
||||
validate_password(&input.password)?;
|
||||
@@ -97,6 +98,7 @@ async fn login(
|
||||
jar: CookieJar,
|
||||
Json(input): Json<Credentials>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
check_auth_rate_limit(&state, "login")?;
|
||||
let username = input.username.trim();
|
||||
if username.is_empty() || input.password.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
@@ -172,6 +174,7 @@ async fn change_password(
|
||||
jar: CookieJar,
|
||||
Json(input): Json<ChangePassword>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
check_auth_rate_limit(&state, "change_password")?;
|
||||
if !verify_password(&input.current_password, &user.password_hash) {
|
||||
return Err(AppError::Unauthenticated);
|
||||
}
|
||||
@@ -332,6 +335,33 @@ fn build_expired_cookie(cfg: &AuthConfig) -> Cookie<'static> {
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Consume one token from the shared auth rate limiter. Called at the
|
||||
/// start of `register`, `login`, and `change_password` so credential
|
||||
/// stuffing / spraying / username-probe loops are throttled by the
|
||||
/// configured budget (default 5/sec with a 10-request burst).
|
||||
///
|
||||
/// All three endpoints share one bucket — they all expose the same
|
||||
/// argon2-verify-or-create work and the same enumeration channels, so
|
||||
/// any one of them in a tight loop should trip the limit. `endpoint`
|
||||
/// is included in the rate-limit-hit log line so operators can tell
|
||||
/// which endpoint is being probed.
|
||||
fn check_auth_rate_limit(state: &AppState, endpoint: &'static str) -> AppResult<()> {
|
||||
use crate::auth::rate_limit::AcquireResult;
|
||||
match state.auth_limiter.try_acquire() {
|
||||
AcquireResult::Allowed => Ok(()),
|
||||
AcquireResult::Denied { retry_after_secs } => {
|
||||
tracing::warn!(
|
||||
endpoint,
|
||||
retry_after_secs,
|
||||
"auth rate limit hit; returning 429"
|
||||
);
|
||||
Err(AppError::TooManyRequests {
|
||||
retry_after_secs: Some(retry_after_secs),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_username(u: &str) -> AppResult<()> {
|
||||
if u.is_empty() {
|
||||
return Err(AppError::InvalidInput("username is required".into()));
|
||||
|
||||
Reference in New Issue
Block a user