"""密码校验、数据库会话与 FastAPI 鉴权依赖。""" from __future__ import annotations import hashlib import secrets from datetime import datetime, timedelta, timezone from argon2 import PasswordHasher from argon2.exceptions import InvalidHashError, VerificationError from fastapi import Cookie, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from .config import settings from .db import get_session from .models import AuthSession, User password_hasher = PasswordHasher( time_cost=2, memory_cost=19_456, parallelism=1, hash_len=32, salt_len=16, ) # 不存在的用户名也执行一次 Argon2,降低用户名枚举与计时攻击差异。 _DUMMY_HASH = password_hasher.hash("not-a-real-password") def utcnow() -> datetime: return datetime.now(timezone.utc) def hash_password(password: str) -> str: return password_hasher.hash(password) def verify_password(password_hash: str, password: str) -> bool: try: return password_hasher.verify(password_hash, password) except (VerificationError, InvalidHashError): return False def verify_dummy_password(password: str) -> None: verify_password(_DUMMY_HASH, password) def new_session_token() -> str: return secrets.token_urlsafe(48) def token_digest(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def session_expiry() -> datetime: return utcnow() + timedelta(hours=settings.auth_session_hours) def unauthorized(detail: str = "登录状态无效或已过期") -> HTTPException: return HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=detail, headers={"WWW-Authenticate": "Session"}, ) async def get_auth_session( token: str, db: AsyncSession, ) -> AuthSession | None: now = utcnow() stmt = ( select(AuthSession) .options(selectinload(AuthSession.user)) .where( AuthSession.token_hash == token_digest(token), AuthSession.revoked_at.is_(None), AuthSession.expires_at > now, ) ) auth_session = (await db.execute(stmt)).scalar_one_or_none() if auth_session is None or not auth_session.user.is_active: return None # 避免每个 API 请求都写数据库;最多每 5 分钟刷新一次活动时间。 if auth_session.last_seen_at < now - timedelta(minutes=5): auth_session.last_seen_at = now await db.commit() return auth_session async def require_user( stock_session: str | None = Cookie(default=None, alias=settings.auth_cookie_name), db: AsyncSession = Depends(get_session), ) -> User: if not stock_session: raise unauthorized() auth_session = await get_auth_session(stock_session, db) if auth_session is None: raise unauthorized() return auth_session.user