fix(security): consolidation & securite phase 1 (#84, BUG-021 a BUG-034)
- sanitizer XSS serveur (markdown + page de partage) [BUG-021/022] - rate-limit/lockout MFA [BUG-023] - isolation vaults par segments [BUG-024] - caps regex ReDoS [BUG-025] - SSRF webhooks + secrets externalises [BUG-026] - rotation/revocation des jetons [BUG-027] - politique de mot de passe + invalidation sessions [BUG-028] - verrous users.json [BUG-029] - IP reelle dans les audits [BUG-030] - rate-limit par compte [BUG-031] - symlinks hors vault ignores [BUG-032] - recherche simple via inverted index [BUG-033] - token en memoire + cookie HttpOnly, CSP durcie [BUG-034] Tests: pytest 961 passed / 6 skipped, ruff 0, mypy 0, frontend vert.
This commit is contained in:
@@ -62,16 +62,21 @@ def create_access_token(user: dict) -> str:
|
||||
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(username: str) -> tuple:
|
||||
"""Create a JWT refresh token. Returns (token_string, jti)."""
|
||||
def create_refresh_token(username: str, remember: bool = False) -> tuple:
|
||||
"""Create a JWT refresh token. Returns (token_string, jti).
|
||||
|
||||
``remember`` is carried as a claim so token rotation can preserve the
|
||||
30-day vs 7-day lifetime chosen at login.
|
||||
"""
|
||||
now = int(time.time())
|
||||
jti = str(uuid.uuid4())
|
||||
payload = {
|
||||
"sub": username,
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"exp": now + REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
"exp": now + (2592000 if remember else REFRESH_TOKEN_EXPIRE_SECONDS),
|
||||
"type": "refresh",
|
||||
"remember": remember,
|
||||
}
|
||||
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import os
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from .jwt_handler import decode_token
|
||||
from backend.services.net import get_client_ip
|
||||
|
||||
from .jwt_handler import decode_token, is_token_revoked
|
||||
from .user_store import get_user
|
||||
|
||||
logger = logging.getLogger("obsigate.auth.middleware")
|
||||
@@ -42,6 +44,7 @@ def get_current_user(
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"_token_vaults": ["*"],
|
||||
"_request_ip": get_client_ip(request),
|
||||
}
|
||||
|
||||
token = None
|
||||
@@ -57,14 +60,31 @@ def get_current_user(
|
||||
if not payload or payload.get("type") != "access":
|
||||
return None
|
||||
|
||||
# BUG-027: access tokens revoked at logout must be rejected immediately.
|
||||
jti = payload.get("jti")
|
||||
if jti and is_token_revoked(jti):
|
||||
return None
|
||||
|
||||
user = get_user(payload["sub"])
|
||||
if not user or not user.get("active"):
|
||||
return None
|
||||
|
||||
# BUG-028: a password change invalidates every token issued before it.
|
||||
pca = user.get("password_changed_at")
|
||||
iat = payload.get("iat")
|
||||
if pca is not None and iat is not None:
|
||||
try:
|
||||
if int(iat) < int(float(pca)):
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# Attach vault permissions from the token (snapshot at login time)
|
||||
user["_token_vaults"] = payload.get("vaults", [])
|
||||
# Attach the token id for per-token rate limiting (AI tool layer).
|
||||
user["_token_jti"] = payload.get("jti")
|
||||
# BUG-030: expose the real client IP to the audit log.
|
||||
user["_request_ip"] = get_client_ip(request)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,32 @@ ph = PasswordHasher(
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
# Password policy (BUG-028). Applied by the API validators at account creation
|
||||
# and password change so the rules stay consistent across both paths.
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 128
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> str:
|
||||
"""Validate a plaintext password against the project policy.
|
||||
|
||||
Args:
|
||||
password: Candidate password.
|
||||
|
||||
Returns:
|
||||
The password unchanged when valid.
|
||||
|
||||
Raises:
|
||||
ValueError: When the password is too short, too long or blank.
|
||||
"""
|
||||
if password is None or len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise ValueError(f"Minimum {MIN_PASSWORD_LENGTH} caractères")
|
||||
if len(password) > MAX_PASSWORD_LENGTH:
|
||||
raise ValueError(f"Maximum {MAX_PASSWORD_LENGTH} caractères")
|
||||
if not password.strip():
|
||||
raise ValueError("Le mot de passe ne peut pas être vide")
|
||||
return password
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password with Argon2id."""
|
||||
|
||||
+116
-16
@@ -8,9 +8,12 @@ import re
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from backend.ratelimit import is_rate_limited
|
||||
from backend.ratelimit import is_account_rate_limited, is_rate_limited
|
||||
from backend.ratelimit import record_account_failure as rl_record_account_failure
|
||||
from backend.ratelimit import record_account_success as rl_record_account_success
|
||||
from backend.ratelimit import record_failure as rl_record_failure
|
||||
from backend.ratelimit import record_success as rl_record_success
|
||||
from backend.services.net import get_client_ip
|
||||
|
||||
from .jwt_handler import (
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
@@ -29,7 +32,7 @@ from .mfa import (
|
||||
verify_totp,
|
||||
)
|
||||
from .middleware import is_auth_enabled, require_admin, require_auth
|
||||
from .password import hash_password, verify_password
|
||||
from .password import hash_password, validate_password_strength, verify_password
|
||||
from .user_store import (
|
||||
create_user,
|
||||
delete_user,
|
||||
@@ -61,9 +64,7 @@ class ChangePasswordRequest(BaseModel):
|
||||
|
||||
@validator("new_password")
|
||||
def password_strength(cls, v):
|
||||
if len(v) < 8:
|
||||
raise ValueError("Minimum 8 caractères")
|
||||
return v
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
@@ -73,6 +74,10 @@ class CreateUserRequest(BaseModel):
|
||||
role: str = "user"
|
||||
vaults: list[str] = []
|
||||
|
||||
@validator("password")
|
||||
def password_valid(cls, v):
|
||||
return validate_password_strength(v)
|
||||
|
||||
@validator("username")
|
||||
def username_valid(cls, v):
|
||||
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
|
||||
@@ -93,6 +98,12 @@ class UpdateUserRequest(BaseModel):
|
||||
password: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
@validator("password")
|
||||
def password_valid(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
# ── Public endpoints ──────────────────────────────────────────────────
|
||||
|
||||
@@ -128,16 +139,21 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
raise HTTPException(403, "Compte désactivé")
|
||||
|
||||
# IP-based rate limiting (10 failures / 15 min per IP)
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
client_ip = get_client_ip(request)
|
||||
if is_rate_limited(client_ip):
|
||||
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
|
||||
|
||||
# BUG-031: per-account budget still applies when the attacker rotates IPs.
|
||||
if is_account_rate_limited(body.username):
|
||||
raise HTTPException(429, "Trop de tentatives sur ce compte (15min)")
|
||||
|
||||
if is_locked(body.username):
|
||||
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
|
||||
|
||||
if not verify_password(body.password, user["password_hash"]):
|
||||
attempts = record_login_failure(body.username)
|
||||
rl_attempts, rl_remaining = rl_record_failure(client_ip)
|
||||
rl_record_account_failure(body.username)
|
||||
remaining = max(0, 5 - attempts)
|
||||
detail = "Identifiants invalides"
|
||||
if 0 < remaining <= 2:
|
||||
@@ -164,9 +180,10 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
|
||||
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
|
||||
record_login_success(username)
|
||||
rl_record_account_success(username)
|
||||
|
||||
access_token = create_access_token(user)
|
||||
refresh_token, refresh_jti = create_refresh_token(username)
|
||||
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
|
||||
|
||||
import os
|
||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||
@@ -208,6 +225,8 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
"""Renew access token via refresh token cookie.
|
||||
|
||||
Called automatically by the frontend when the access token expires.
|
||||
The refresh token is rotated on every use (BUG-027) and rejected if it
|
||||
predates the user's last password change (BUG-028).
|
||||
"""
|
||||
refresh_tok = request.cookies.get("refresh_token")
|
||||
if not refresh_tok:
|
||||
@@ -224,11 +243,38 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
if not user or not user.get("active"):
|
||||
raise HTTPException(401, "Utilisateur introuvable ou inactif")
|
||||
|
||||
# BUG-028: reject refresh tokens issued before the last password change.
|
||||
pca = user.get("password_changed_at")
|
||||
iat = payload.get("iat")
|
||||
if pca is not None and iat is not None:
|
||||
try:
|
||||
stale = int(iat) < int(float(pca))
|
||||
except (TypeError, ValueError):
|
||||
stale = True
|
||||
if stale:
|
||||
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
|
||||
|
||||
import os
|
||||
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
remember_me = bool(payload.get("remember", False))
|
||||
|
||||
# BUG-027: rotate the refresh token — the old one is now single-use.
|
||||
revoke_token(payload["jti"])
|
||||
new_refresh_token, _new_jti = create_refresh_token(user["username"], remember=remember_me)
|
||||
max_age = 2592000 if remember_me else 604800
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=new_refresh_token,
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
secure=secure,
|
||||
path="/api/auth/refresh",
|
||||
)
|
||||
|
||||
new_access_token = create_access_token(user)
|
||||
|
||||
# Update cookies
|
||||
import os
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=new_access_token,
|
||||
@@ -251,7 +297,7 @@ async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
):
|
||||
"""Logout: revoke refresh token and delete cookies."""
|
||||
"""Logout: revoke refresh and access tokens, then delete cookies."""
|
||||
refresh_tok = request.cookies.get("refresh_token")
|
||||
if refresh_tok:
|
||||
payload = decode_token(refresh_tok)
|
||||
@@ -261,6 +307,21 @@ async def logout(
|
||||
except Exception:
|
||||
pass # token already revoked
|
||||
|
||||
# BUG-027: revoke the access token too, otherwise it stays valid until expiry.
|
||||
access_tok = None
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
access_tok = auth_header[7:].strip()
|
||||
if not access_tok:
|
||||
access_tok = request.cookies.get("access_token")
|
||||
if access_tok:
|
||||
access_payload = decode_token(access_tok)
|
||||
if access_payload and access_payload.get("type") == "access":
|
||||
try:
|
||||
revoke_token(access_payload["jti"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
||||
response.delete_cookie("access_token", path="/")
|
||||
response.delete_cookie("access_token", path="/api") # just in case
|
||||
@@ -313,19 +374,51 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
req: ChangePasswordRequest,
|
||||
response: Response,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Change own password."""
|
||||
"""Change own password.
|
||||
|
||||
BUG-028: changing the password invalidates all previously issued tokens;
|
||||
a fresh pair is issued to keep the current session alive.
|
||||
"""
|
||||
user = get_user(current_user["username"])
|
||||
assert user is not None, f"User {current_user['username']} not found"
|
||||
if not verify_password(req.current_password, user["password_hash"]):
|
||||
raise HTTPException(400, "Mot de passe actuel incorrect")
|
||||
update_user(current_user["username"], {"password": req.new_password})
|
||||
return {"message": "Mot de passe mis à jour"}
|
||||
updated = get_user(current_user["username"])
|
||||
result: dict = {"message": "Mot de passe mis à jour"}
|
||||
if updated is not None:
|
||||
result.update(_issue_tokens(updated, updated["username"], False, response))
|
||||
return result
|
||||
|
||||
|
||||
# ── MFA endpoints ────────────────────────────────────────────────────
|
||||
|
||||
def _enforce_mfa_rate_limit(request: Request, username: str) -> str:
|
||||
"""Reject MFA attempts from a rate-limited IP or on a locked account.
|
||||
|
||||
BUG-023: the second-factor endpoints were previously unprotected, making
|
||||
the 6-digit TOTP brute-forceable. Returns the resolved client IP.
|
||||
"""
|
||||
client_ip = get_client_ip(request)
|
||||
if is_rate_limited(client_ip):
|
||||
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
|
||||
if is_account_rate_limited(username):
|
||||
raise HTTPException(429, "Trop de tentatives sur ce compte (15min)")
|
||||
if is_locked(username):
|
||||
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
|
||||
return client_ip
|
||||
|
||||
|
||||
def _record_mfa_failure(client_ip: str, username: str) -> None:
|
||||
"""Record a failed MFA attempt for the IP, the account and the lockout."""
|
||||
record_login_failure(username)
|
||||
rl_record_failure(client_ip)
|
||||
rl_record_account_failure(username)
|
||||
|
||||
|
||||
class MfaVerifyRequest(BaseModel):
|
||||
username: str
|
||||
code: str
|
||||
@@ -601,6 +694,8 @@ async def mfa_webauthn_verify(
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_authentication
|
||||
|
||||
client_ip = _enforce_mfa_rate_limit(request, body.username)
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
hash_password("dummy_timing_protection")
|
||||
@@ -616,8 +711,10 @@ async def mfa_webauthn_verify(
|
||||
raise ValueError("Credential non enregistré")
|
||||
new_count = complete_authentication(body.username, body.credential, stored)
|
||||
except ValueError as e:
|
||||
_record_mfa_failure(client_ip, body.username)
|
||||
raise HTTPException(401, str(e))
|
||||
except Exception as e:
|
||||
_record_mfa_failure(client_ip, body.username)
|
||||
logger.warning(f"WebAuthn verification failed for {body.username}: {e}")
|
||||
raise HTTPException(401, "Vérification WebAuthn échouée")
|
||||
|
||||
@@ -627,7 +724,6 @@ async def mfa_webauthn_verify(
|
||||
c["sign_count"] = new_count
|
||||
update_user(body.username, {"webauthn_credentials": updated})
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
@@ -655,6 +751,8 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
|
||||
"""
|
||||
from .user_store import get_user
|
||||
|
||||
client_ip = _enforce_mfa_rate_limit(request, body.username)
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
# Timing-safe: simulate work
|
||||
@@ -665,10 +763,10 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
if not verify_totp(user["mfa_secret"], body.code):
|
||||
_record_mfa_failure(client_ip, body.username)
|
||||
raise HTTPException(401, "Code TOTP invalide")
|
||||
|
||||
# Clear IP rate limit on success
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
@@ -682,6 +780,8 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
||||
"""
|
||||
from .user_store import get_user, update_user
|
||||
|
||||
client_ip = _enforce_mfa_rate_limit(request, body.username)
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
hash_password("dummy_timing_protection")
|
||||
@@ -696,6 +796,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
||||
|
||||
idx = verify_recovery_code(body.recovery_code, hashed_codes)
|
||||
if idx is None:
|
||||
_record_mfa_failure(client_ip, body.username)
|
||||
raise HTTPException(401, "Code de récupération invalide")
|
||||
|
||||
# Remove used recovery code (single-use)
|
||||
@@ -703,7 +804,6 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
||||
update_user(body.username, {"mfa_recovery_codes": hashed_codes})
|
||||
|
||||
# Clear IP rate limit
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
|
||||
logger.info(f"User '{body.username}' logged in via recovery code")
|
||||
|
||||
+64
-49
@@ -6,6 +6,7 @@
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -16,6 +17,12 @@ logger = logging.getLogger("obsigate.auth.users")
|
||||
|
||||
USERS_FILE = Path("data/users.json")
|
||||
|
||||
# Serialises read-modify-write cycles on users.json. ``RLock`` because a few
|
||||
# helpers (e.g. ``record_login_failure``) call other mutators while holding it.
|
||||
# BUG-029: without this, concurrent MFA enable + password change could lose one
|
||||
# of the two updates (last writer wins).
|
||||
_users_lock = threading.RLock()
|
||||
|
||||
|
||||
def _read() -> dict:
|
||||
"""Read users.json. Returns empty structure if file doesn't exist."""
|
||||
@@ -75,26 +82,28 @@ def create_user(
|
||||
display_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new user. Raises ValueError if username already taken."""
|
||||
data = _read()
|
||||
if username in data["users"]:
|
||||
raise ValueError(f"User '{username}' already exists")
|
||||
with _users_lock:
|
||||
data = _read()
|
||||
if username in data["users"]:
|
||||
raise ValueError(f"User '{username}' already exists")
|
||||
|
||||
user = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"username": username,
|
||||
"display_name": display_name or username,
|
||||
"password_hash": hash_password(password),
|
||||
"role": role,
|
||||
"vaults": vaults or [],
|
||||
"active": True,
|
||||
"language": "fr", # default UI language
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_login": None,
|
||||
"failed_attempts": 0,
|
||||
"locked_until": None,
|
||||
}
|
||||
data["users"][username] = user
|
||||
_write(data)
|
||||
user = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"username": username,
|
||||
"display_name": display_name or username,
|
||||
"password_hash": hash_password(password),
|
||||
"role": role,
|
||||
"vaults": vaults or [],
|
||||
"active": True,
|
||||
"language": "fr", # default UI language
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"password_changed_at": datetime.now(timezone.utc).timestamp(),
|
||||
"last_login": None,
|
||||
"failed_attempts": 0,
|
||||
"locked_until": None,
|
||||
}
|
||||
data["users"][username] = user
|
||||
_write(data)
|
||||
logger.info(f"Created user '{username}' (role={role})")
|
||||
return {k: v for k, v in user.items() if k != "password_hash"}
|
||||
|
||||
@@ -105,28 +114,33 @@ def update_user(username: str, updates: dict) -> dict:
|
||||
Forbidden fields (id, username, created_at) are silently ignored.
|
||||
If 'password' is in updates, it's hashed and stored as password_hash.
|
||||
"""
|
||||
data = _read()
|
||||
if username not in data["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
with _users_lock:
|
||||
data = _read()
|
||||
if username not in data["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
|
||||
forbidden = {"id", "username", "created_at"}
|
||||
safe_updates = {k: v for k, v in updates.items() if k not in forbidden}
|
||||
forbidden = {"id", "username", "created_at"}
|
||||
safe_updates = {k: v for k, v in updates.items() if k not in forbidden}
|
||||
|
||||
if "password" in safe_updates:
|
||||
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
||||
if "password" in safe_updates:
|
||||
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
||||
# BUG-028: invalidate every token issued before this change.
|
||||
safe_updates["password_changed_at"] = datetime.now(timezone.utc).timestamp()
|
||||
|
||||
data["users"][username].update(safe_updates)
|
||||
_write(data)
|
||||
return {k: v for k, v in data["users"][username].items() if k != "password_hash"}
|
||||
data["users"][username].update(safe_updates)
|
||||
_write(data)
|
||||
result = {k: v for k, v in data["users"][username].items() if k != "password_hash"}
|
||||
return result
|
||||
|
||||
|
||||
def delete_user(username: str):
|
||||
"""Delete a user. Raises ValueError if not found."""
|
||||
data = _read()
|
||||
if username not in data["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
del data["users"][username]
|
||||
_write(data)
|
||||
with _users_lock:
|
||||
data = _read()
|
||||
if username not in data["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
del data["users"][username]
|
||||
_write(data)
|
||||
logger.info(f"Deleted user '{username}'")
|
||||
|
||||
|
||||
@@ -144,24 +158,25 @@ def record_login_failure(username: str) -> int:
|
||||
|
||||
After 5 failures, locks the account for 15 minutes.
|
||||
"""
|
||||
data = _read()
|
||||
user = data["users"].get(username)
|
||||
if not user:
|
||||
return 0
|
||||
with _users_lock:
|
||||
data = _read()
|
||||
user = data["users"].get(username)
|
||||
if not user:
|
||||
return 0
|
||||
|
||||
attempts = user.get("failed_attempts", 0) + 1
|
||||
updates = {"failed_attempts": attempts}
|
||||
attempts = user.get("failed_attempts", 0) + 1
|
||||
updates = {"failed_attempts": attempts}
|
||||
|
||||
# Lock after 5 failed attempts (15 minutes)
|
||||
if attempts >= 5:
|
||||
locked_until = (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
).isoformat()
|
||||
updates["locked_until"] = locked_until
|
||||
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
||||
# Lock after 5 failed attempts (15 minutes)
|
||||
if attempts >= 5:
|
||||
locked_until = (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
).isoformat()
|
||||
updates["locked_until"] = locked_until
|
||||
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
||||
|
||||
update_user(username, updates)
|
||||
return attempts
|
||||
update_user(username, updates)
|
||||
return attempts
|
||||
|
||||
|
||||
def is_locked(username: str) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user