"""Web 会话认证:签发、校验与撤销(契约 api-conventions 第 2 节)。 - Cookie 名 ``__Host-hunjing_session``:HttpOnly、Secure、SameSite=Lax、Path=/。 - Cookie 值为 CSPRNG opaque token;数据库只存 SHA-256 哈希(ADR-015)。 - 状态变更请求要求 Origin 校验与 ``Hunjing-CSRF-Token`` 双提交。 """ import hashlib import secrets from datetime import UTC, datetime, timedelta from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models import Session SESSION_COOKIE = "__Host-hunjing_session" SESSION_TTL = timedelta(days=14) def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def generate_session_token() -> str: return secrets.token_urlsafe(32) def generate_csrf_token() -> str: return secrets.token_urlsafe(24) async def create_session(db: AsyncSession, user_id: str) -> tuple[Session, str, str]: """签发会话:返回(会话记录,opaque token,CSRF token)。""" token = generate_session_token() csrf = generate_csrf_token() session = Session( id=f"web_{secrets.token_urlsafe(12)}", user_id=user_id, token_hash=_hash_token(token), csrf_token_hash=_hash_token(csrf), expires_at=datetime.now(UTC) + SESSION_TTL, ) db.add(session) return session, token, csrf async def resolve_session(db: AsyncSession, token: str) -> Session | None: """按 opaque token 查找有效(未过期、未撤销)会话。""" result = await db.execute( select(Session).where( Session.token_hash == _hash_token(token), Session.revoked_at.is_(None), Session.expires_at > datetime.now(UTC), ) ) return result.scalar_one_or_none() async def revoke_session(db: AsyncSession, token: str) -> bool: session = await resolve_session(db, token) if session is None: return False session.revoked_at = datetime.now(UTC) return True def session_is_csrf_valid(session: Session, csrf_header: str | None) -> bool: if csrf_header is None: return False return secrets.compare_digest(session.csrf_token_hash, _hash_token(csrf_header))