mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-16 05:45:32 +00:00
* feat: 新增统一认证中心 * perf: 优化代码 * fix: 修复问题 * perf: 优化页面 * fix: 修复部分接口缺失Request参数的问题 * perf: 优化访问控制机制 * perf: 优化代码 * fix: 修复 Logout Token 缺少 exp * perf&fix: 优化和修复 * perf: 优化代码 * perf: 优化代码 * fix: 修复测试问题
331 lines
11 KiB
Python
331 lines
11 KiB
Python
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from urllib.parse import parse_qsl
|
|
|
|
import jwt
|
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
from fastapi import Depends, HTTPException, Request, params
|
|
from jwt.exceptions import PyJWTError
|
|
from redis.asyncio import Redis
|
|
from redis.exceptions import RedisError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from common.aspect.db_session import DBSessionDependency
|
|
from config.env import OidcConfig
|
|
from exceptions.exception import OAuthProtocolException, OidcInteractionException
|
|
from module_identity.dao.oidc_key_dao import OidcKeyDao
|
|
from module_identity.security.client_auth import ClientAuthenticationError
|
|
from module_identity.security.jwt_profile import JwtProfileError, decode_access_token
|
|
from module_identity.security.principal import OAuthClientPrincipal
|
|
from module_identity.service.interaction_service import InteractionService
|
|
from module_identity.service.runtime_service import OidcRuntimeService
|
|
from module_identity.service.token_service import TokenService
|
|
from utils.time_util import TimezoneUtil
|
|
|
|
_MAX_PROTOCOL_FORM_BYTES = 16 * 1024
|
|
_INVALID_PERCENT_ESCAPE = re.compile(r'%(?![0-9A-Fa-f]{2})')
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AccessTokenContext:
|
|
"""
|
|
已验签且绑定 UserInfo audience 的 Access Token 快照
|
|
"""
|
|
|
|
token: str
|
|
claims: dict[str, Any]
|
|
|
|
|
|
_JWT_DOT_COUNT = 2
|
|
_REMOTE_KEY_HEADERS = frozenset({'jku', 'x5u', 'jwk', 'x5c'})
|
|
|
|
|
|
async def load_access_verification_key(
|
|
token: str,
|
|
query_db: AsyncSession,
|
|
*,
|
|
now: datetime | None = None,
|
|
) -> RSAPublicKey:
|
|
"""
|
|
按 Access Token header kid 加载发布窗口内的本地公钥
|
|
|
|
:param token: 待验证的 JWT;仅用于读取 header kid,不信任其中的公钥材料
|
|
:param query_db: 异步数据库会话
|
|
:param now: 可注入的统一项目当前时间
|
|
:return: 数据库记录对应的 RSA 公钥对象
|
|
:raises JwtProfileError: Header、kid 或本地公钥不可用
|
|
"""
|
|
|
|
try:
|
|
header = jwt.get_unverified_header(token)
|
|
except (PyJWTError, TypeError, ValueError) as exc:
|
|
raise JwtProfileError('JWT 头部格式无效') from exc
|
|
if _REMOTE_KEY_HEADERS.intersection(header) or header.get('alg') != 'RS256' or header.get('typ') != 'at+jwt':
|
|
raise JwtProfileError('JWT 头部包含不支持的参数')
|
|
kid = header.get('kid')
|
|
if not isinstance(kid, str) or not kid:
|
|
raise JwtProfileError('签名密钥标识 kid 不能为空')
|
|
current = TimezoneUtil.to_utc(now or TimezoneUtil.utc_now())
|
|
record = await OidcKeyDao.get_verifying(query_db, kid, current)
|
|
if record is None or not isinstance(record.public_jwk, dict):
|
|
raise JwtProfileError('未知的签名密钥标识 kid')
|
|
try:
|
|
return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(record.public_jwk, separators=(',', ':')))
|
|
except (TypeError, ValueError, KeyError) as exc:
|
|
raise JwtProfileError('签名公钥无效') from exc
|
|
|
|
|
|
def _disabled() -> None:
|
|
"""
|
|
在 OIDC 关闭时返回本地 404,不执行协议认证
|
|
"""
|
|
|
|
if not OidcConfig.oidc_enabled:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail='请求的资源不存在',
|
|
headers={'Cache-Control': 'no-store', 'Pragma': 'no-cache'},
|
|
)
|
|
|
|
|
|
async def require_oidc_protocol_ready(
|
|
request: Request,
|
|
query_db: AsyncSession = DBSessionDependency(),
|
|
) -> None:
|
|
"""
|
|
在 OIDC 已启用但签名能力尚未就绪时阻断公共协议端点
|
|
|
|
OIDC 关闭时仍由各控制器维持原有本地 404 行为;启用但缺少有效
|
|
active 密钥时统一返回标准 OAuth 503,不影响后台管理接口。
|
|
|
|
:param request: 当前 HTTP 请求
|
|
:param query_db: 异步数据库会话
|
|
:return: None
|
|
:raises OAuthProtocolException: OIDC 协议尚未具备签名能力
|
|
"""
|
|
|
|
if not OidcConfig.oidc_enabled:
|
|
return
|
|
readiness = await OidcRuntimeService.cached_readiness(request.app, query_db)
|
|
if not readiness.ready:
|
|
raise OAuthProtocolException(
|
|
'temporarily_unavailable',
|
|
'Authorization server is not ready',
|
|
503,
|
|
)
|
|
|
|
|
|
def _redis(request: Request) -> Redis:
|
|
"""
|
|
读取应用生命周期创建的 Redis 客户端
|
|
|
|
:param request: 当前HTTP请求
|
|
:return: 应用生命周期创建的Redis客户端
|
|
"""
|
|
|
|
value = getattr(request.app.state, 'redis', None)
|
|
if value is None:
|
|
raise HTTPException(status_code=503, detail='认证服务暂不可用')
|
|
return value
|
|
|
|
|
|
async def read_form(request: Request) -> dict[str, str]:
|
|
"""
|
|
严格读取 URL encoded 表单并拒绝重复字段
|
|
|
|
:param request: 当前 HTTP 请求
|
|
:return: 唯一字段映射
|
|
:raises HTTPException: Content-Type 错误或字段重复
|
|
"""
|
|
|
|
_disabled()
|
|
content_type = request.headers.get('content-type', '').split(';', 1)[0].strip().lower()
|
|
if content_type != 'application/x-www-form-urlencoded':
|
|
raise HTTPException(status_code=415, detail='不支持当前请求内容类型')
|
|
content_length = request.headers.get('content-length')
|
|
declared_length: int | None = None
|
|
if content_length is not None:
|
|
try:
|
|
declared_length = int(content_length)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=413, detail='请求体大小超过限制') from exc
|
|
if declared_length < 0 or declared_length > _MAX_PROTOCOL_FORM_BYTES:
|
|
raise HTTPException(status_code=413, detail='请求体大小超过限制')
|
|
chunks: list[bytes] = []
|
|
actual_length = 0
|
|
async for chunk in request.stream():
|
|
actual_length += len(chunk)
|
|
if actual_length > _MAX_PROTOCOL_FORM_BYTES:
|
|
raise HTTPException(status_code=413, detail='请求体大小超过限制')
|
|
chunks.append(chunk)
|
|
if declared_length is not None and actual_length != declared_length:
|
|
raise HTTPException(status_code=413, detail='请求体大小超过限制')
|
|
try:
|
|
decoded_body = b''.join(chunks).decode('utf-8')
|
|
if _INVALID_PERCENT_ESCAPE.search(decoded_body):
|
|
raise ValueError('百分号编码无效')
|
|
form_items = parse_qsl(
|
|
decoded_body,
|
|
keep_blank_values=True,
|
|
strict_parsing=True,
|
|
encoding='utf-8',
|
|
errors='strict',
|
|
max_num_fields=64,
|
|
)
|
|
except (UnicodeDecodeError, ValueError) as exc:
|
|
raise HTTPException(status_code=400, detail='表单格式或参数无效') from exc
|
|
result: dict[str, str] = {}
|
|
for key, value in form_items:
|
|
if key in result:
|
|
raise HTTPException(status_code=400, detail='表单格式或参数无效')
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
async def get_oidc_client(
|
|
request: Request,
|
|
query_db: AsyncSession = DBSessionDependency(),
|
|
) -> OAuthClientPrincipal:
|
|
"""
|
|
验证 Basic 或 Public Client 表单认证
|
|
|
|
:param request: 当前 HTTP 请求
|
|
:param query_db: 异步数据库会话
|
|
:return: 已完成认证的 Client Principal
|
|
"""
|
|
|
|
form = await read_form(request)
|
|
authorization = request.headers.get('authorization')
|
|
try:
|
|
_, principal = await TokenService.authenticate_client(
|
|
query_db,
|
|
authorization=authorization,
|
|
client_id=form.get('client_id'),
|
|
client_secret=form.get('client_secret'),
|
|
)
|
|
except (ClientAuthenticationError, ValueError, OAuthProtocolException):
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail='客户端认证失败',
|
|
headers={'WWW-Authenticate': 'Basic realm="oauth2/token"'},
|
|
) from None
|
|
return principal
|
|
|
|
|
|
async def _load_access_context(request: Request, query_db: AsyncSession) -> AccessTokenContext:
|
|
"""
|
|
按 JWT header kid 查询数据库公钥并验证严格 Access Token Profile
|
|
|
|
:param request: 当前HTTP请求
|
|
:param query_db: orm对象
|
|
:return: 已校验的访问令牌及声明上下文
|
|
"""
|
|
|
|
_disabled()
|
|
authorization = request.headers.get('authorization', '')
|
|
if authorization[:7].lower() != 'bearer ' or ',' in authorization:
|
|
raise _invalid_token()
|
|
token = authorization[7:].strip()
|
|
if not token or token.count('.') != _JWT_DOT_COUNT:
|
|
raise _invalid_token()
|
|
try:
|
|
key = await load_access_verification_key(token, query_db)
|
|
issuer = OidcConfig.oidc_issuer.rstrip('/')
|
|
claims = decode_access_token(
|
|
token,
|
|
verification_key=key,
|
|
issuer=issuer,
|
|
audience=f'{issuer}/oauth2/userinfo',
|
|
clock_skew=OidcConfig.oidc_allowed_clock_skew_seconds,
|
|
)
|
|
scope = claims.get('scope', '').split()
|
|
if (
|
|
'openid' not in scope
|
|
or not isinstance(claims.get('sid'), str)
|
|
or claims.get('sub', '').startswith('client:')
|
|
):
|
|
raise JwtProfileError('当前令牌不是用户访问令牌')
|
|
if isinstance(claims.get('ver'), bool) or not isinstance(claims.get('ver'), int) or claims['ver'] < 1:
|
|
raise JwtProfileError('身份安全版本无效')
|
|
return AccessTokenContext(token, claims)
|
|
except (JwtProfileError, PyJWTError, TypeError, ValueError, KeyError):
|
|
raise _invalid_token() from None
|
|
|
|
|
|
async def get_oidc_access_token(
|
|
request: Request,
|
|
query_db: AsyncSession = DBSessionDependency(),
|
|
) -> AccessTokenContext:
|
|
"""
|
|
验证 UserInfo 使用的 Bearer Access Token
|
|
|
|
:param request: 当前HTTP请求
|
|
:param query_db: orm对象
|
|
:return: UserInfo使用的访问令牌上下文
|
|
"""
|
|
|
|
return await _load_access_context(request, query_db)
|
|
|
|
|
|
async def get_interaction_csrf(
|
|
request: Request,
|
|
interaction_id: str,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
验证 Interaction CSRF Header,并返回内部状态记录
|
|
|
|
:param request: 当前 HTTP 请求
|
|
:param interaction_id: 路径中的 Interaction 标识
|
|
:return: 已验证的内部 Interaction 记录
|
|
"""
|
|
|
|
_disabled()
|
|
redis = _redis(request)
|
|
csrf_token = request.headers.get('x-csrf-token')
|
|
if not csrf_token or ',' in csrf_token:
|
|
raise HTTPException(status_code=403, detail='CSRF 校验失败,请重新发起认证')
|
|
try:
|
|
record = await InteractionService.get_record(redis, interaction_id)
|
|
except (OidcInteractionException, RedisError, TypeError, ValueError):
|
|
raise HTTPException(status_code=404, detail='认证交互不存在') from None
|
|
if not InteractionService.verify_csrf(record, csrf_token, pepper=OidcConfig.oidc_token_hash_pepper):
|
|
raise HTTPException(status_code=403, detail='CSRF 校验失败,请重新发起认证')
|
|
return record
|
|
|
|
|
|
def _invalid_token() -> HTTPException:
|
|
"""
|
|
构造不泄露验证细节的 Bearer 401
|
|
"""
|
|
|
|
return HTTPException(
|
|
status_code=401, detail='访问令牌无效', headers={'WWW-Authenticate': 'Bearer error="invalid_token"'}
|
|
)
|
|
|
|
|
|
def OidcClientDependency() -> params.Depends: # noqa: N802
|
|
"""
|
|
返回 Client Authentication 依赖
|
|
"""
|
|
|
|
return Depends(get_oidc_client)
|
|
|
|
|
|
def OidcAccessTokenDependency() -> params.Depends: # noqa: N802
|
|
"""
|
|
返回严格 Access Token 依赖
|
|
"""
|
|
|
|
return Depends(get_oidc_access_token)
|
|
|
|
|
|
def InteractionCsrfDependency() -> params.Depends: # noqa: N802
|
|
"""
|
|
返回 Interaction CSRF 依赖
|
|
"""
|
|
|
|
return Depends(get_interaction_csrf)
|