Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/module_identity/controller/interaction_controller.py
T
insistence a89aec54aa feat: 新增 OAuth 2.0 / OIDC 统一认证中心 (#129)
* feat: 新增统一认证中心

* perf: 优化代码

* fix: 修复问题

* perf: 优化页面

* fix: 修复部分接口缺失Request参数的问题

* perf: 优化访问控制机制

* perf: 优化代码

* fix: 修复 Logout Token 缺少 exp

* perf&fix: 优化和修复

* perf: 优化代码

* perf: 优化代码

* fix: 修复测试问题
2026-10-01 22:56:05 +08:00

318 lines
12 KiB
Python

import json
from typing import Annotated, TypeVar
from fastapi import Depends, Header, Request
from fastapi.responses import Response
from pydantic import BaseModel, ValidationError
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.db_session import DBSessionDependency
from common.router import APIRouterPro
from common.vo import DataResponseModel
from config.env import OidcConfig
from exceptions.exception import OidcInteractionException
from module_identity.dependencies import require_oidc_protocol_ready
from module_identity.entity.vo.interaction_vo import (
CaptchaResponseModel,
ChangePasswordModel,
InteractionConsentModel,
InteractionLoginModel,
InteractionResultModel,
InteractionViewModel,
)
from module_identity.service.authorization_service import InteractionCompletionService
from module_identity.service.consent_service import InteractionConsentService
from module_identity.service.interaction_service import (
InteractionFlowService,
InteractionLoginOutcome,
InteractionLoginService,
InteractionService,
)
from module_identity.service.session_service import SsoSessionError, SsoSessionService
from utils.client_ip_util import ClientIPUtil
from utils.oidc_util import OidcUtil
from utils.response_util import ResponseUtil
interaction_controller = APIRouterPro(
tags=['认证中心交互'], order_num=5, dependencies=[Depends(require_oidc_protocol_ready)]
)
_MODEL = TypeVar('_MODEL', bound=BaseModel)
_MAX_INTERACTION_BODY_BYTES = 16 * 1024
_NO_STORE = {'Cache-Control': 'no-store', 'Pragma': 'no-cache'}
def _redis(request: Request) -> Redis:
"""
读取应用生命周期创建的 Redis 客户端
:param request: 当前 HTTP 请求
:return: 应用 Redis 客户端
:raises OidcInteractionException: Redis 客户端不可用
"""
value = getattr(request.app.state, 'redis', None)
if value is None:
raise OidcInteractionException(error='server_error', status_code=503, message='认证服务不可用')
return value
def _not_found() -> Response:
"""
构造认证中心未启用时的响应
:return: 认证中心未启用的 404 响应
"""
return _failure_response('认证服务未启用', 404)
def _invalid_body() -> Response:
"""
构造认证交互请求无效时的响应
:return: 请求无效的 400 响应
"""
return _failure_response('认证交互请求无效', 400)
def _failure_response(msg: str, status_code: int, headers: dict[str, str] | None = None) -> Response:
"""
构造项目统一错误响应并保留安全 HTTP 状态码
:param msg: 错误消息
:param status_code: HTTP 状态码
:param headers: 可选响应头
:return: 统一错误响应
"""
response = ResponseUtil.failure(msg=msg, headers=headers or _NO_STORE)
response.status_code = status_code
return response
def _login_response(outcome: InteractionLoginOutcome) -> Response:
"""
构造认证登录或改密响应
:param outcome: 登录流程结果
:return: 统一认证响应
"""
if outcome.failure_message is not None:
return ResponseUtil.failure(msg=outcome.failure_message, headers=_NO_STORE)
response = ResponseUtil.success(data=outcome.result, headers=_NO_STORE)
if outcome.cookie is not None:
try:
OidcUtil.parse_sso_cookie(outcome.cookie)
except ValueError as exc:
raise SsoSessionError(str(exc)) from exc
response.set_cookie(value=outcome.cookie, **SsoSessionService.cookie_parameters(max_age=outcome.cookie_max_age))
return response
async def _safe_json_body(request: Request, model: type[_MODEL]) -> _MODEL:
"""
读取并校验认证交互 JSON 请求体
:param request: 当前 HTTP 请求
:param model: 请求体模型类型
:return: 校验后的请求体模型
:raises OidcInteractionException: 请求体格式或内容无效
"""
# 仅接受 JSON,避免协议请求被宽松解析
content_type = request.headers.get('content-type', '').split(';', 1)[0].strip().lower()
if content_type != 'application/json':
raise OidcInteractionException(error='invalid_request', status_code=400, message='认证交互请求无效')
try:
chunks: list[bytes] = []
size = 0
async for chunk in request.stream():
if not isinstance(chunk, (bytes, bytearray)):
raise ValueError('请求体必须为字节数据')
# 限制请求体大小,避免认证交互接口被大请求消耗资源
size += len(chunk)
if size > _MAX_INTERACTION_BODY_BYTES:
raise ValueError('请求体大小超过允许范围')
chunks.append(bytes(chunk))
raw = b''.join(chunks)
except Exception as exc:
raise OidcInteractionException(error='invalid_request', status_code=400, message='认证交互请求无效') from exc
if len(raw) > _MAX_INTERACTION_BODY_BYTES:
raise OidcInteractionException(error='invalid_request', status_code=400, message='认证交互请求无效')
try:
value = json.loads(raw.decode('utf-8'), object_pairs_hook=OidcUtil.json_object_pairs)
if not isinstance(value, dict):
raise ValueError('JSON 请求体必须为对象')
return model.model_validate(value)
except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError, ValidationError) as exc:
raise OidcInteractionException(error='invalid_request', status_code=400, message='认证交互请求无效') from exc
@interaction_controller.get(
'/auth/interaction/{interaction_id}',
summary='获取认证交互页面接口',
description='用于获取认证交互页面信息',
response_model=DataResponseModel[InteractionViewModel],
)
async def get_interaction(
request: Request,
interaction_id: str,
query_db: Annotated[AsyncSession, DBSessionDependency()],
csrf_token: str | None = Header(default=None, alias='X-CSRF-Token'),
) -> Response:
if not OidcConfig.oidc_enabled:
return _not_found()
redis = _redis(request)
await InteractionFlowService.csrf_record(redis, interaction_id, csrf_token)
page = await InteractionService.get(redis, interaction_id, query_db)
page['captchaEnabled'] = await InteractionFlowService.captcha_enabled(redis)
return ResponseUtil.success(data=InteractionViewModel.model_validate(page), headers=_NO_STORE)
@interaction_controller.get(
'/auth/interaction/{interaction_id}/captcha',
summary='获取认证交互验证码接口',
description='用于获取认证交互验证码',
response_model=DataResponseModel[CaptchaResponseModel],
)
async def captcha(request: Request, interaction_id: str) -> Response:
if not OidcConfig.oidc_enabled:
return _not_found()
outcome = await InteractionFlowService.captcha(_redis(request), interaction_id, ClientIPUtil.get_client_ip(request))
if outcome.rate_limited:
return _failure_response(
'请求过于频繁,请稍后再试',
429,
{**_NO_STORE, 'Retry-After': str(outcome.retry_after)},
)
if outcome.unavailable:
return _failure_response('认证服务暂不可用', 503)
return ResponseUtil.success(data=outcome.result, headers=_NO_STORE)
@interaction_controller.post(
'/auth/interaction/{interaction_id}/login',
summary='提交认证中心登录接口',
description='用于提交认证中心登录',
response_model=DataResponseModel[InteractionResultModel],
)
async def login_endpoint(
request: Request,
interaction_id: str,
query_db: Annotated[AsyncSession, DBSessionDependency()],
csrf_token: str | None = Header(default=None, alias='X-CSRF-Token'),
) -> Response:
if not OidcConfig.oidc_enabled:
return _not_found()
try:
body = await _safe_json_body(request, InteractionLoginModel)
except OidcInteractionException:
return _invalid_body()
outcome = await InteractionLoginService.login(
_redis(request),
interaction_id,
body,
query_db,
csrf_token,
ClientIPUtil.get_client_ip(request),
request.headers.get('user-agent'),
)
return _login_response(outcome)
@interaction_controller.post(
'/auth/interaction/{interaction_id}/change-password',
summary='提交认证中心改密接口',
description='用于提交认证中心密码修改',
response_model=DataResponseModel[InteractionResultModel],
)
async def change_password_endpoint(
request: Request,
interaction_id: str,
query_db: Annotated[AsyncSession, DBSessionDependency()],
csrf_token: str | None = Header(default=None, alias='X-CSRF-Token'),
) -> Response:
if not OidcConfig.oidc_enabled:
return _not_found()
try:
body = await _safe_json_body(request, ChangePasswordModel)
except OidcInteractionException:
return _invalid_body()
outcome = await InteractionLoginService.change_password(_redis(request), interaction_id, body, query_db, csrf_token)
return _login_response(outcome)
@interaction_controller.post(
'/auth/interaction/{interaction_id}/consent',
summary='提交认证中心授权同意接口',
description='用于提交认证中心授权同意',
response_model=DataResponseModel[InteractionResultModel],
)
async def consent_endpoint(
request: Request,
interaction_id: str,
query_db: Annotated[AsyncSession, DBSessionDependency()],
csrf_token: str | None = Header(default=None, alias='X-CSRF-Token'),
) -> Response:
if not OidcConfig.oidc_enabled:
return _not_found()
try:
body = await _safe_json_body(request, InteractionConsentModel)
except OidcInteractionException:
return _invalid_body()
result = await InteractionConsentService.consent(_redis(request), interaction_id, body, query_db, csrf_token)
return ResponseUtil.success(data=result, headers=_NO_STORE)
@interaction_controller.post(
'/auth/interaction/{interaction_id}/cancel',
summary='取消认证中心授权接口',
description='用于取消认证中心授权',
response_model=DataResponseModel[InteractionResultModel],
)
async def cancel(
request: Request,
interaction_id: str,
query_db: Annotated[AsyncSession, DBSessionDependency()],
csrf_token: str | None = Header(default=None, alias='X-CSRF-Token'),
) -> Response:
if not OidcConfig.oidc_enabled:
return _not_found()
result = await InteractionConsentService.cancel(_redis(request), interaction_id, query_db, csrf_token)
return ResponseUtil.success(data=result, headers=_NO_STORE)
@interaction_controller.post(
'/auth/interaction/{interaction_id}/complete',
summary='完成认证中心交互接口',
description='用于完成认证交互并返回外部客户端跳转地址',
response_model=DataResponseModel[InteractionResultModel],
)
async def complete(
request: Request,
interaction_id: str,
query_db: Annotated[AsyncSession, DBSessionDependency()],
csrf_token: str | None = Header(default=None, alias='X-CSRF-Token'),
) -> Response:
if not OidcConfig.oidc_enabled:
return _not_found()
await InteractionFlowService.csrf_record(_redis(request), interaction_id, csrf_token)
result = await InteractionCompletionService.complete(_redis(request), interaction_id, query_db)
return ResponseUtil.success(
data=InteractionResultModel(
next_action='redirect',
interaction_id=interaction_id,
redirect_url=result.location,
),
headers=_NO_STORE,
)