feat: implement brute force protection and error handling for authentication
This commit is contained in:
@@ -10,6 +10,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Response, Query, Cookie
|
from fastapi import APIRouter, Request, Response, Query, Cookie
|
||||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
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)
|
# Server-side session token store (valid tokens for authenticated sessions)
|
||||||
_auth_tokens: set = set()
|
_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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -45,9 +52,27 @@ def verify_auth(request: Request) -> bool:
|
|||||||
|
|
||||||
@router.post("/api/auth")
|
@router.post("/api/auth")
|
||||||
async def authenticate(request: Request, body: AuthRequest):
|
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
|
config = request.app.state.config
|
||||||
expected = hashlib.sha256(config.dashboard_password.encode()).hexdigest()
|
expected = hashlib.sha256(config.dashboard_password.encode()).hexdigest()
|
||||||
if hmac.compare_digest(body.fingerprint, expected):
|
if hmac.compare_digest(body.fingerprint, expected):
|
||||||
|
# Success — clear failed attempts
|
||||||
|
_auth_attempts.pop(ip, None)
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
_auth_tokens.add(token)
|
_auth_tokens.add(token)
|
||||||
response = JSONResponse(content={"authenticated": True})
|
response = JSONResponse(content={"authenticated": True})
|
||||||
@@ -58,8 +83,38 @@ async def authenticate(request: Request, body: AuthRequest):
|
|||||||
samesite="strict",
|
samesite="strict",
|
||||||
)
|
)
|
||||||
return response
|
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(
|
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,
|
status_code=401,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -136,8 +136,22 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.closeAuthModal();
|
this.closeAuthModal();
|
||||||
this.switchToAdmin();
|
this.switchToAdmin();
|
||||||
} else {
|
} 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;
|
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 {
|
} catch {
|
||||||
this.authModal.error = 'Authentication failed';
|
this.authModal.error = 'Authentication failed';
|
||||||
|
|||||||
Reference in New Issue
Block a user