From e1ec7ede4513c88bfbe93daec490d449a02ac26b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 7 Mar 2026 17:09:22 +0100 Subject: [PATCH] feat: implement brute force protection and error handling for authentication --- src/routes/api.py | 57 +++++++++++++++++++++++++++- src/templates/static/js/dashboard.js | 16 +++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/routes/api.py b/src/routes/api.py index e830423..9830af9 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -10,6 +10,7 @@ import hashlib import hmac import os import secrets +import time from fastapi import APIRouter, Request, Response, Query, Cookie from fastapi.responses import JSONResponse, PlainTextResponse @@ -21,6 +22,12 @@ from logger import get_app_logger # Server-side session token store (valid tokens for authenticated sessions) _auth_tokens: set = set() +# Bruteforce protection: tracks failed attempts per IP +# { ip: { "attempts": int, "locked_until": float } } +_auth_attempts: dict = {} +_AUTH_MAX_ATTEMPTS = 5 +_AUTH_BASE_LOCKOUT = 30 # seconds, doubles on each lockout + router = APIRouter() @@ -45,9 +52,27 @@ def verify_auth(request: Request) -> bool: @router.post("/api/auth") async def authenticate(request: Request, body: AuthRequest): + ip = request.client.host + + # Check if IP is currently locked out + record = _auth_attempts.get(ip) + if record and record["locked_until"] > time.time(): + remaining = int(record["locked_until"] - time.time()) + return JSONResponse( + content={ + "authenticated": False, + "error": f"Too many attempts. Try again in {remaining}s", + "locked": True, + "retry_after": remaining, + }, + status_code=429, + ) + config = request.app.state.config expected = hashlib.sha256(config.dashboard_password.encode()).hexdigest() if hmac.compare_digest(body.fingerprint, expected): + # Success — clear failed attempts + _auth_attempts.pop(ip, None) token = secrets.token_hex(32) _auth_tokens.add(token) response = JSONResponse(content={"authenticated": True}) @@ -58,8 +83,38 @@ async def authenticate(request: Request, body: AuthRequest): samesite="strict", ) return response + + # Failed attempt — track and possibly lock out + if not record: + record = {"attempts": 0, "locked_until": 0, "lockouts": 0} + _auth_attempts[ip] = record + record["attempts"] += 1 + + if record["attempts"] >= _AUTH_MAX_ATTEMPTS: + lockout = _AUTH_BASE_LOCKOUT * (2 ** record["lockouts"]) + record["locked_until"] = time.time() + lockout + record["lockouts"] += 1 + record["attempts"] = 0 + get_app_logger().warning( + f"Auth bruteforce: IP {ip} locked out for {lockout}s " + f"(lockout #{record['lockouts']})" + ) + return JSONResponse( + content={ + "authenticated": False, + "error": f"Too many attempts. Locked for {lockout}s", + "locked": True, + "retry_after": lockout, + }, + status_code=429, + ) + + remaining_attempts = _AUTH_MAX_ATTEMPTS - record["attempts"] return JSONResponse( - content={"authenticated": False, "error": "Invalid password"}, + content={ + "authenticated": False, + "error": f"Invalid password. {remaining_attempts} attempt{'s' if remaining_attempts != 1 else ''} remaining", + }, status_code=401, ) diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index 719d9dd..66e3eac 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -136,8 +136,22 @@ document.addEventListener('alpine:init', () => { this.closeAuthModal(); this.switchToAdmin(); } else { - this.authModal.error = 'Invalid password'; + const data = await resp.json().catch(() => ({})); + this.authModal.error = data.error || 'Invalid password'; + this.authModal.password = ''; this.authModal.loading = false; + if (data.locked && data.retry_after) { + let remaining = data.retry_after; + const interval = setInterval(() => { + remaining--; + if (remaining <= 0) { + clearInterval(interval); + this.authModal.error = ''; + } else { + this.authModal.error = `Too many attempts. Try again in ${remaining}s`; + } + }, 1000); + } } } catch { this.authModal.error = 'Authentication failed';