Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/module_identity/controller/authorization_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

389 lines
16 KiB
Python

import hashlib
from base64 import b64encode
from collections.abc import Iterable
from html import escape
from typing import Annotated, Any, cast
from urllib.parse import urlsplit
from fastapi import Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
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 read_form, require_oidc_protocol_ready
from module_identity.redis_keys import OidcRedisKey
from module_identity.service.audit_service import AuditService
from module_identity.service.authorization_service import AuthorizationService
from module_identity.service.infrastructure_service import OidcRateLimiter, RateLimitExceeded, RateLimitUnavailable
from module_identity.service.logout_confirmation_service import LogoutConfirmationService
from module_identity.service.session_service import LogoutService, LogoutServiceError, SsoSessionService
from utils.client_ip_util import ClientIPUtil
from utils.oidc_util import OidcUtil
authorization_controller = APIRouterPro(
tags=['认证中心协议'], order_num=4, dependencies=[Depends(require_oidc_protocol_ready)]
)
_NO_STORE = {'Cache-Control': 'no-store', 'Pragma': 'no-cache'}
_AUTHORIZE_RATE_LIMIT = 30
_AUTHORIZE_RATE_WINDOW_SECONDS = 60
_LOGOUT_STYLE = """
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;color:#1f2937;background:#f3f4f6}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px}
main{width:100%;max-width:480px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:36px}
.brand{font-size:14px;color:#4b5563;margin:0 0 24px}h1{font-size:26px;line-height:1.4;margin:0 0 16px}
p{font-size:16px;line-height:1.75;margin:0 0 28px;color:#4b5563}form{display:flex;gap:12px;flex-wrap:wrap}
button{flex:1;min-height:44px;padding:10px 16px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;
color:#1f2937;font:inherit;font-size:15px;cursor:pointer}button:hover{background:#f3f4f6}
button:focus-visible{outline:3px solid #2563eb;outline-offset:3px}
button[value=confirm]{background:#b91c1c;border-color:#b91c1c;color:#fff}button[value=confirm]:hover{background:#991b1b}
@media(max-width:400px){body{padding:16px}main{padding:24px}form{flex-direction:column}}
"""
_LOGOUT_STYLE_HASH = b64encode(hashlib.sha256(_LOGOUT_STYLE.encode()).digest()).decode()
_LOGOUT_QUERY_LIMITS = {'id_token_hint': 8192, 'post_logout_redirect_uri': 1000, 'state': 2048}
_LOGOUT_QUERY_FIELDS = frozenset(_LOGOUT_QUERY_LIMITS)
def _read_authorization_query(request: Request) -> dict[str, str]:
"""
读取并拒绝重复授权 Query 参数
:param request: 当前 HTTP 请求
:return: 唯一 Query 参数映射
:raises OAuthProtocolException: Query 参数重复时抛出
"""
values: dict[str, str] = {}
# 拒绝重复授权参数
for key, value in request.query_params.multi_items():
if key in values:
raise OAuthProtocolException('invalid_request', 'Duplicate authorization parameter')
values[key] = value
return values
def _authorization_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', 'Authorization service is unavailable', 503)
return cast('Redis', redis)
async def _enforce_authorization_rate_limit(request: Request, redis: Redis) -> None:
"""
按 HMAC-IP 固定窗口执行授权请求限流
:param request: 当前 HTTP 请求
:param redis: Redis 客户端
:return: 无返回值
:raises OAuthProtocolException: 客户端地址缺失、请求超限或限流服务不可用时抛出
"""
# 使用 HMAC-IP 固定窗口限流,Redirect 校验前不执行外跳
ip_address = ClientIPUtil.get_client_ip(request)
if not isinstance(ip_address, str) or not ip_address or ip_address == 'unknown':
raise OAuthProtocolException('temporarily_unavailable', 'Authorization service is unavailable', 503)
ip_hash = OidcUtil.hash_sensitive_identifier(ip_address, OidcConfig.oidc_token_hash_pepper)
try:
await OidcRateLimiter.enforce(
redis,
OidcRedisKey.authorize_ip_rate_limit(ip_hash),
limit=_AUTHORIZE_RATE_LIMIT,
window_seconds=_AUTHORIZE_RATE_WINDOW_SECONDS,
)
except RateLimitExceeded as exc:
raise OAuthProtocolException(
'slow_down', 'Too many authorization requests', 429, headers={'Retry-After': str(exc.retry_after)}
) from exc
except RateLimitUnavailable as exc:
raise OAuthProtocolException('temporarily_unavailable', 'Authorization service is unavailable', 503) from exc
async def _record_authorization_failure_audit(db: AsyncSession, **fields: Any) -> None:
"""
尝试记录授权失败审计,审计异常不影响原协议错误
:param db: 异步数据库会话
:param fields: 审计字段
:return: 无返回值
"""
try:
await AuditService.record_independent(
db, OidcAuditEvent.AUTHORIZE_DENIED, 'failure', risk_level='high', **fields
)
except Exception:
return
def _oidc_not_found_response() -> Response:
"""
构造 OIDC 关闭时的本地 404 响应
:return: OIDC 未启用时的本地 404 响应
"""
return JSONResponse(content={'error': 'not_found'}, status_code=404, headers=_NO_STORE)
def _logout_page(title: str, body: str, *, redirect_origin: str | None = None) -> HTMLResponse:
"""
构造仅包含服务端固定内容的退出页面
:param title: 页面标题
:param body: 服务端生成的页面正文
:param redirect_origin: 已校验的客户端回调源地址
:return: 带有内容安全策略的退出页面响应
"""
form_sources = "'self'" + (f' {redirect_origin}' if redirect_origin else '')
content = (
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
f'<title>{escape(title)}</title><style>{_LOGOUT_STYLE}</style></head>'
'<body><main><div class="brand">统一认证中心</div>'
f'<h1>{escape(title)}</h1>{body}</main></body></html>'
)
return HTMLResponse(
content,
headers={
**_NO_STORE,
'Content-Security-Policy': (
f"default-src 'none'; style-src 'sha256-{_LOGOUT_STYLE_HASH}'; "
f"form-action {form_sources}; base-uri 'none'; frame-ancestors 'none'"
),
'Referrer-Policy': 'strict-origin',
'X-Frame-Options': 'DENY',
},
)
def _confirmation_response(token: str, redirect_origin: str | None = None) -> HTMLResponse:
"""
构造绑定一次性凭据的退出确认页面
:param token: 一次性退出确认凭据
:param redirect_origin: 已校验的客户端回调源地址
:return: 退出确认页面响应
"""
return _logout_page(
'退出认证中心?',
(
'<p>确认后,此次登录以及关联应用的离线访问授权将失效。'
'你可以取消,继续保持登录。</p>'
'<form method="post" action="/oauth2/logout/confirm">'
f'<input type="hidden" name="confirmation" value="{escape(token, quote=True)}">'
'<button name="decision" value="cancel" type="submit" autofocus>取消,保持登录</button>'
'<button name="decision" value="confirm" type="submit">确认退出</button>'
'</form>'
),
redirect_origin=redirect_origin,
)
def _local_response(_request: Request) -> HTMLResponse:
"""
构造不回显退出参数的同源完成页
:param _request: 当前 HTTP 请求
:return: 同源退出完成页
"""
return _logout_page('已退出', '<p>此次登录已结束,可以关闭此页面。</p>')
async def _logout_parameters(request: Request) -> dict[str, str] | None:
"""
读取并校验 GET Query 或 POST Form 退出参数
:param request: 当前 HTTP 请求
:return: 通过校验的退出参数,校验失败时返回 None
"""
if request.method == 'POST':
try:
values = await read_form(request)
except HTTPException:
return None
return values if _valid_logout_parameters(values.items()) else None
seen: set[str] = set()
for key, value in request.query_params.multi_items():
if key not in _LOGOUT_QUERY_FIELDS or key in seen or len(value) > _LOGOUT_QUERY_LIMITS[key]:
return None
seen.add(key)
return dict(request.query_params.multi_items())
def _valid_logout_parameters(values: Iterable[tuple[str, str]]) -> bool:
"""
校验退出参数的字段白名单和长度
:param values: 待校验的退出参数
:return: 参数是否全部通过校验
"""
return all(key in _LOGOUT_QUERY_FIELDS and len(value) <= _LOGOUT_QUERY_LIMITS[key] for key, value in values)
@authorization_controller.api_route(
'/oauth2/authorize',
methods=['GET', 'POST'],
summary='OAuth 授权接口',
description='用于处理 OAuth 授权请求并返回认证交互或客户端回调',
include_in_schema=False,
)
async def authorize(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
) -> Response:
if not OidcConfig.oidc_enabled:
return _oidc_not_found_response()
if request.method == 'POST':
if request.query_params:
raise OAuthProtocolException('invalid_request', 'Authorization parameters must be in the form body')
try:
raw = await read_form(request)
except HTTPException as exc:
raise OAuthProtocolException('invalid_request', 'Invalid authorization form', exc.status_code) from exc
else:
raw = _read_authorization_query(request)
redis = _authorization_redis(request)
try:
await _enforce_authorization_rate_limit(request, redis)
except OAuthProtocolException as exc:
await _record_authorization_failure_audit(query_db, client_id=raw.get('client_id'), failure_code=exc.error)
raise
try:
result = await AuthorizationService.process_authorization_request(
query_db,
redis,
raw,
sso_cookie=request.cookies.get(OidcConfig.oidc_sso_cookie_name),
)
except OAuthProtocolException as exc:
if exc.error == 'not_found' and not exc.can_redirect:
return _oidc_not_found_response()
raise
return RedirectResponse(result.location, status_code=result.status_code, headers=_NO_STORE)
@authorization_controller.api_route(
'/oauth2/logout',
methods=['GET', 'POST'],
summary='退出认证中心接口',
description='用于退出认证中心登录',
include_in_schema=False,
)
async def logout(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
) -> Response:
if not OidcConfig.oidc_enabled:
return _oidc_not_found_response()
parameters = await _logout_parameters(request)
if parameters is None:
return JSONResponse(content={'error': 'invalid_request'}, status_code=400, headers=_NO_STORE)
redis = getattr(request.app.state, 'redis', None)
try:
if redis is None:
raise LogoutServiceError('退出服务暂不可用')
address = ClientIPUtil.get_client_ip(request)
rate_scope = OidcUtil.logout_rate_scope(address, getattr(OidcConfig, 'oidc_token_hash_pepper', ''))
await OidcRateLimiter.enforce(redis, OidcRedisKey.logout_rate_limit(rate_scope), limit=60, window_seconds=60)
token, nonce = await LogoutConfirmationService.issue(
redis, parameters, request.cookies.get(OidcConfig.oidc_sso_cookie_name)
)
redirect_origin = await LogoutConfirmationService.form_redirect_origin(query_db, parameters)
response = _confirmation_response(token, redirect_origin)
response.set_cookie(
LogoutConfirmationService.COOKIE_NAME,
nonce,
max_age=LogoutConfirmationService.TTL_SECONDS,
path='/',
secure=True,
httponly=True,
samesite='strict',
)
return response
except RateLimitExceeded as exc:
headers = dict(_NO_STORE)
headers['Retry-After'] = str(exc.retry_after)
return JSONResponse(content={'error': 'temporarily_unavailable'}, status_code=429, headers=headers)
except RateLimitUnavailable:
return JSONResponse(content={'error': 'temporarily_unavailable'}, status_code=503, headers=_NO_STORE)
except Exception:
return JSONResponse(content={'error': 'temporarily_unavailable'}, status_code=503, headers=_NO_STORE)
@authorization_controller.post(
'/oauth2/logout/confirm',
summary='确认退出认证中心接口',
description='用于确认或取消当前浏览器的认证中心退出请求',
include_in_schema=False,
)
async def confirm_logout(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
) -> Response:
if not OidcConfig.oidc_enabled:
return _oidc_not_found_response()
issuer = urlsplit(OidcConfig.oidc_issuer)
if request.headers.get('origin') != f'{issuer.scheme}://{issuer.netloc}':
return JSONResponse({'error': 'invalid_request'}, status_code=403, headers=_NO_STORE)
try:
form = await read_form(request)
if set(form) != {'confirmation', 'decision'} or form['decision'] not in {'confirm', 'cancel'}:
raise ValueError('确认信息无效')
redis = request.app.state.redis
parameters = await LogoutConfirmationService.consume(
redis,
form['confirmation'],
request.cookies.get(OidcConfig.oidc_sso_cookie_name),
request.cookies.get(LogoutConfirmationService.COOKIE_NAME),
)
except (HTTPException, ValueError, KeyError, TypeError):
return JSONResponse({'error': 'invalid_request'}, status_code=400, headers=_NO_STORE)
except Exception:
return JSONResponse({'error': 'temporarily_unavailable'}, status_code=503, headers=_NO_STORE)
if form['decision'] == 'cancel':
response = _logout_page('已取消退出', '<p>登录状态保持不变,可以关闭此页面。</p>')
else:
try:
result = await LogoutService.execute_logout(
query_db,
redis,
confirmed=True,
id_token_hint=parameters.get('id_token_hint'),
cookie=request.cookies.get(OidcConfig.oidc_sso_cookie_name),
post_logout_redirect_uri=parameters.get('post_logout_redirect_uri'),
state=parameters.get('state'),
)
except Exception:
return JSONResponse({'error': 'temporarily_unavailable'}, status_code=503, headers=_NO_STORE)
response = (
_local_response(request)
if result.redirect_uri is None
else RedirectResponse(
OidcUtil.append_state(result.redirect_uri, result.state), status_code=303, headers=_NO_STORE
)
)
response.delete_cookie(**SsoSessionService.cookie_parameters())
response.delete_cookie(
LogoutConfirmationService.COOKIE_NAME, path='/', secure=True, httponly=True, samesite='strict'
)
return response