SA-005 addressed by updateing ratelimiting
Build LinkLog Development Image / development-image (push) Successful in 8s

This commit is contained in:
2026-08-26 17:03:32 +02:00
parent c70850b44d
commit 27f26e615a
7 changed files with 114 additions and 9 deletions
+14 -4
View File
@@ -3,7 +3,7 @@
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from backend.app.api.dependencies import get_current_user
@@ -17,6 +17,7 @@ from backend.app.services.token_service import issue_token, revoke_token, valida
from backend.app.services.otp_service import verify_code
from backend.app.services.secret_store import decrypt_secret
from backend.app.services.email_addresses import verify_user_email_address
from backend.app.services.login_throttle import check_login_allowed, clear_login_failures, record_login_failure
router = APIRouter()
@@ -35,10 +36,17 @@ class PasswordResetRequest(BaseModel):
@router.post('/login')
def login(payload: LoginRequest):
user = authenticate_user(payload.email.strip(), payload.password)
def login(payload: LoginRequest, request: Request):
email = payload.email.strip()
ip_address = request.client.host if request.client else 'unknown'
retry_after = check_login_allowed(ip_address, email)
if retry_after is not None:
raise HTTPException(status_code=429, detail='Too many failed login attempts. Try again later.', headers={'Retry-After': str(retry_after)})
user = authenticate_user(email, payload.password)
if user is None:
reset_user = find_user(payload.email.strip())
record_login_failure(ip_address, email)
reset_user = find_user(email)
if reset_user and reset_user['email_verified'] and smtp_configured():
try:
token = create_reset_token(reset_user['id'])
@@ -50,8 +58,10 @@ def login(payload: LoginRequest):
if not user['email_verified']:
raise HTTPException(status_code=403, detail='Email address is not verified')
if user['otp_enabled'] and not verify_code(decrypt_secret(user['otp_secret']), payload.otp):
record_login_failure(ip_address, email)
raise HTTPException(status_code=401, detail='One-time password required or invalid')
clear_login_failures(ip_address, email)
token_data = issue_token(user['id'], user['username'])
return {
'access_token': token_data['access_token'],
+64
View File
@@ -0,0 +1,64 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime, timedelta, timezone
from hashlib import sha256
import json
from backend.app.database import get_connection
MAX_FAILURES = 5
FAILURE_WINDOW = timedelta(minutes=15)
LOCKOUT_DURATION = timedelta(minutes=2)
def _setting_name(ip_address: str, email: str) -> str:
key = sha256(f'{ip_address}\0{email.casefold()}'.encode('utf-8')).hexdigest()
return f'login_rate:{key}'
def _read_rate(setting_name: str) -> dict:
with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
if not row:
return {}
try:
return json.loads(row['value'])
except (TypeError, json.JSONDecodeError):
return {}
def check_login_allowed(ip_address: str, email: str) -> int | None:
rate = _read_rate(_setting_name(ip_address, email))
now = datetime.now(timezone.utc)
locked_until = datetime.fromisoformat(rate['locked_until']) if rate.get('locked_until') else None
if locked_until and locked_until > now:
return int((locked_until - now).total_seconds()) + 1
return None
def record_login_failure(ip_address: str, email: str) -> None:
setting_name = _setting_name(ip_address, email)
now = datetime.now(timezone.utc)
rate = _read_rate(setting_name)
first_failure = datetime.fromisoformat(rate['first_failure']) if rate.get('first_failure') else now
if now - first_failure >= FAILURE_WINDOW:
rate = {}
first_failure = now
failures = int(rate.get('failures', 0)) + 1
updated = {'failures': failures, 'first_failure': first_failure.isoformat()}
if failures >= MAX_FAILURES:
updated['locked_until'] = (now + LOCKOUT_DURATION).isoformat()
with get_connection() as conn:
conn.execute(
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
(setting_name, json.dumps(updated)),
)
conn.commit()
def clear_login_failures(ip_address: str, email: str) -> None:
with get_connection() as conn:
conn.execute('DELETE FROM app_settings WHERE name = ?', (_setting_name(ip_address, email),))
conn.commit()