Skip to content
Commits on Source (3)
  • Gabriel Antico's avatar
    feat(auth): per-email lockout dopo 5 fallimenti consecutivi · 4315f60c
    Gabriel Antico authored
    In topologia NAT corporate il rate-limit per-IP non discrimina utenti
    reali (tutti CoinSafe escono da 192.168.0.1 verso Caddy). HDRCR-117 ha
    alzato il limite a 60/min per non bloccare utenti legittimi, ma serviva
    una difesa per brute-force su una singola credenziale che fosse
    indipendente dall'IP.
    
    `app/auth/lockout.py` implementa counter+flag su redis con TTL:
    - `auth:failed:<email>` counter di fallimenti consecutivi (TTL 5 min)
    - `auth:locked:<email>` flag di lockout attivo (TTL 15 min)
    
    5 fallimenti su `alice@x` dentro 5 min lockano `alice@x` per 15 min, da
    qualunque IP. Login riuscito resetta il counter. L'errore restituito è
    sempre lo stesso 401 generico per evitare user-enumeration.
    
    Se redis è giù degrada silenziosamente: niente lockout, ma il
    rate-limit per-IP del Limiter resta come backstop.
    
    Test fakeano `redis.asyncio.from_url` con uno store in-memory che
    implementa solo i comandi usati — niente redis richiesto in CI.
    
    HDRCR-118 #comment Aggiunto app/auth/lockout.py e integrato nel /auth/login
    4315f60c
  • Gabriel Antico's avatar
    Merge branch 'feat/HDRCR-118-per-email-lockout' into 'dev' · 398a568c
    Gabriel Antico authored
    HDRCR-118: per-email lockout dopo 5 fallimenti consecutivi
    
    See merge request !188
    398a568c
  • semantic-release-bot's avatar
    chore(release): 0.41.0 [skip ci] · 135995df
    semantic-release-bot authored
    ## [0.41.0](v0.40.1...v0.41.0) (2026-06-01)
    
    ### Features
    
    * **auth:** per-email lockout dopo 5 fallimenti consecutivi ([4315f60c](4315f60c)), closes [#comment](https://gitlab.coinsafe.it/coinsafe-devs/hydrocore/issues/comment)
    135995df
......@@ -2,6 +2,12 @@
Tutti i cambiamenti rilevanti di hydrocore. Formato: [Conventional Commits](https://www.conventionalcommits.org/) + [SemVer](https://semver.org/). Generato automaticamente da semantic-release: NON modificare a mano.
## [0.41.0](https://gitlab.coinsafe.it/coinsafe-devs/hydrocore/compare/v0.40.1...v0.41.0) (2026-06-01)
### Features
* **auth:** per-email lockout dopo 5 fallimenti consecutivi ([4315f60](https://gitlab.coinsafe.it/coinsafe-devs/hydrocore/commit/4315f60cd64bfb12574a3c9685911f91e7582ca8)), closes [#comment](https://gitlab.coinsafe.it/coinsafe-devs/hydrocore/issues/comment)
## [0.40.1](https://gitlab.coinsafe.it/coinsafe-devs/hydrocore/compare/v0.40.0...v0.40.1) (2026-06-01)
### Bug Fixes
......
"""Per-email lockout dei tentativi di login.
Indipendente dall'IP del client — necessario nella topologia NAT corporate
CoinSafe (HDRCR-117) dove rate-limit per-IP colpisce tutti gli utenti
dietro lo stesso gateway. Il counter è agganciato all'email tentata,
quindi 5 password sbagliate su `alice@x` da IP diversi lockano `alice@x`
e nessun altro account.
Storage: redis (riusiamo `settings.redis_url`). Chiavi:
- ``auth:failed:<email>`` counter di fallimenti consecutivi (TTL 5 min)
- ``auth:locked:<email>`` flag di lockout attivo (TTL 15 min)
Se redis è giù, degrada silenziosamente: niente lockout, ma il
rate-limit per-IP del Limiter resta come backstop.
"""
from __future__ import annotations
import logging
import redis.asyncio as aioredis
from app.config import get_settings
logger = logging.getLogger(__name__)
FAILED_WINDOW_SECONDS = 300
LOCKOUT_DURATION_SECONDS = 900
FAILED_THRESHOLD = 5
def _normalize(email: str) -> str:
"""Lowercase + strip — l'utente può digitare la mail con
maiuscole/spazi, ma la chiave deve essere stabile."""
return email.strip().lower()
async def _redis_client():
"""Crea un client redis fresh per ogni chiamata.
Volutamente NON cachato: la connessione torna al pool del driver
appena la coroutine finisce. Se redis è giù `from_url` non solleva
finché non si tenta un comando.
"""
return aioredis.from_url(get_settings().redis_url)
async def is_locked(email: str) -> bool:
"""Ritorna True se l'email è attualmente lockata.
Non solleva mai: se redis è giù, ritorna False (degrade gracefully).
"""
try:
client = await _redis_client()
try:
return bool(await client.get(f"auth:locked:{_normalize(email)}"))
finally:
await client.aclose()
except Exception: # noqa: BLE001
# Redis irraggiungibile: il limiter per-IP resta backstop.
return False
async def record_failure(email: str) -> bool:
"""Incrementa il counter di fallimenti per `email`. Se supera la
soglia, attiva il lockout. Ritorna True se il lockout è appena
scattato (utile per loggarlo)."""
try:
client = await _redis_client()
try:
normalized = _normalize(email)
counter_key = f"auth:failed:{normalized}"
count = await client.incr(counter_key)
# `EXPIRE` ogni volta resetta la finestra mobile: 5 fallimenti
# in 5 min consecutivi triggrano il lockout; un singolo fail
# isolato espira da solo dopo 5 min.
await client.expire(counter_key, FAILED_WINDOW_SECONDS)
if count >= FAILED_THRESHOLD:
lock_key = f"auth:locked:{normalized}"
# `SET .. NX` evita di rinfrescare il TTL se già lockato.
was_set = await client.set(
lock_key, "1", ex=LOCKOUT_DURATION_SECONDS, nx=True
)
if was_set:
logger.warning(
"auth_lockout email=%s failures=%d duration_s=%d",
normalized,
count,
LOCKOUT_DURATION_SECONDS,
)
return True
return False
finally:
await client.aclose()
except Exception: # noqa: BLE001
return False
async def reset(email: str) -> None:
"""Cancella counter e lockout per `email` — chiamato dopo un login
riuscito."""
try:
client = await _redis_client()
try:
normalized = _normalize(email)
await client.delete(
f"auth:failed:{normalized}", f"auth:locked:{normalized}"
)
finally:
await client.aclose()
except Exception: # noqa: BLE001
pass
......@@ -13,6 +13,11 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.auth.lockout import (
is_locked,
record_failure,
reset as reset_lockout,
)
from app.auth.schemas import (
ChangePasswordRequest,
DeleteAccountRequest,
......@@ -173,10 +178,21 @@ async def login(
esce da un unico NAT corporate verso Caddy: tutti gli utenti
condividono lo stesso IP nel ``X-Forwarded-For`` (HDRCR-117), quindi
un limite più stretto (es. 5/min) bloccava utenti legittimi con 429.
Il vero contenimento contro brute-force su credenziali specifiche va
implementato come per-email lockout (HDRCR-118 follow-up),
semanticamente indipendente dall'IP.
HDRCR-118 — contenimento contro brute-force per credenziali
specifiche: per-email lockout. 5 fallimenti consecutivi sulla stessa
email entro 5 min → lockout 15 min su quella email, da qualunque IP.
L'errore è sempre lo stesso 401 generico per non leakare il fatto
che l'account esiste e/o è lockato (user-enumeration).
"""
# Lockout pre-check: se l'email è lockata, blocca prima ancora di
# toccare il DB o computare bcrypt.
if await is_locked(form_data.username):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
# Filter soft-deleted rows: with the partial unique index introduced in
# migration 028, multiple rows can legitimately share the same email
# (one live + N soft-deleted). Without this filter, scalar_one_or_none
......@@ -193,12 +209,14 @@ async def login(
if user is None:
# Timing-safe: hash against dummy to prevent user enumeration
verify_password(form_data.password, DUMMY_HASH)
await record_failure(form_data.username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
if not verify_password(form_data.password, user.password_hash):
await record_failure(form_data.username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
......@@ -210,6 +228,9 @@ async def login(
detail="Incorrect email or password",
)
# Login riuscito: reset del counter dei fallimenti per questa email.
await reset_lockout(form_data.username)
user.ultimo_accesso = datetime.now(UTC)
app_version = request.headers.get("X-App-Version")
if app_version:
......
{
"name": "frontend-office",
"private": true,
"version": "0.40.1",
"version": "0.41.0",
"type": "module",
"scripts": {
"dev": "vite",
......
{
"name": "frontend",
"private": true,
"version": "0.40.1",
"version": "0.41.0",
"type": "module",
"scripts": {
"dev": "vite",
......
[project]
name = "hydrocore"
version = "0.40.1"
version = "0.41.0"
description = "Backend orchestratore per l'ecosistema Hydromoving HHO"
requires-python = ">=3.12"
dependencies = [
......
"""Tests per il per-email lockout (HDRCR-118).
Coprono il modulo `app.auth.lockout` senza richiedere un redis vero:
mockiamo `redis.asyncio.from_url` con un fake in-memory che implementa
soltanto i comandi che usiamo (`get`, `set`, `incr`, `expire`, `delete`,
`aclose`).
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from app.auth import lockout
class _FakeRedis:
"""Fake redis client async con il subset di comandi usato dal modulo."""
def __init__(self, store: dict[str, str]) -> None:
self._store = store
async def get(self, key: str) -> str | None:
return self._store.get(key)
async def set(
self,
key: str,
value: str,
ex: int | None = None,
nx: bool = False,
) -> bool | None:
if nx and key in self._store:
return None
self._store[key] = value
return True
async def incr(self, key: str) -> int:
current = int(self._store.get(key, "0"))
current += 1
self._store[key] = str(current)
return current
async def expire(self, key: str, seconds: int) -> bool:
# TTL non simulato — non rilevante per i test logici
return key in self._store
async def delete(self, *keys: str) -> int:
removed = 0
for k in keys:
if k in self._store:
del self._store[k]
removed += 1
return removed
async def aclose(self) -> None:
return None
@pytest.fixture
def fake_redis():
"""Patcha ``aioredis.from_url`` con un fake store condiviso fra
chiamate dentro lo stesso test."""
store: dict[str, str] = {}
def _factory(url: str, **_: object) -> _FakeRedis:
return _FakeRedis(store)
with patch("app.auth.lockout.aioredis.from_url", side_effect=_factory):
yield store
async def test_no_lockout_at_start(fake_redis: dict[str, str]) -> None:
assert await lockout.is_locked("alice@example.com") is False
async def test_lockout_after_threshold(fake_redis: dict[str, str]) -> None:
email = "alice@example.com"
for i in range(lockout.FAILED_THRESHOLD - 1):
triggered = await lockout.record_failure(email)
assert triggered is False, f"attempt {i + 1} should not lock"
# 5° fallimento triggera il lockout
triggered = await lockout.record_failure(email)
assert triggered is True
assert await lockout.is_locked(email) is True
async def test_lockout_is_per_email_not_global(
fake_redis: dict[str, str],
) -> None:
# 5 fallimenti su alice
for _ in range(lockout.FAILED_THRESHOLD):
await lockout.record_failure("alice@example.com")
# bob resta libero
assert await lockout.is_locked("alice@example.com") is True
assert await lockout.is_locked("bob@example.com") is False
async def test_reset_clears_counter_and_lock(
fake_redis: dict[str, str],
) -> None:
email = "alice@example.com"
for _ in range(lockout.FAILED_THRESHOLD):
await lockout.record_failure(email)
assert await lockout.is_locked(email) is True
await lockout.reset(email)
assert await lockout.is_locked(email) is False
# un nuovo singolo fail dopo il reset non deve far ripartire il lockout
triggered = await lockout.record_failure(email)
assert triggered is False
async def test_email_normalization(fake_redis: dict[str, str]) -> None:
# tre varianti della stessa email contano sullo stesso counter
await lockout.record_failure("Alice@Example.com")
await lockout.record_failure(" alice@example.COM ")
await lockout.record_failure("alice@example.com")
assert await lockout.is_locked("alice@example.com") is False
# ancora 2 a soglia 5
await lockout.record_failure("alice@EXAMPLE.com")
await lockout.record_failure("ALICE@example.com")
assert await lockout.is_locked("alice@example.com") is True
async def test_redis_down_degrades_gracefully() -> None:
"""Se redis è giù, is_locked/record_failure/reset non sollevano."""
def _boom(url: str, **_: object) -> _FakeRedis:
raise ConnectionError("redis is down")
with patch("app.auth.lockout.aioredis.from_url", side_effect=_boom):
assert await lockout.is_locked("alice@example.com") is False
assert await lockout.record_failure("alice@example.com") is False
# reset deve essere idempotente anche con redis down
await lockout.reset("alice@example.com")
......@@ -739,7 +739,7 @@ wheels = [
[[package]]
name = "hydrocore"
version = "0.40.1"
version = "0.41.0"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
......