"""PostgreSQL-backed authentication using Argon2id and short-lived bearer tokens.""" from __future__ import annotations import logging import threading import time from collections import defaultdict, deque from typing import Any from argon2 import PasswordHasher from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError from flask import current_app from sqlalchemy import text from app import db from app.core.system.tokens import TokenError, decode_access_token, issue_access_token logger = logging.getLogger(__name__) _password_hasher = PasswordHasher() _failure_lock = threading.Lock() _login_failures: dict[str, deque[float]] = defaultdict(deque) _FAILURE_WINDOW_SECONDS = 300 _FAILURE_LIMIT = 5 def hash_password(password: str) -> str: return _password_hasher.hash(password) def verify_password(encoded: str, password: str) -> bool: try: return bool(_password_hasher.verify(encoded, password)) except (VerifyMismatchError, VerificationError, InvalidHashError): return False def _rate_key(username: str, ip_address: str | None) -> str: return f"{username.strip().lower()}|{ip_address or '-'}" def _check_rate_limit(key: str, *, now: float | None = None) -> None: now = now or time.time() with _failure_lock: failures = _login_failures[key] while failures and failures[0] <= now - _FAILURE_WINDOW_SECONDS: failures.popleft() if len(failures) >= _FAILURE_LIMIT: raise PermissionError("too many login attempts") def _record_failure(key: str, *, now: float | None = None) -> None: with _failure_lock: _login_failures[key].append(now or time.time()) def _clear_failures(key: str) -> None: with _failure_lock: _login_failures.pop(key, None) def _audit( *, user_id: str | None, username: str, event_type: str, success: bool, ip_address: str | None, user_agent: str | None, detail: str | None = None, ) -> None: db.session.execute( text( "INSERT INTO public.auth_audit_events " "(user_id, username, event_type, success, ip_address, user_agent, detail) " "VALUES (CAST(:user_id AS uuid), :username, :event_type, :success, " ":ip_address, :user_agent, :detail)" ), { "user_id": user_id, "username": username[:64], "event_type": event_type, "success": success, "ip_address": (ip_address or "")[:64] or None, "user_agent": (user_agent or "")[:300] or None, "detail": (detail or "")[:500] or None, }, ) def authenticate_user( username: str, password: str, *, ip_address: str | None = None, user_agent: str | None = None, ) -> dict[str, Any] | None: key = _rate_key(username, ip_address) _check_rate_limit(key) row = db.session.execute( text( "SELECT u.id::text, u.username, u.display_name, u.password_hash, " "u.status, COALESCE(array_agg(r.name ORDER BY r.name) " "FILTER (WHERE r.name IS NOT NULL), ARRAY[]::varchar[]) AS roles " "FROM public.users u " "LEFT JOIN public.user_roles ur ON ur.user_id = u.id " "LEFT JOIN public.roles r ON r.id = ur.role_id " "WHERE lower(u.username) = lower(:username) " "GROUP BY u.id" ), {"username": username.strip()}, ).one_or_none() valid = bool(row and row[4] == "active" and verify_password(row[3], password)) if not valid: _record_failure(key) _audit( user_id=row[0] if row else None, username=username, event_type="login", success=False, ip_address=ip_address, user_agent=user_agent, detail="invalid credentials or disabled account", ) db.session.commit() return None db.session.execute( text("UPDATE public.users SET last_login_at = CURRENT_TIMESTAMP WHERE id = CAST(:id AS uuid)"), {"id": row[0]}, ) _audit( user_id=row[0], username=row[1], event_type="login", success=True, ip_address=ip_address, user_agent=user_agent, ) db.session.commit() _clear_failures(key) roles = list(row[5]) return { "id": row[0], "username": row[1], "display_name": row[2], "roles": roles, "token": issue_access_token( user_id=row[0], roles=roles, secret=current_app.config["SECRET_KEY"] ), } def load_identity(user_id: str) -> dict[str, Any] | None: row = db.session.execute( text( "SELECT u.id::text, u.username, u.display_name, u.status, " "COALESCE(array_agg(r.name ORDER BY r.name) FILTER " "(WHERE r.name IS NOT NULL), ARRAY[]::varchar[]) " "FROM public.users u " "LEFT JOIN public.user_roles ur ON ur.user_id = u.id " "LEFT JOIN public.roles r ON r.id = ur.role_id " "WHERE u.id = CAST(:id AS uuid) GROUP BY u.id" ), {"id": user_id}, ).one_or_none() if not row or row[3] != "active": return None return {"id": row[0], "username": row[1], "display_name": row[2], "roles": list(row[4])} def load_identity_from_token(token: str, *, secret: str) -> dict[str, Any] | None: try: claims = decode_access_token(token, secret=secret) except TokenError: return None return load_identity(claims["sub"]) # Compatibility aliases retained only for imports during the route migration. def login_user(username: str, password: str): result = authenticate_user(username, password) return (True, result) if result else (False, "用户名或密码错误") def get_user_by_username(username: str): row = db.session.execute( text("SELECT id::text FROM public.users WHERE lower(username) = lower(:username)"), {"username": username}, ).scalar_one_or_none() return load_identity(row) if row else None def init_db() -> bool: logger.warning("init_db is deprecated; use Alembic migrations") return True def require_auth(view): from app.core.system.permissions import require_permissions, READ_GOVERNANCE return require_permissions(READ_GOVERNANCE)(view)