mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-13 12:49:27 +00:00
* feat: 新增统一认证中心 * perf: 优化代码 * fix: 修复问题 * perf: 优化页面 * fix: 修复部分接口缺失Request参数的问题 * perf: 优化访问控制机制 * perf: 优化代码 * fix: 修复 Logout Token 缺少 exp * perf&fix: 优化和修复 * perf: 优化代码 * perf: 优化代码 * fix: 修复测试问题
411 lines
14 KiB
Python
411 lines
14 KiB
Python
from typing import Annotated, cast
|
|
|
|
from fastapi import Depends, HTTPException, Request
|
|
from fastapi.responses import JSONResponse, Response
|
|
from jwt.exceptions import PyJWTError
|
|
from redis.asyncio import Redis
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from common.aspect.db_session import DBSessionDependency
|
|
from common.constant import OidcAuditEvent
|
|
from common.router import APIRouterPro
|
|
from config.env import OidcConfig
|
|
from exceptions.exception import OAuthProtocolException
|
|
from module_identity.dependencies import (
|
|
AccessTokenContext,
|
|
OidcAccessTokenDependency,
|
|
load_access_verification_key,
|
|
read_form,
|
|
require_oidc_protocol_ready,
|
|
)
|
|
from module_identity.redis_keys import OidcRedisKey
|
|
from module_identity.security.jwt_profile import JwtProfileError
|
|
from module_identity.service.audit_service import AuditService
|
|
from module_identity.service.infrastructure_service import OidcRateLimiter, RateLimitExceeded, RateLimitUnavailable
|
|
from module_identity.service.token_protocol_service import (
|
|
IntrospectionService,
|
|
RevocationError,
|
|
RevocationService,
|
|
UserInfoService,
|
|
)
|
|
from module_identity.service.token_service import RefreshTokenReuseDetected, TokenService
|
|
from utils.oidc_util import OidcUtil
|
|
|
|
token_controller = APIRouterPro(tags=['认证中心协议'], order_num=2, dependencies=[Depends(require_oidc_protocol_ready)])
|
|
_NO_STORE = {'Cache-Control': 'no-store', 'Pragma': 'no-cache'}
|
|
_FORM_LIMITS = {
|
|
'grant_type': 50,
|
|
'client_id': 64,
|
|
'client_secret': 512,
|
|
'code': 4096,
|
|
'redirect_uri': 1000,
|
|
'code_verifier': 128,
|
|
'refresh_token': 4096,
|
|
'scope': 2000,
|
|
'resource': 1000,
|
|
'token': 8192,
|
|
'token_type_hint': 32,
|
|
}
|
|
_RATE_LIMIT = 60
|
|
_RATE_WINDOW_SECONDS = 60
|
|
_JWT_DOT_COUNT = 2
|
|
_NOT_FOUND = 404
|
|
|
|
|
|
def _invalid_userinfo_response() -> JSONResponse:
|
|
"""
|
|
构造不泄露原因的 UserInfo 401 响应
|
|
|
|
:return: 不泄露校验细节的 UserInfo 401 响应
|
|
"""
|
|
# 不向客户端泄露 UserInfo 校验失败原因
|
|
return JSONResponse(
|
|
content={'error': 'invalid_token'},
|
|
status_code=401,
|
|
headers={**_NO_STORE, 'WWW-Authenticate': 'Bearer error="invalid_token"'},
|
|
)
|
|
|
|
|
|
def _request_redis(request: Request) -> Redis:
|
|
"""
|
|
读取应用 Redis 客户端
|
|
|
|
:param request: 当前 HTTP 请求
|
|
:return: Redis 客户端
|
|
:raises OAuthProtocolException: Redis 客户端不可用时抛出
|
|
"""
|
|
|
|
redis = getattr(request.app.state, 'redis', None)
|
|
if redis is None:
|
|
raise OAuthProtocolException('server_error', 'Token endpoint is unavailable', 503)
|
|
return cast('Redis', redis)
|
|
|
|
|
|
async def _pre_auth_rate_limit(request: Request, redis: Redis, form: dict[str, str], endpoint: str) -> None:
|
|
"""
|
|
在 Client Secret 校验前按可见 Client ID 限流
|
|
|
|
:param request: 当前 HTTP 请求
|
|
:param redis: Redis 客户端
|
|
:param form: 已解析的协议表单
|
|
:param endpoint: 当前协议端点标识
|
|
:return: 无返回值
|
|
:raises RateLimitExceeded: 请求超过限流阈值时抛出
|
|
:raises RateLimitUnavailable: 限流服务不可用时抛出
|
|
"""
|
|
# 在 Client Secret 校验前按可见 Client ID 限流
|
|
client_id = form.get('client_id')
|
|
authorization = request.headers.get('authorization')
|
|
if authorization:
|
|
try:
|
|
client_id, _ = OidcUtil.parse_basic_credentials(authorization)
|
|
except ValueError:
|
|
client_id = None
|
|
if not isinstance(client_id, str) or not client_id:
|
|
client_id = 'anonymous'
|
|
key_builders = {
|
|
'token': OidcRedisKey.token_client_rate_limit,
|
|
'revoke': OidcRedisKey.revoke_client_rate_limit,
|
|
'introspect': OidcRedisKey.introspect_client_rate_limit,
|
|
}
|
|
try:
|
|
key = key_builders[endpoint](client_id)
|
|
except (KeyError, TypeError, ValueError):
|
|
key = key_builders[endpoint]('anonymous')
|
|
await OidcRateLimiter.enforce(redis, key, limit=_RATE_LIMIT, window_seconds=_RATE_WINDOW_SECONDS)
|
|
|
|
|
|
def _validate_protocol_form(form: dict[str, str], allowed: set[str]) -> None:
|
|
"""
|
|
校验协议表单字段白名单和长度
|
|
|
|
:param form: 已解析的协议表单
|
|
:param allowed: 当前端点允许的字段集合
|
|
:return: 无返回值
|
|
:raises OAuthProtocolException: 包含未知字段或超长字段时抛出
|
|
"""
|
|
# 拒绝未知或超长的协议表单字段
|
|
if any(key not in allowed for key in form) or any(
|
|
key in _FORM_LIMITS and len(value) > _FORM_LIMITS[key] for key, value in form.items()
|
|
):
|
|
raise OAuthProtocolException('invalid_request', 'Invalid request', 400)
|
|
|
|
|
|
def _rate_limit_error(error: RateLimitExceeded | RateLimitUnavailable) -> JSONResponse:
|
|
"""
|
|
构造协议限流错误响应
|
|
|
|
:param error: 限流异常
|
|
:return: 裸标准限流错误响应
|
|
"""
|
|
# 限流响应不回显敏感字段
|
|
if isinstance(error, RateLimitExceeded):
|
|
response = JSONResponse(content={'error': 'temporarily_unavailable'}, status_code=429, headers=_NO_STORE)
|
|
response.headers['Retry-After'] = str(error.retry_after)
|
|
return response
|
|
return JSONResponse(content={'error': 'temporarily_unavailable'}, status_code=503, headers=_NO_STORE)
|
|
|
|
|
|
async def _record_independent_audit(db: AsyncSession, event_type: str, **fields: object) -> None:
|
|
"""
|
|
尝试记录协议失败审计,审计异常不改变标准错误响应
|
|
|
|
:param db: 异步数据库会话
|
|
:param event_type: 审计事件类型
|
|
:param fields: 审计字段
|
|
:return: 无返回值
|
|
"""
|
|
|
|
try:
|
|
await AuditService.record_independent(db, event_type, 'failure', **fields)
|
|
except Exception:
|
|
return
|
|
|
|
|
|
async def _verification_key_or_none(token: str, query_db: AsyncSession) -> object:
|
|
"""
|
|
为 JWT 撤销和内省加载本地公钥
|
|
|
|
:param token: 待读取的访问令牌
|
|
:param query_db: 异步数据库会话
|
|
:return: 本地公钥或 None
|
|
"""
|
|
# Opaque Token 保持幂等语义,JWT 校验失败按无公钥处理
|
|
if not isinstance(token, str) or token.count('.') != _JWT_DOT_COUNT:
|
|
return None
|
|
try:
|
|
return await load_access_verification_key(token, query_db)
|
|
except (JwtProfileError, PyJWTError, TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
async def _verification_key_loader(query_db: AsyncSession, token: str) -> object:
|
|
"""
|
|
提供给 Service 事务入口的本地公钥加载回调
|
|
|
|
:param query_db: 异步数据库会话
|
|
:param token: 待读取的访问令牌
|
|
:return: 本地公钥或 None
|
|
"""
|
|
|
|
return await _verification_key_or_none(token, query_db)
|
|
|
|
|
|
def _oauth_error(error: OAuthProtocolException) -> JSONResponse:
|
|
"""
|
|
生成裸标准 OAuth 错误响应
|
|
|
|
:param error: OAuth 协议异常
|
|
:return: 裸标准 OAuth 错误响应
|
|
"""
|
|
|
|
content = {'error': error.error}
|
|
if error.error_description:
|
|
content['error_description'] = error.error_description
|
|
headers = dict(_NO_STORE)
|
|
if error.error == 'invalid_client':
|
|
headers['WWW-Authenticate'] = 'Basic realm="oauth2/token"'
|
|
headers.update(error.headers)
|
|
|
|
return JSONResponse(content=content, status_code=error.status_code, headers=headers)
|
|
|
|
|
|
def _http_error(error: HTTPException) -> JSONResponse:
|
|
"""
|
|
将输入边界 HTTP 错误转换为裸 JSON 响应
|
|
|
|
:param error: 输入边界 HTTP 异常
|
|
:return: 裸 JSON 错误响应
|
|
"""
|
|
|
|
code = 'not_found' if error.status_code == _NOT_FOUND else 'invalid_request'
|
|
|
|
return JSONResponse(content={'error': code}, status_code=error.status_code, headers=_NO_STORE)
|
|
|
|
|
|
@token_controller.post(
|
|
'/oauth2/token',
|
|
summary='获取 OAuth Token 接口',
|
|
description='用于签发 OAuth Token',
|
|
include_in_schema=False,
|
|
)
|
|
async def token(
|
|
request: Request,
|
|
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
|
) -> Response:
|
|
try:
|
|
form = await read_form(request)
|
|
_validate_protocol_form(
|
|
form,
|
|
{
|
|
'grant_type',
|
|
'client_id',
|
|
'client_secret',
|
|
'code',
|
|
'redirect_uri',
|
|
'code_verifier',
|
|
'refresh_token',
|
|
'scope',
|
|
'resource',
|
|
},
|
|
)
|
|
redis = _request_redis(request)
|
|
await _pre_auth_rate_limit(request, redis, form, 'token')
|
|
client_secret = form.pop('client_secret', None)
|
|
result = await TokenService.issue_token_request(
|
|
query_db,
|
|
redis,
|
|
form,
|
|
authorization=request.headers.get('authorization'),
|
|
client_id=form.get('client_id'),
|
|
client_secret=client_secret,
|
|
)
|
|
return JSONResponse(content=result.as_dict(), headers=_NO_STORE)
|
|
except RefreshTokenReuseDetected as exc:
|
|
return _oauth_error(exc)
|
|
except (RateLimitExceeded, RateLimitUnavailable) as exc:
|
|
return _rate_limit_error(exc)
|
|
except OAuthProtocolException as exc:
|
|
await _record_independent_audit(
|
|
query_db,
|
|
OidcAuditEvent.INVALID_CLIENT if exc.error == 'invalid_client' else OidcAuditEvent.TOKEN_FAILED,
|
|
failure_code=exc.error,
|
|
)
|
|
return _oauth_error(exc)
|
|
except HTTPException as exc:
|
|
return _http_error(exc)
|
|
except Exception:
|
|
await _record_independent_audit(query_db, OidcAuditEvent.TOKEN_FAILED, failure_code='server_error')
|
|
return JSONResponse(
|
|
content={'error': 'server_error', 'error_description': 'Token endpoint is unavailable'},
|
|
status_code=500,
|
|
headers=_NO_STORE,
|
|
)
|
|
|
|
|
|
@token_controller.post(
|
|
'/oauth2/revoke',
|
|
summary='撤销 OAuth Token 接口',
|
|
description='用于撤销 OAuth Token',
|
|
include_in_schema=False,
|
|
)
|
|
async def revoke(
|
|
request: Request,
|
|
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
|
) -> Response:
|
|
try:
|
|
form = await read_form(request)
|
|
_validate_protocol_form(form, {'client_id', 'client_secret', 'token', 'token_type_hint'})
|
|
redis = _request_redis(request)
|
|
await _pre_auth_rate_limit(request, redis, form, 'revoke')
|
|
committed = await RevocationService.revoke_request(
|
|
query_db,
|
|
redis,
|
|
form.get('token', ''),
|
|
authorization=request.headers.get('authorization'),
|
|
client_id=form.get('client_id'),
|
|
client_secret=form.get('client_secret'),
|
|
verification_key_loader=_verification_key_loader,
|
|
token_type_hint=form.get('token_type_hint'),
|
|
)
|
|
if not committed:
|
|
return JSONResponse(content={'error': 'temporarily_unavailable'}, status_code=503, headers=_NO_STORE)
|
|
return Response(status_code=200, headers=_NO_STORE)
|
|
except OAuthProtocolException as exc:
|
|
await _record_independent_audit(
|
|
query_db,
|
|
OidcAuditEvent.INVALID_CLIENT if exc.error == 'invalid_client' else OidcAuditEvent.TOKEN_FAILED,
|
|
failure_code=exc.error,
|
|
)
|
|
return _oauth_error(exc)
|
|
except (RateLimitExceeded, RateLimitUnavailable) as exc:
|
|
return _rate_limit_error(exc)
|
|
except RevocationError as exc:
|
|
await _record_independent_audit(
|
|
query_db,
|
|
OidcAuditEvent.INVALID_CLIENT if exc.error == 'invalid_client' else OidcAuditEvent.TOKEN_FAILED,
|
|
failure_code=exc.error,
|
|
)
|
|
headers = dict(_NO_STORE)
|
|
if exc.error == 'invalid_client':
|
|
headers['WWW-Authenticate'] = 'Basic realm="oauth2/token"'
|
|
return JSONResponse(
|
|
content={'error': exc.error, 'error_description': exc.description},
|
|
status_code=401 if exc.error == 'invalid_client' else 400,
|
|
headers=headers,
|
|
)
|
|
except HTTPException as exc:
|
|
return _http_error(exc)
|
|
except Exception:
|
|
await _record_independent_audit(query_db, OidcAuditEvent.TOKEN_FAILED, failure_code='server_error')
|
|
return JSONResponse(content={'error': 'server_error'}, status_code=500, headers=_NO_STORE)
|
|
|
|
|
|
@token_controller.post(
|
|
'/oauth2/introspect',
|
|
summary='查询 OAuth Token 状态接口',
|
|
description='用于查询 OAuth Token 状态',
|
|
include_in_schema=False,
|
|
)
|
|
async def introspect(
|
|
request: Request,
|
|
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
|
) -> Response:
|
|
try:
|
|
form = await read_form(request)
|
|
_validate_protocol_form(form, {'client_id', 'client_secret', 'token', 'token_type_hint'})
|
|
redis = _request_redis(request)
|
|
await _pre_auth_rate_limit(request, redis, form, 'introspect')
|
|
result = await IntrospectionService.introspect_request(
|
|
query_db,
|
|
redis,
|
|
form.get('token', ''),
|
|
authorization=request.headers.get('authorization'),
|
|
client_id=form.get('client_id'),
|
|
client_secret=form.get('client_secret'),
|
|
verification_key_loader=_verification_key_loader,
|
|
token_type_hint=form.get('token_type_hint'),
|
|
)
|
|
return JSONResponse(content=result, headers=_NO_STORE)
|
|
except OAuthProtocolException as exc:
|
|
await _record_independent_audit(
|
|
query_db,
|
|
OidcAuditEvent.INVALID_CLIENT if exc.error == 'invalid_client' else OidcAuditEvent.TOKEN_FAILED,
|
|
failure_code=exc.error,
|
|
)
|
|
return _oauth_error(exc)
|
|
except (RateLimitExceeded, RateLimitUnavailable) as exc:
|
|
return _rate_limit_error(exc)
|
|
except HTTPException as exc:
|
|
return _http_error(exc)
|
|
except Exception:
|
|
await _record_independent_audit(query_db, OidcAuditEvent.TOKEN_FAILED, failure_code='server_error')
|
|
return JSONResponse(content={'error': 'server_error'}, status_code=500, headers=_NO_STORE)
|
|
|
|
|
|
@token_controller.get(
|
|
'/oauth2/userinfo',
|
|
summary='获取 UserInfo 接口',
|
|
description='用于返回当前访问令牌对应的用户信息',
|
|
include_in_schema=False,
|
|
)
|
|
@token_controller.post(
|
|
'/oauth2/userinfo',
|
|
summary='获取 UserInfo 接口',
|
|
description='用于返回当前访问令牌对应的用户信息',
|
|
include_in_schema=False,
|
|
)
|
|
async def userinfo(
|
|
request: Request,
|
|
token_context: Annotated[AccessTokenContext, OidcAccessTokenDependency()],
|
|
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
|
) -> Response:
|
|
if not OidcConfig.oidc_enabled:
|
|
return JSONResponse(content={'error': 'not_found'}, status_code=404, headers=_NO_STORE)
|
|
try:
|
|
claims = token_context.claims
|
|
output = await UserInfoService.build(query_db, claims, getattr(request.app.state, 'redis', None))
|
|
return JSONResponse(content=output, headers=_NO_STORE)
|
|
except Exception:
|
|
return _invalid_userinfo_response()
|