Rate limit account authentication attempts
This commit is contained in:
@@ -16,6 +16,10 @@ from app import database
|
||||
|
||||
PASSWORD_ITERATIONS = 310_000
|
||||
SESSION_LIFETIME_SECONDS = 7 * 24 * 60 * 60
|
||||
LOGIN_FAILURE_LIMIT = 5
|
||||
LOGIN_WINDOW_SECONDS = 15 * 60
|
||||
REGISTRATION_LIMIT = 30
|
||||
REGISTRATION_WINDOW_SECONDS = 60 * 60
|
||||
_EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||||
_DUMMY_SALT = bytes.fromhex("4f2bc994cb56a7a98b253a615fb57d2c")
|
||||
_DUMMY_DIGEST = hashlib.pbkdf2_hmac(
|
||||
@@ -30,6 +34,116 @@ def normalize_email(email: str) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _fingerprint(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
|
||||
|
||||
|
||||
def _rate_limit_keys(email: str, client_host: str) -> tuple[tuple[str, str], ...]:
|
||||
normalized = normalize_email(email)
|
||||
return (
|
||||
("login-email", _fingerprint(normalized)),
|
||||
("login-client", _fingerprint(client_host or "unknown")),
|
||||
)
|
||||
|
||||
|
||||
def login_retry_after(email: str, client_host: str, *, now: int | None = None) -> int:
|
||||
"""Return seconds until a login attempt is allowed, or zero when clear."""
|
||||
current = int(time.time()) if now is None else now
|
||||
retry = 0
|
||||
with database._connect() as connection:
|
||||
for scope, fingerprint in _rate_limit_keys(email, client_host):
|
||||
row = connection.execute(
|
||||
"SELECT window_started_at, attempts FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
|
||||
(scope, fingerprint),
|
||||
).fetchone()
|
||||
if row is None or row["attempts"] < LOGIN_FAILURE_LIMIT:
|
||||
continue
|
||||
remaining = LOGIN_WINDOW_SECONDS - (current - row["window_started_at"])
|
||||
retry = max(retry, remaining)
|
||||
connection.execute(
|
||||
"DELETE FROM auth_rate_limits WHERE window_started_at + ? <= ?",
|
||||
(LOGIN_WINDOW_SECONDS, current),
|
||||
)
|
||||
return max(0, retry)
|
||||
|
||||
|
||||
def record_login_failure(email: str, client_host: str, *, now: int | None = None) -> None:
|
||||
current = int(time.time()) if now is None else now
|
||||
with database._connect() as connection:
|
||||
for scope, fingerprint in _rate_limit_keys(email, client_host):
|
||||
connection.execute(
|
||||
"""INSERT INTO auth_rate_limits(scope,fingerprint,window_started_at,attempts)
|
||||
VALUES(?,?,?,1)
|
||||
ON CONFLICT(scope,fingerprint) DO UPDATE SET
|
||||
window_started_at=CASE
|
||||
WHEN window_started_at + ? <= ? THEN ? ELSE window_started_at END,
|
||||
attempts=CASE
|
||||
WHEN window_started_at + ? <= ? THEN 1 ELSE attempts + 1 END""",
|
||||
(
|
||||
scope, fingerprint, current,
|
||||
LOGIN_WINDOW_SECONDS, current, current,
|
||||
LOGIN_WINDOW_SECONDS, current,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def clear_login_failures(email: str, client_host: str) -> None:
|
||||
with database._connect() as connection:
|
||||
connection.executemany(
|
||||
"DELETE FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
|
||||
_rate_limit_keys(email, client_host),
|
||||
)
|
||||
|
||||
|
||||
def registration_retry_after(client_host: str, *, now: int | None = None) -> int:
|
||||
return _retry_after(
|
||||
"register-client", client_host or "unknown", REGISTRATION_LIMIT,
|
||||
REGISTRATION_WINDOW_SECONDS, now=now,
|
||||
)
|
||||
|
||||
|
||||
def record_registration_attempt(client_host: str, *, now: int | None = None) -> None:
|
||||
_record_attempt(
|
||||
"register-client", client_host or "unknown", REGISTRATION_WINDOW_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def _retry_after(
|
||||
scope: str, value: str, limit: int, duration: int, *, now: int | None = None
|
||||
) -> int:
|
||||
current = int(time.time()) if now is None else now
|
||||
fingerprint = _fingerprint(value)
|
||||
with database._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT window_started_at,attempts FROM auth_rate_limits WHERE scope=? AND fingerprint=?",
|
||||
(scope, fingerprint),
|
||||
).fetchone()
|
||||
if row is None or row["attempts"] < limit:
|
||||
return 0
|
||||
return max(0, duration - (current - row["window_started_at"]))
|
||||
|
||||
|
||||
def _record_attempt(
|
||||
scope: str, value: str, duration: int, *, now: int | None = None
|
||||
) -> None:
|
||||
current = int(time.time()) if now is None else now
|
||||
fingerprint = _fingerprint(value)
|
||||
with database._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO auth_rate_limits(scope,fingerprint,window_started_at,attempts)
|
||||
VALUES(?,?,?,1)
|
||||
ON CONFLICT(scope,fingerprint) DO UPDATE SET
|
||||
window_started_at=CASE WHEN window_started_at + ? <= ? THEN ? ELSE window_started_at END,
|
||||
attempts=CASE WHEN window_started_at + ? <= ? THEN 1 ELSE attempts + 1 END""",
|
||||
(
|
||||
scope, fingerprint, current,
|
||||
duration, current, current,
|
||||
duration, current,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
|
||||
@@ -71,6 +71,14 @@ def initialize_database() -> None:
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
|
||||
ON auth_sessions(user_id, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
scope TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
window_started_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL CHECK(attempts >= 0),
|
||||
PRIMARY KEY(scope, fingerprint)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -1179,7 +1179,16 @@ def create_local_session(request: Request) -> dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/v1/auth/register", status_code=201)
|
||||
def register_account(credentials: PasswordCredentials) -> dict[str, Any]:
|
||||
def register_account(credentials: PasswordCredentials, request: Request) -> dict[str, Any]:
|
||||
client_host = request.client.host if request.client is not None else "unknown"
|
||||
retry_after = auth.registration_retry_after(client_host)
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="تم بلوغ حد إنشاء الحسابات من هذا الاتصال؛ حاول لاحقًا.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
auth.record_registration_attempt(client_host)
|
||||
try:
|
||||
user_id = auth.create_account(credentials.email, credentials.password)
|
||||
email = auth.normalize_email(credentials.email)
|
||||
@@ -1190,13 +1199,27 @@ def register_account(credentials: PasswordCredentials) -> dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/v1/auth/login")
|
||||
def login_account(credentials: PasswordCredentials) -> dict[str, Any]:
|
||||
def login_account(credentials: PasswordCredentials, request: Request) -> dict[str, Any]:
|
||||
try:
|
||||
account = auth.authenticate(credentials.email, credentials.password)
|
||||
email = auth.normalize_email(credentials.email)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
client_host = request.client.host if request.client is not None else "unknown"
|
||||
retry_after = auth.login_retry_after(email, client_host)
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="محاولات الدخول كثيرة؛ انتظر انتهاء المهلة ثم أعد المحاولة.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
try:
|
||||
account = auth.authenticate(email, credentials.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if account is None:
|
||||
auth.record_login_failure(email, client_host)
|
||||
raise HTTPException(status_code=401, detail="البريد الإلكتروني أو كلمة المرور غير صحيحة.")
|
||||
auth.clear_login_failures(email, client_host)
|
||||
user_id, email = account
|
||||
return _auth_response(user_id, email, "account")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user