auth.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. """PostgreSQL-backed authentication using Argon2id and short-lived bearer tokens."""
  2. from __future__ import annotations
  3. import logging
  4. import threading
  5. import time
  6. from collections import defaultdict, deque
  7. from typing import Any
  8. from argon2 import PasswordHasher
  9. from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
  10. from flask import current_app
  11. from sqlalchemy import text
  12. from app import db
  13. from app.core.system.tokens import TokenError, decode_access_token, issue_access_token
  14. logger = logging.getLogger(__name__)
  15. _password_hasher = PasswordHasher()
  16. _failure_lock = threading.Lock()
  17. _login_failures: dict[str, deque[float]] = defaultdict(deque)
  18. _FAILURE_WINDOW_SECONDS = 300
  19. _FAILURE_LIMIT = 5
  20. def hash_password(password: str) -> str:
  21. return _password_hasher.hash(password)
  22. def verify_password(encoded: str, password: str) -> bool:
  23. try:
  24. return bool(_password_hasher.verify(encoded, password))
  25. except (VerifyMismatchError, VerificationError, InvalidHashError):
  26. return False
  27. def _rate_key(username: str, ip_address: str | None) -> str:
  28. return f"{username.strip().lower()}|{ip_address or '-'}"
  29. def _check_rate_limit(key: str, *, now: float | None = None) -> None:
  30. now = now or time.time()
  31. with _failure_lock:
  32. failures = _login_failures[key]
  33. while failures and failures[0] <= now - _FAILURE_WINDOW_SECONDS:
  34. failures.popleft()
  35. if len(failures) >= _FAILURE_LIMIT:
  36. raise PermissionError("too many login attempts")
  37. def _record_failure(key: str, *, now: float | None = None) -> None:
  38. with _failure_lock:
  39. _login_failures[key].append(now or time.time())
  40. def _clear_failures(key: str) -> None:
  41. with _failure_lock:
  42. _login_failures.pop(key, None)
  43. def _audit(
  44. *,
  45. user_id: str | None,
  46. username: str,
  47. event_type: str,
  48. success: bool,
  49. ip_address: str | None,
  50. user_agent: str | None,
  51. detail: str | None = None,
  52. ) -> None:
  53. db.session.execute(
  54. text(
  55. "INSERT INTO public.auth_audit_events "
  56. "(user_id, username, event_type, success, ip_address, user_agent, detail) "
  57. "VALUES (CAST(:user_id AS uuid), :username, :event_type, :success, "
  58. ":ip_address, :user_agent, :detail)"
  59. ),
  60. {
  61. "user_id": user_id,
  62. "username": username[:64],
  63. "event_type": event_type,
  64. "success": success,
  65. "ip_address": (ip_address or "")[:64] or None,
  66. "user_agent": (user_agent or "")[:300] or None,
  67. "detail": (detail or "")[:500] or None,
  68. },
  69. )
  70. def authenticate_user(
  71. username: str,
  72. password: str,
  73. *,
  74. ip_address: str | None = None,
  75. user_agent: str | None = None,
  76. ) -> dict[str, Any] | None:
  77. key = _rate_key(username, ip_address)
  78. _check_rate_limit(key)
  79. row = db.session.execute(
  80. text(
  81. "SELECT u.id::text, u.username, u.display_name, u.password_hash, "
  82. "u.status, COALESCE(array_agg(r.name ORDER BY r.name) "
  83. "FILTER (WHERE r.name IS NOT NULL), ARRAY[]::varchar[]) AS roles "
  84. "FROM public.users u "
  85. "LEFT JOIN public.user_roles ur ON ur.user_id = u.id "
  86. "LEFT JOIN public.roles r ON r.id = ur.role_id "
  87. "WHERE lower(u.username) = lower(:username) "
  88. "GROUP BY u.id"
  89. ),
  90. {"username": username.strip()},
  91. ).one_or_none()
  92. valid = bool(row and row[4] == "active" and verify_password(row[3], password))
  93. if not valid:
  94. _record_failure(key)
  95. _audit(
  96. user_id=row[0] if row else None,
  97. username=username,
  98. event_type="login",
  99. success=False,
  100. ip_address=ip_address,
  101. user_agent=user_agent,
  102. detail="invalid credentials or disabled account",
  103. )
  104. db.session.commit()
  105. return None
  106. db.session.execute(
  107. text("UPDATE public.users SET last_login_at = CURRENT_TIMESTAMP WHERE id = CAST(:id AS uuid)"),
  108. {"id": row[0]},
  109. )
  110. _audit(
  111. user_id=row[0],
  112. username=row[1],
  113. event_type="login",
  114. success=True,
  115. ip_address=ip_address,
  116. user_agent=user_agent,
  117. )
  118. db.session.commit()
  119. _clear_failures(key)
  120. roles = list(row[5])
  121. return {
  122. "id": row[0],
  123. "username": row[1],
  124. "display_name": row[2],
  125. "roles": roles,
  126. "token": issue_access_token(
  127. user_id=row[0], roles=roles, secret=current_app.config["SECRET_KEY"]
  128. ),
  129. }
  130. def load_identity(user_id: str) -> dict[str, Any] | None:
  131. row = db.session.execute(
  132. text(
  133. "SELECT u.id::text, u.username, u.display_name, u.status, "
  134. "COALESCE(array_agg(r.name ORDER BY r.name) FILTER "
  135. "(WHERE r.name IS NOT NULL), ARRAY[]::varchar[]) "
  136. "FROM public.users u "
  137. "LEFT JOIN public.user_roles ur ON ur.user_id = u.id "
  138. "LEFT JOIN public.roles r ON r.id = ur.role_id "
  139. "WHERE u.id = CAST(:id AS uuid) GROUP BY u.id"
  140. ),
  141. {"id": user_id},
  142. ).one_or_none()
  143. if not row or row[3] != "active":
  144. return None
  145. return {"id": row[0], "username": row[1], "display_name": row[2], "roles": list(row[4])}
  146. def load_identity_from_token(token: str, *, secret: str) -> dict[str, Any] | None:
  147. try:
  148. claims = decode_access_token(token, secret=secret)
  149. except TokenError:
  150. return None
  151. return load_identity(claims["sub"])
  152. # Compatibility aliases retained only for imports during the route migration.
  153. def login_user(username: str, password: str):
  154. result = authenticate_user(username, password)
  155. return (True, result) if result else (False, "用户名或密码错误")
  156. def get_user_by_username(username: str):
  157. row = db.session.execute(
  158. text("SELECT id::text FROM public.users WHERE lower(username) = lower(:username)"),
  159. {"username": username},
  160. ).scalar_one_or_none()
  161. return load_identity(row) if row else None
  162. def init_db() -> bool:
  163. logger.warning("init_db is deprecated; use Alembic migrations")
  164. return True
  165. def require_auth(view):
  166. from app.core.system.permissions import require_permissions, READ_GOVERNANCE
  167. return require_permissions(READ_GOVERNANCE)(view)