| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129 |
- """JWT(JSON Web Token)的签发与校验。
- access token 有效期较短,用于访问接口;refresh token 有效期较长,只用于换新令牌。
- """
- import hashlib
- from dataclasses import dataclass
- from datetime import UTC, datetime, timedelta
- from typing import Any
- import jwt
- from app.core.config import Settings
- from app.core.errors import AppError
- from app.core.identifiers import new_ulid
- @dataclass(frozen=True, slots=True)
- class TokenPair:
- """一次登录签发的一对令牌及其有效期。"""
- access_token: str
- refresh_token: str
- access_expires_in: int
- refresh_expires_in: int
- refresh_jti: str
- class JwtService:
- """封装 PyJWT,统一项目中的令牌字段、密钥和校验规则。"""
- def __init__(self, settings: Settings) -> None:
- self._settings = settings
- def issue_pair(
- self,
- *,
- subject_id: str,
- subject_type: str,
- audience: str,
- session_id: str,
- now: datetime | None = None,
- ) -> TokenPair:
- """为一个用户会话签发 access token 和 refresh token。"""
- issued_at = now or datetime.now(UTC)
- access_seconds = self._settings.access_token_minutes * 60
- refresh_seconds = self._settings.refresh_token_days * 24 * 60 * 60
- refresh_jti = new_ulid()
- # 两种令牌共有的标准/自定义声明;sub 是用户,sid 是服务端会话。
- common = {
- "sub": subject_id,
- "subject_type": subject_type,
- "aud": audience,
- "iss": "zhibaotong-s1",
- "sid": session_id,
- "iat": issued_at,
- }
- access = jwt.encode(
- {
- # ** 会把 common 字典的键值展开并合并到新字典。
- **common,
- "typ": "access",
- "exp": issued_at + timedelta(seconds=access_seconds),
- },
- self._settings.jwt_access_secret,
- algorithm="HS256",
- )
- refresh = jwt.encode(
- {
- **common,
- "typ": "refresh",
- "jti": refresh_jti,
- "exp": issued_at + timedelta(seconds=refresh_seconds),
- },
- self._settings.jwt_refresh_secret,
- algorithm="HS256",
- )
- return TokenPair(
- access_token=access,
- refresh_token=refresh,
- access_expires_in=access_seconds,
- refresh_expires_in=refresh_seconds,
- refresh_jti=refresh_jti,
- )
- def decode_access(self, token: str, *, audience: str) -> dict[str, Any]:
- """校验并解码 access token。"""
- return self._decode(
- token,
- secret=self._settings.jwt_access_secret,
- audience=audience,
- token_type="access",
- )
- def decode_refresh(self, token: str, *, audience: str) -> dict[str, Any]:
- """校验并解码 refresh token。"""
- return self._decode(
- token,
- secret=self._settings.jwt_refresh_secret,
- audience=audience,
- token_type="refresh",
- )
- @staticmethod
- def hash_token_identifier(value: str) -> str:
- return hashlib.sha256(value.encode("utf-8")).hexdigest()
- @staticmethod
- def _decode(
- token: str,
- *,
- secret: str,
- audience: str,
- token_type: str,
- ) -> dict[str, Any]:
- """执行公共解码逻辑,并把第三方库异常转换成本项目的业务异常。"""
- try:
- payload = jwt.decode(
- token,
- secret,
- algorithms=["HS256"],
- audience=audience,
- issuer="zhibaotong-s1",
- )
- except jwt.PyJWTError as exc:
- raise AppError("AUTH_REQUIRED", "登录凭证无效或已过期", 401) from exc
- if payload.get("typ") != token_type:
- raise AppError("AUTH_REQUIRED", "登录凭证类型无效", 401)
- return payload
|