Files
gaokao-volunteer-system/admin/auth.py

159 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""JWT 鉴权 (T6.1).
- encode_token / decode_token: HS256 签发与校验
- get_current_user: FastAPI 依赖,从 Authorization 头提取 Bearer token
- require_user / require_role: 路由级保护
"""
from __future__ import annotations
import time
from typing import Optional
import jwt
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from admin.config import Settings
from admin.db import AdminUser, AdminUserRepo
from admin.errors import (
AUTH_ACCOUNT_DISABLED,
AUTH_INSUFFICIENT_PERMISSION,
AUTH_TOKEN_EXPIRED,
AUTH_TOKEN_INVALID,
BusinessError,
)
_BEARER_SCHEME = HTTPBearer(auto_error=False)
def encode_token(
user: AdminUser,
settings: Settings,
*,
extra_claims: Optional[dict] = None,
) -> str:
"""签发 JWT。
Args:
user: 当前登录用户
settings: 配置
extra_claims: 可选附加 claims如自定义角色扩展
Returns:
JWT 字符串
"""
now = int(time.time())
payload = {
"sub": f"admin:{user.id}",
"username": user.username,
"role": user.role,
"iat": now,
"exp": now + settings.jwt_expire_minutes * 60,
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
class TokenError(ValueError):
"""Token 解析失败(签名错/过期/格式错)。"""
def decode_token(token: str, settings: Settings) -> dict:
"""解析 JWT 校验签名与有效期。
Raises:
TokenError: 任何解析失败
"""
try:
return jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
except jwt.ExpiredSignatureError as e:
raise TokenError("token expired") from e
except jwt.InvalidTokenError as e:
raise TokenError(f"invalid token: {e}") from e
def _parse_user_id_from_subject(sub: str) -> int:
"""'admin:42' → 42"""
if not isinstance(sub, str) or ":" not in sub:
raise TokenError("malformed sub")
prefix, _, sid = sub.partition(":")
if prefix != "admin":
raise TokenError("unexpected sub prefix")
try:
return int(sid)
except ValueError as e:
raise TokenError("non-integer sub id") from e
def get_settings(request: Request) -> Settings:
"""从 app.state 取 Settings避免每次都重新加载"""
settings = getattr(request.app.state, "settings", None)
if settings is None: # pragma: no cover - 兜底
from admin.config import load_settings
settings = load_settings()
return settings
def _raise_token_error(exc: TokenError) -> None:
"""把 TokenError 翻译成业务错误 (区分过期 vs 无效)."""
msg = str(exc).lower()
if "expired" in msg:
raise BusinessError(AUTH_TOKEN_EXPIRED, detail={"reason": str(exc)}) from exc
raise BusinessError(AUTH_TOKEN_INVALID, detail={"reason": str(exc)}) from exc
def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_BEARER_SCHEME),
settings: Settings = Depends(get_settings),
) -> AdminUser:
"""FastAPI 依赖:从 Authorization: Bearer *** JWT,返回 AdminUser。
缺失/无效/过期一律 401 (走业务错误码 E012xx 系列).
"""
if credentials is None or credentials.scheme.lower() != "bearer":
raise BusinessError(
AUTH_TOKEN_INVALID, detail={"reason": "missing bearer token"}
)
try:
claims = decode_token(credentials.credentials, settings)
except TokenError as e:
_raise_token_error(e)
raise AssertionError("unreachable")
try:
user_id = _parse_user_id_from_subject(claims.get("sub", ""))
except TokenError as e:
_raise_token_error(e)
raise AssertionError("unreachable")
repo = AdminUserRepo(settings.db_path)
user = repo.get_by_id(user_id)
if user is None:
raise BusinessError(AUTH_TOKEN_INVALID, detail={"reason": "user not found"})
if not user.is_active:
raise BusinessError(AUTH_ACCOUNT_DISABLED, detail={"user_id": user.id})
return user
def require_role(*allowed_roles: str):
def _dep(user: AdminUser = Depends(get_current_user)) -> AdminUser:
if user.role not in allowed_roles:
raise BusinessError(
AUTH_INSUFFICIENT_PERMISSION,
detail={
"required_roles": list(allowed_roles),
"actual_role": user.role,
"user_id": user.id,
},
)
return user
return _dep