auth.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. from __future__ import annotations
  2. import hashlib
  3. import hmac
  4. import logging
  5. import secrets
  6. from datetime import datetime, timedelta
  7. from typing import Any
  8. from sqlalchemy.exc import IntegrityError
  9. from supply_infra.config import get_infra_settings
  10. from supply_infra.db import get_session
  11. from supply_infra.db.models.auth_user import AuthUser
  12. from supply_infra.db.repositories.auth_repo import AuthRepository
  13. from supply_infra.pipeline.dates import CHINA_TIMEZONE
  14. logger = logging.getLogger(__name__)
  15. SESSION_COOKIE_NAME = "supply_session"
  16. ADMIN_ROLE = "admin"
  17. USER_ROLE = "user"
  18. ACTIVE_STATUS = "active"
  19. DISABLED_STATUS = "disabled"
  20. VALID_ROLES = {ADMIN_ROLE, USER_ROLE}
  21. VALID_STATUSES = {ACTIVE_STATUS, DISABLED_STATUS}
  22. _SCRYPT_N = 2**14
  23. _SCRYPT_R = 8
  24. _SCRYPT_P = 1
  25. _DUMMY_PASSWORD_HASH: str | None = None
  26. class InvalidCredentialsError(Exception):
  27. """Raised for every login rejection to avoid exposing account state."""
  28. class DuplicateUsernameError(Exception):
  29. """Raised when a local username already exists."""
  30. def _now() -> datetime:
  31. return datetime.now(CHINA_TIMEZONE).replace(tzinfo=None)
  32. def normalize_username(username: str) -> str:
  33. return username.strip().lower()
  34. def hash_password(password: str) -> str:
  35. salt = secrets.token_bytes(16)
  36. derived = hashlib.scrypt(
  37. password.encode("utf-8"),
  38. salt=salt,
  39. n=_SCRYPT_N,
  40. r=_SCRYPT_R,
  41. p=_SCRYPT_P,
  42. dklen=32,
  43. )
  44. return (
  45. f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$"
  46. f"{salt.hex()}${derived.hex()}"
  47. )
  48. def verify_password(password: str, encoded: str) -> bool:
  49. try:
  50. algorithm, n, r, p, salt_hex, expected_hex = encoded.split("$", 5)
  51. if algorithm != "scrypt":
  52. return False
  53. derived = hashlib.scrypt(
  54. password.encode("utf-8"),
  55. salt=bytes.fromhex(salt_hex),
  56. n=int(n),
  57. r=int(r),
  58. p=int(p),
  59. dklen=len(bytes.fromhex(expected_hex)),
  60. )
  61. return hmac.compare_digest(derived.hex(), expected_hex)
  62. except (TypeError, ValueError):
  63. return False
  64. def _dummy_password_hash() -> str:
  65. global _DUMMY_PASSWORD_HASH
  66. if _DUMMY_PASSWORD_HASH is None:
  67. _DUMMY_PASSWORD_HASH = hash_password("supply-agent-invalid-password")
  68. return _DUMMY_PASSWORD_HASH
  69. def hash_session_token(token: str) -> str:
  70. return hashlib.sha256(token.encode("utf-8")).hexdigest()
  71. def serialize_user(user: AuthUser) -> dict[str, Any]:
  72. return {
  73. "id": user.id,
  74. "username": user.username,
  75. "display_name": user.display_name,
  76. "role": user.role,
  77. "status": user.status,
  78. "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
  79. "created_at": user.created_at.isoformat() if user.created_at else None,
  80. "updated_at": user.updated_at.isoformat() if user.updated_at else None,
  81. }
  82. def authenticate(
  83. *,
  84. username: str,
  85. password: str,
  86. ip_address: str | None,
  87. user_agent: str | None,
  88. ) -> tuple[dict[str, Any], str]:
  89. now = _now()
  90. normalized = normalize_username(username)
  91. settings = get_infra_settings()
  92. with get_session() as db:
  93. repo = AuthRepository(db)
  94. repo.delete_expired_sessions(now)
  95. user = repo.get_user_by_username(normalized)
  96. if user is None:
  97. verify_password(password, _dummy_password_hash())
  98. raise InvalidCredentialsError
  99. if user.locked_until and user.locked_until > now:
  100. verify_password(password, user.password_hash)
  101. raise InvalidCredentialsError
  102. if user.locked_until and user.locked_until <= now:
  103. user.locked_until = None
  104. user.failed_login_count = 0
  105. password_valid = verify_password(password, user.password_hash)
  106. if not password_valid or user.status != ACTIVE_STATUS:
  107. if password_valid:
  108. raise InvalidCredentialsError
  109. user.failed_login_count += 1
  110. if user.failed_login_count >= 5:
  111. user.failed_login_count = 0
  112. user.locked_until = now + timedelta(minutes=15)
  113. raise InvalidCredentialsError
  114. user.failed_login_count = 0
  115. user.locked_until = None
  116. user.last_login_at = now
  117. token = secrets.token_urlsafe(32)
  118. repo.create_session(
  119. token_hash=hash_session_token(token),
  120. user_id=user.id,
  121. expires_at=now + timedelta(hours=settings.auth_session_hours),
  122. ip_address=(ip_address or "")[:64] or None,
  123. user_agent=(user_agent or "")[:512] or None,
  124. )
  125. db.flush()
  126. return serialize_user(user), token
  127. def resolve_session(token: str) -> dict[str, Any] | None:
  128. now = _now()
  129. with get_session() as db:
  130. repo = AuthRepository(db)
  131. user = repo.get_active_session(
  132. token_hash=hash_session_token(token),
  133. now=now,
  134. )
  135. if user is None:
  136. return None
  137. return serialize_user(user)
  138. def revoke_session(token: str) -> None:
  139. with get_session() as db:
  140. AuthRepository(db).delete_session_by_hash(hash_session_token(token))
  141. def list_users() -> list[dict[str, Any]]:
  142. with get_session() as db:
  143. return [serialize_user(user) for user in AuthRepository(db).list_users()]
  144. def create_user(
  145. *,
  146. username: str,
  147. password: str,
  148. display_name: str,
  149. role: str,
  150. ) -> dict[str, Any]:
  151. if role not in VALID_ROLES:
  152. raise ValueError("invalid role")
  153. try:
  154. with get_session() as db:
  155. user = AuthRepository(db).create_user(
  156. username=normalize_username(username),
  157. password_hash=hash_password(password),
  158. display_name=display_name.strip(),
  159. role=role,
  160. )
  161. db.flush()
  162. return serialize_user(user)
  163. except IntegrityError as exc:
  164. raise DuplicateUsernameError from exc
  165. def update_user(
  166. user_id: int,
  167. *,
  168. display_name: str | None = None,
  169. role: str | None = None,
  170. status: str | None = None,
  171. password: str | None = None,
  172. ) -> dict[str, Any] | None:
  173. if role is not None and role not in VALID_ROLES:
  174. raise ValueError("invalid role")
  175. if status is not None and status not in VALID_STATUSES:
  176. raise ValueError("invalid status")
  177. with get_session() as db:
  178. repo = AuthRepository(db)
  179. user = repo.get_user(user_id)
  180. if user is None:
  181. return None
  182. revoke_existing_sessions = False
  183. if display_name is not None:
  184. user.display_name = display_name.strip()
  185. if role is not None and role != user.role:
  186. user.role = role
  187. revoke_existing_sessions = True
  188. if status is not None and status != user.status:
  189. user.status = status
  190. revoke_existing_sessions = True
  191. if password is not None:
  192. user.password_hash = hash_password(password)
  193. user.failed_login_count = 0
  194. user.locked_until = None
  195. revoke_existing_sessions = True
  196. if revoke_existing_sessions:
  197. repo.delete_user_sessions(user.id)
  198. db.flush()
  199. return serialize_user(user)
  200. def delete_user(user_id: int) -> bool:
  201. with get_session() as db:
  202. repo = AuthRepository(db)
  203. user = repo.get_user(user_id)
  204. if user is None:
  205. return False
  206. repo.delete_user_sessions(user.id)
  207. repo.delete_user(user)
  208. return True
  209. def ensure_bootstrap_admin() -> None:
  210. settings = get_infra_settings()
  211. username = normalize_username(settings.auth_bootstrap_admin_username)
  212. password = settings.auth_bootstrap_admin_password.get_secret_value()
  213. if not username and not password:
  214. return
  215. if not username or not password:
  216. raise RuntimeError(
  217. "AUTH_BOOTSTRAP_ADMIN_USERNAME and AUTH_BOOTSTRAP_ADMIN_PASSWORD "
  218. "must be configured together"
  219. )
  220. if len(password) < 8:
  221. raise RuntimeError("AUTH_BOOTSTRAP_ADMIN_PASSWORD must contain at least 8 characters")
  222. with get_session() as db:
  223. repo = AuthRepository(db)
  224. if repo.get_user_by_username(username) is not None:
  225. return
  226. repo.create_user(
  227. username=username,
  228. password_hash=hash_password(password),
  229. display_name=settings.auth_bootstrap_admin_display_name.strip() or "系统管理员",
  230. role=ADMIN_ROLE,
  231. )
  232. logger.info("Created bootstrap administrator: username=%s", username)