security.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. """JWT(JSON Web Token)的签发与校验。
  2. access token 有效期较短,用于访问接口;refresh token 有效期较长,只用于换新令牌。
  3. """
  4. import hashlib
  5. from dataclasses import dataclass
  6. from datetime import UTC, datetime, timedelta
  7. from typing import Any
  8. import jwt
  9. from app.core.config import Settings
  10. from app.core.errors import AppError
  11. from app.core.identifiers import new_ulid
  12. @dataclass(frozen=True, slots=True)
  13. class TokenPair:
  14. """一次登录签发的一对令牌及其有效期。"""
  15. access_token: str
  16. refresh_token: str
  17. access_expires_in: int
  18. refresh_expires_in: int
  19. refresh_jti: str
  20. class JwtService:
  21. """封装 PyJWT,统一项目中的令牌字段、密钥和校验规则。"""
  22. def __init__(self, settings: Settings) -> None:
  23. self._settings = settings
  24. def issue_pair(
  25. self,
  26. *,
  27. subject_id: str,
  28. subject_type: str,
  29. audience: str,
  30. session_id: str,
  31. now: datetime | None = None,
  32. ) -> TokenPair:
  33. """为一个用户会话签发 access token 和 refresh token。"""
  34. issued_at = now or datetime.now(UTC)
  35. access_seconds = self._settings.access_token_minutes * 60
  36. refresh_seconds = self._settings.refresh_token_days * 24 * 60 * 60
  37. refresh_jti = new_ulid()
  38. # 两种令牌共有的标准/自定义声明;sub 是用户,sid 是服务端会话。
  39. common = {
  40. "sub": subject_id,
  41. "subject_type": subject_type,
  42. "aud": audience,
  43. "iss": "zhibaotong-s1",
  44. "sid": session_id,
  45. "iat": issued_at,
  46. }
  47. access = jwt.encode(
  48. {
  49. # ** 会把 common 字典的键值展开并合并到新字典。
  50. **common,
  51. "typ": "access",
  52. "exp": issued_at + timedelta(seconds=access_seconds),
  53. },
  54. self._settings.jwt_access_secret,
  55. algorithm="HS256",
  56. )
  57. refresh = jwt.encode(
  58. {
  59. **common,
  60. "typ": "refresh",
  61. "jti": refresh_jti,
  62. "exp": issued_at + timedelta(seconds=refresh_seconds),
  63. },
  64. self._settings.jwt_refresh_secret,
  65. algorithm="HS256",
  66. )
  67. return TokenPair(
  68. access_token=access,
  69. refresh_token=refresh,
  70. access_expires_in=access_seconds,
  71. refresh_expires_in=refresh_seconds,
  72. refresh_jti=refresh_jti,
  73. )
  74. def decode_access(self, token: str, *, audience: str) -> dict[str, Any]:
  75. """校验并解码 access token。"""
  76. return self._decode(
  77. token,
  78. secret=self._settings.jwt_access_secret,
  79. audience=audience,
  80. token_type="access",
  81. )
  82. def decode_refresh(self, token: str, *, audience: str) -> dict[str, Any]:
  83. """校验并解码 refresh token。"""
  84. return self._decode(
  85. token,
  86. secret=self._settings.jwt_refresh_secret,
  87. audience=audience,
  88. token_type="refresh",
  89. )
  90. @staticmethod
  91. def hash_token_identifier(value: str) -> str:
  92. return hashlib.sha256(value.encode("utf-8")).hexdigest()
  93. @staticmethod
  94. def _decode(
  95. token: str,
  96. *,
  97. secret: str,
  98. audience: str,
  99. token_type: str,
  100. ) -> dict[str, Any]:
  101. """执行公共解码逻辑,并把第三方库异常转换成本项目的业务异常。"""
  102. try:
  103. payload = jwt.decode(
  104. token,
  105. secret,
  106. algorithms=["HS256"],
  107. audience=audience,
  108. issuer="zhibaotong-s1",
  109. )
  110. except jwt.PyJWTError as exc:
  111. raise AppError("AUTH_REQUIRED", "登录凭证无效或已过期", 401) from exc
  112. if payload.get("typ") != token_type:
  113. raise AppError("AUTH_REQUIRED", "登录凭证类型无效", 401)
  114. return payload