| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- """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)
|