Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/utils/oidc_util.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

1366 lines
55 KiB
Python

import base64
import binascii
import hashlib
import hmac
import ipaddress
import json
import math
import re
import secrets
from collections.abc import Collection, Iterable, Mapping, Sequence
from datetime import datetime
from typing import Any
from urllib.parse import parse_qsl, unquote_plus, urlencode, urlsplit, urlunsplit
from uuid import RFC_4122, UUID
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey, RSAPublicNumbers
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
class OidcUtil:
"""
统一认证公共工具类。
"""
_PROTOCOL_DESCRIPTION_PATTERN = re.compile(r'[\x20-\x21\x23-\x5B\x5D-\x7E]+')
_ERROR_MESSAGES = {
'invalid_request': '认证请求无效',
'invalid_client': '客户端认证失败',
'invalid_grant': '授权凭据无效或已过期',
'unauthorized_client': '客户端无权使用当前授权方式',
'unsupported_grant_type': '不支持当前授权类型',
'invalid_scope': '请求的权限范围无效',
'invalid_target': '请求的资源受众无效',
'unsupported_response_type': '不支持当前授权响应类型',
'unsupported_response_mode': '不支持当前授权响应模式',
'access_denied': '授权请求被拒绝',
'server_error': '认证服务处理失败',
'temporarily_unavailable': '认证服务暂不可用',
'invalid_token': '访问令牌无效',
'insufficient_scope': '访问令牌的权限不足',
'interaction_required': '需要重新完成认证交互',
'login_required': '需要登录后继续',
'consent_required': '需要用户确认授权',
'account_selection_required': '需要选择登录账号',
'not_found': '请求的认证资源不存在',
}
_DEFAULT_PROTOCOL_DESCRIPTIONS = {
'invalid_request': 'Invalid request',
'invalid_client': 'Client authentication failed',
'invalid_grant': 'The authorization grant is invalid or expired',
'unauthorized_client': 'Client is not authorized',
'unsupported_grant_type': 'Grant type is not supported',
'invalid_scope': 'Requested scope is invalid',
'invalid_target': 'Requested resource is invalid',
'unsupported_response_type': 'Response type is not supported',
'unsupported_response_mode': 'Response mode is not supported',
'access_denied': 'Access is denied',
'server_error': 'Authorization service is unavailable',
'temporarily_unavailable': 'Authorization service is temporarily unavailable',
'invalid_token': 'Invalid access token',
'insufficient_scope': 'Access token scope is insufficient',
'interaction_required': 'Interaction is required',
'login_required': 'Login is required',
'consent_required': 'Consent is required',
'account_selection_required': 'Account selection is required',
'not_found': 'Resource is unavailable',
}
_DESCRIPTION_MESSAGES = {
'A current login is required': '需要有效的当前登录状态',
'A new authorization decision is required': '需要重新确认授权',
'A resource audience is required for resource scope': '申请资源权限时必须指定资源受众',
'A transaction coordinator is required': '缺少事务协调器',
'Access to this application is blocked': '当前用户已被禁止访问此应用',
'Application is unavailable; restart login': '应用已不可用,请返回应用重新登录',
'Application permissions have changed; restart login': '应用权限已变更,请返回应用重新登录',
'Authentication audit service is unavailable': '认证审计服务不可用',
'Authentication service is unavailable': '认证服务不可用',
'Authorization audit service is unavailable': '授权审计服务暂不可用',
'Authorization code could not be completed': '授权码兑换未能完成',
'Authorization code could not be created': '授权码创建失败',
'Authorization code is invalid': '授权码无效',
'Authorization code is invalid or expired': '授权码无效或已过期',
'Authorization code is not allowed for this client': '当前客户端未启用授权码模式',
'Authorization code state is invalid': '授权码状态无效',
'Authorization completion failed': '完成授权失败',
'Authorization parameters must be in the form body': '授权参数必须全部放在表单请求体中',
'Authorization server is not ready': '认证中心尚未就绪',
'Authorization service is unavailable': '授权服务暂不可用',
'CSRF validation failed': 'CSRF 校验失败,请重新发起认证',
'Client PKCE policy is weaker than provider policy': '客户端 PKCE 配置不符合认证中心的安全要求',
'Client authentication failed': '客户端认证失败',
'Client is inactive': '客户端已停用或不可用',
'Client is not registered': '客户端未注册或已停用',
'Consent is required': '需要用户确认授权',
'Current login could not be verified': '无法验证当前登录状态',
'Duplicate authorization parameter': '授权请求包含重复参数',
'Interaction TTL is invalid': '认证交互有效期无效',
'Interaction cache callback failed': '认证交互缓存更新回调执行失败',
'Interaction could not be created': '认证交互创建失败',
'Interaction has already completed': '认证交互已完成,请勿重复提交',
'Interaction is invalid or expired': '认证交互无效或已过期',
'Interaction is missing or expired': '认证交互不存在或已过期',
'Interaction is no longer active': '认证交互已失效,请重新发起认证',
'Interaction is not complete': '认证交互尚未完成',
'Interaction state has changed': '认证交互状态已变更,请刷新后重试',
'Interaction state is invalid': '认证交互状态无效',
'Interaction transition failed': '认证交互状态更新失败',
'Invalid authorization form': '授权请求表单无效',
'Invalid authorization request': '授权请求无效',
'Invalid interaction request': '认证交互请求无效',
'Invalid request': '请求参数无效',
'Invalid token request': '令牌请求无效',
'Invalid validated redirect URI': '已验证的回调地址无效',
'Login is required': '需要登录后继续',
'OIDC provider is disabled': '统一认证服务未启用',
'Only PKCE S256 is supported': '仅支持 PKCE S256 校验方式',
'Only authorization code is supported': '仅支持授权码响应类型 code',
'OpenID scope requires the sub claim': 'openid 权限必须包含用户主体声明 sub',
'PKCE S256 code_challenge must contain 43 characters': 'PKCE S256 的 code_challenge 必须为 43 个字符',
'Requested resource is not allowed for this client': '当前客户端无权访问所请求的资源',
'Requested scope does not belong to the selected resource': '请求的权限范围不属于所选资源',
'Requested scope is not allowed for this client': '当前客户端不允许申请所请求的权限范围',
'Requested scope is not authorized': '请求的权限范围尚未获准',
'Required scope cannot be removed': '必需的权限范围不能取消',
'Resource is unavailable': '资源不可用',
'Resource policy has changed': '资源访问策略已变更,请重新授权',
'Response mode is not supported': '不支持当前授权响应模式',
'Scope policy has changed': '权限范围策略已变更,请重新授权',
'Submitted scope is not allowed': '提交的权限范围不在允许列表内',
'The authorization grant is invalid or expired': '授权凭据无效或已过期',
'The authorization grant is no longer valid': '原授权已失效,请重新授权',
'Token endpoint is unavailable': '令牌服务暂不可用',
'Token issuance is unavailable': '令牌签发服务暂不可用',
'Token policy is unavailable': '令牌策略不可用',
'Too many authorization requests': '授权请求过于频繁,请稍后重试',
'User denied the authorization request': '用户已拒绝授权请求',
'User identity mapping is unavailable': '用户身份映射暂不可用',
'User identity version could not be updated': '用户身份安全版本更新失败',
'Validated redirect URI is invalid': '已校验的回调地址无效',
'Validated redirect URI is unavailable': '已校验的回调地址不可用',
'client_id and redirect_uri are required': '必须提供 client_id 和 redirect_uri',
'max_age must be non-negative': 'max_age 不得为负数',
'nonce is required for OpenID Connect authorization': 'OpenID Connect 授权请求必须提供 nonce',
'openid scope is required': '授权请求必须包含 openid 权限',
'prompt contains an unsupported combination': 'prompt 包含不支持的参数组合',
'redirect_uri is not registered': '登录回调地址 redirect_uri 未注册',
}
_PROTOCOL_DESCRIPTIONS = {message: description for description, message in _DESCRIPTION_MESSAGES.items()}
_SENSITIVE_KEY = re.compile(
r'(?:token|code|secret|cookie|password|passwd|credential|authorization|private[_-]?key|'
r'pkce|verifier|nonce|client[_-]?assertion|assertion|access[_-]?token|refresh[_-]?token|'
r'id[_-]?token|state)',
re.IGNORECASE,
)
_SENSITIVE_VALUE = re.compile(
r'(?i)(?:bearer\s+|basic\s+|(?:access|refresh|id)?[_-]?(?:token|secret|code|cookie|password|verifier)\s*[=:])'
)
_OPAQUE_CAPABILITY = re.compile(
r'(?i)(?<![A-Za-z0-9_-])(?:'
r'(?:rt1|ac1|ss1)\.[^.\s]{1,256}\.[^.\s]{1,4096}|'
r'cs1\.[^.\s]{16,4096}(?:\.[^.\s]{1,256})?'
r')(?![A-Za-z0-9_-])'
)
_JWT_PART_COUNT = 3
_JWT_PART_MIN_LENGTH = 16
_JWT_PART_MAX_LENGTH = 4096
_COMPONENT_PATTERN = re.compile(r'^[A-Za-z0-9._-]{1,128}$')
_SHA256_HEX_LENGTH = 64
_ENCRYPTION_KDF_ITERATIONS = 310_000
_SAFE_KID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$')
_MIN_PEPPER_BYTES = 32
_MAX_URI_LENGTH = 1000
_DEVELOPMENT_HTTP_HOSTS = frozenset({'localhost', '127.0.0.1', '::1'})
_USERINFO_AUDIENCE_SUFFIX = '/oauth2/userinfo'
_ASCII_CONTROL_LIMIT = 0x20
_MAX_USER_AGENT_LENGTH = 500
_COOKIE_PARTS = 3
_COOKIE_PREFIX = 'ss1'
_COOKIE_SECRET_TEXT_LENGTH = 43
_COOKIE_SECRET_BYTES = 32
_S256_CHALLENGE = re.compile(r'[A-Za-z0-9_-]{43}')
# 异常诊断与协议文案
@classmethod
def localized_oauth_message(cls, error: str, description: str | None) -> str:
"""
返回中文异常诊断;未知协议描述按错误码提供安全的中文说明。
:param error: OAuth 错误码
:param description: 原始协议描述或中文诊断
:return: 中文异常说明
"""
if description:
translated = cls._DESCRIPTION_MESSAGES.get(description)
if translated is not None:
return translated
if any('\u4e00' <= char <= '\u9fff' for char in description):
return description
return cls._ERROR_MESSAGES.get(error, '认证请求处理失败')
@classmethod
def protocol_error_description(cls, error: str, description: str | None) -> str | None:
"""
保留合法 ASCII 协议描述,将中文或不安全字符转换为协议允许的说明。
:param error: OAuth 错误码
:param description: 原始异常说明
:return: 符合 OAuth 字符约束的描述或 None
"""
if not description:
return None
if cls._PROTOCOL_DESCRIPTION_PATTERN.fullmatch(description):
return description
return cls._PROTOCOL_DESCRIPTIONS.get(
description, cls._DEFAULT_PROTOCOL_DESCRIPTIONS.get(error, 'Authentication request failed')
)
# 基础数据、批量参数与认证声明
@staticmethod
def read_field(user: Any, name: str, default: Any = None) -> Any:
"""
从 ORM 用户对象或字典读取字段
:param user: SysUser ORM 记录或用户 Claim 字段映射
:param name: 字段名称
:param default: 字段缺省值
:return: 读取的字段值
"""
if isinstance(user, Mapping):
return user.get(name, default)
return getattr(user, name, default)
@staticmethod
def positive_int(value: Any, field: str) -> int:
"""
校验正整数标量
:param value: 待校验的正整数
:param field: 字段名称
:return: 校验后的正整数
"""
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
raise ValueError(f'{field} 必须为正整数')
return value
@staticmethod
def nonnegative_int(value: Any, field: str) -> int:
"""
校验非负整数标量
:param value: 待校验的非负整数
:param field: 字段名称
:return: 校验后的非负整数
"""
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise ValueError(f'{field} 必须为非负整数')
return value
@staticmethod
def nonempty_string(value: Any, field: str, limit: int) -> str:
"""
校验非空字符串及长度
:param value: 待校验的字符串
:param field: 字段名称
:param limit: 字符串的最大长度
:return: 校验后的字符串
"""
if not isinstance(value, str) or not value or len(value) > limit:
raise ValueError(f'{field} 必须为非空字符串')
return value
@classmethod
def string_list(cls, value: Any, field: str, limit: int) -> list[str]:
"""
校验字符串列表并复制为 JSON 安全列表
:param value: 待校验的字符串列表
:param field: 字段名称
:param limit: 字符串列表的最大长度
:return: 校验后的字符串列表
"""
if not isinstance(value, (list, tuple)) or len(value) > limit:
raise ValueError(f'{field} 必须为符合长度限制的字符串列表')
return [cls.nonempty_string(item, field, 500) for item in value]
@staticmethod
def json_list(value: Any) -> list[Any]:
"""
将 Scope 或 Resource 字段规范化为列表
:param value: JSON 编码的 Scope 或 Resource 列表值
:return: Scope 或 Resource 元素列表;输入不是 JSON 数组时返回空列表
"""
return list(value) if isinstance(value, (list, tuple)) else []
@staticmethod
def json_object_pairs(items: list[tuple[str, object]]) -> dict[str, object]:
"""
构造拒绝重复字段的 JSON 对象
:param items: JSON 对象字段
:return: 唯一字段组成的对象
:raises ValueError: JSON 对象包含重复字段
"""
result: dict[str, object] = {}
for key, value in items:
# 拒绝重复字段,避免不同解析器产生歧义
if key in result:
raise ValueError('JSON 请求体包含重复字段')
result[key] = value
return result
@staticmethod
def serialize_json(record: Mapping[str, Any], *, error_message: str) -> str:
"""
生成稳定紧凑的 JSON,并拒绝不可序列化对象和非有限数字。
:param record: 需要缓存的标量载荷
:param error_message: 调用场景的中文错误说明
:return: 按键排序的 JSON 字符串
"""
try:
return json.dumps(record, ensure_ascii=False, separators=(',', ':'), sort_keys=True, allow_nan=False)
except (TypeError, ValueError) as exc:
raise ValueError(error_message) from exc
@staticmethod
def json_etag(payload: Mapping[str, Any]) -> str:
"""
为 JWKS 响应计算稳定的强 ETag
:param payload: 公开响应 JSON 映射
:return: 带双引号的 SHA-256 ETag
"""
canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()
return '"' + hashlib.sha256(canonical).hexdigest() + '"'
@staticmethod
def actor_name(value: Any, *, error_message: str = '操作者不能为空') -> str:
"""
校验操作者名称并按管理字段长度截断,保留原始空白语义。
:param value: 待校验的操作者名称
:param error_message: 名称不可用时的中文说明
:return: 最多 64 个字符的操作者名称
"""
if not isinstance(value, str) or not value.strip():
raise ValueError(error_message)
return value[:64]
@staticmethod
def split_batch(value: str, field_name: str, *, max_size: int, validate_all_first: bool = False) -> list[str]:
"""
拆分批量路径标识,拒绝空值、路径字符、空白和重复项。
:param value: 逗号分隔的参数
:param field_name: 错误说明中的字段名称
:param max_size: 允许的最大项目数量
:param validate_all_first: 是否先校验全部格式再检查重复,保留调用场景的报错顺序
:return: 去除首尾空白后的标识列表
"""
items = value.split(',') if isinstance(value, str) else []
if not items or len(items) > max_size:
raise ValueError(f'{field_name} 参数无效')
values = [item.strip() for item in items]
def invalid(item: str) -> bool:
return not item or '%' in item or '/' in item or '\\' in item or any(char.isspace() for char in item)
if validate_all_first and any(invalid(item) for item in values):
raise ValueError(f'{field_name} 参数无效')
result: list[str] = []
for item in values:
if invalid(item):
raise ValueError(f'{field_name} 参数无效')
if item in result:
raise ValueError(f'{field_name} 参数重复')
result.append(item)
return result
@staticmethod
def unique_codes(values: Iterable[str], field_name: str) -> list[str]:
"""
校验权限或资源编码的边界空白,并保持输入顺序和重复项错误。
:param values: 待校验的编码集合
:param field_name: 中文错误说明中的字段名称
:return: 保持原序的编码列表
"""
result: list[str] = []
for value in values:
if not isinstance(value, str) or not value.strip() or value.strip() != value:
raise ValueError(f'{field_name} 包含无效的标识')
if value in result:
raise ValueError(f'{field_name} 不得包含重复项')
result.append(value)
return result
@staticmethod
def normalize_user_ids(values: Iterable[int], *, max_size: int) -> tuple[int, ...]:
"""
校验、去重并排序用户 ID,确保批量锁顺序稳定
:param values: 待校验的用户 ID 可迭代集合
:param max_size: 去重后允许的最大用户数量
:return: 排序去重后的用户 ID 元组
"""
if isinstance(values, (str, bytes)):
raise ValueError('用户编号列表必须全部为整数')
result = tuple(sorted(set(values)))
if not result or len(result) > max_size:
raise ValueError('用户编号列表数量无效')
if any(not isinstance(item, int) or isinstance(item, bool) or item <= 0 for item in result):
raise ValueError('用户编号列表必须全部为正整数')
return result
@staticmethod
def is_trimmed_identifier(value: str, *, max_length: int) -> bool:
"""
检查标识长度及首尾空白,保留内部字符。
:param value: 待校验的 Session ID
:param max_length: 标识最大长度
:return: Session ID 是否有效
"""
return isinstance(value, str) and 1 <= len(value) <= max_length and value.strip() == value
@staticmethod
def scope_set(scopes: str | Iterable[str] | None) -> set[str]:
"""
将空格分隔或集合形式的 Scope 规范化
:param scopes: 请求的 Scope 集合
:return: 规范化 Scope 集合
"""
if scopes is None:
return set()
if isinstance(scopes, str):
return {item for item in scopes.split() if item}
return {item for item in scopes if isinstance(item, str) and item}
@staticmethod
def normalize_audiences(value: Any) -> list[str]:
"""
将 aud 声明规范化为去重列表
:param value: OAuth Token 的 Audience 声明值
:return: 去重后的 Audience 列表;格式非法时返回空列表
"""
values = [value] if isinstance(value, str) else value
if not isinstance(values, list) or not values or any(not isinstance(item, str) or not item for item in values):
return []
return list(dict.fromkeys(values))
@staticmethod
def token_audiences(issuer: str, resources: Sequence[str]) -> list[str]:
"""
将 aud 声明规范化为去重列表
:param issuer: OIDC issuer URL
:param resources: 已校验的 Resource audience 字符串序列
:return: 包含 userinfo audience 和 Resource audience 的去重列表
"""
result = [f'{issuer.rstrip("/")}{OidcUtil._USERINFO_AUDIENCE_SUFFIX}']
result.extend(resources)
return list(dict.fromkeys(result))
@staticmethod
def claim_numeric_date(value: Any) -> int | float | None:
"""
将用户更新时间转换为 NumericDate
:param value: 用户更新时间
:return: NumericDate 时间戳或 None
"""
if value is None or isinstance(value, (int, float)):
return value
timestamp = getattr(value, 'timestamp', None)
return int(timestamp()) if callable(timestamp) else None
@staticmethod
def numeric_date(value: datetime | None) -> int | None:
"""
将 datetime 转换为有限的 Unix 时间戳
:param value: 可选的 Token 签发时间或过期时间
:return: 有限的 Unix 时间戳;输入为空或时间戳非有限时返回 None
"""
if value is None:
return None
timestamp = value.timestamp()
return int(timestamp) if math.isfinite(timestamp) else None
@staticmethod
def requires_reauthentication(auth_time: datetime, max_age: int | None, now: datetime | None = None) -> bool:
"""
纯函数判断 SSO 认证是否超过 max_age
:param auth_time: SSO 认证时间
:param max_age: 请求的最大认证年龄
:param now: 可选当前时间
:return: 超过 max_age 时为 True
"""
# 时间工具依赖基础异常,延迟导入避免异常模块初始化时形成循环。
from utils.time_util import TimezoneUtil # noqa: PLC0415
if max_age is None:
return False
if not isinstance(max_age, int) or isinstance(max_age, bool) or max_age < 0:
raise ValueError('max_age 必须为非负整数')
current = TimezoneUtil.to_utc(now) if now is not None else TimezoneUtil.utc_now()
auth = TimezoneUtil.to_utc(auth_time)
return current.timestamp() - auth.timestamp() > max_age
# URI、路径标识与交互参数
@staticmethod
def validate_path_identifier(value: str, field_name: str) -> str:
"""
校验可安全放入单一路径段的管理标识
:param value: 待校验的资源或权限标识
:param field_name: 校验失败时展示的字段名称
:return: 校验通过的管理标识
"""
if (
not isinstance(value, str)
or not value
or not value[0].isalnum()
or any(not (char.isascii() and (char.isalnum() or char in '._:-')) for char in value)
):
raise ValueError(f'{field_name} 必须符合路径标识的字符要求')
return value
@classmethod
def is_valid_kid(cls, value: Any) -> bool:
"""
检查签名密钥标识的字符集和长度。
:param value: 待检查的标识
:return: 是否满足原有安全路径规则
"""
return isinstance(value, str) and cls._SAFE_KID_PATTERN.fullmatch(value) is not None
@staticmethod
def validate_registered_uri(uri_type: str, value: str) -> str:
"""
校验注册 URI
:param uri_type: 注册地址类型
:param value: 待校验的完整注册地址
:return: 校验通过的原始注册地址
"""
if '*' in value:
raise ValueError('地址不得包含通配符')
parsed = urlsplit(value)
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
raise ValueError('地址必须使用 HTTP 或 HTTPS 协议并包含主机名')
if parsed.username is not None or parsed.password is not None:
raise ValueError('地址不得包含用户名或密码')
if parsed.fragment:
raise ValueError('地址不得包含片段标识 fragment')
if uri_type == 'backchannel_logout' and parsed.query:
raise ValueError('后端退出通知地址不得包含查询参数')
try:
hostname = parsed.hostname
_ = parsed.port
except ValueError as exc:
raise ValueError('地址中的主机名或端口无效') from exc
if not hostname:
raise ValueError('地址必须包含主机名')
if parsed.scheme == 'http' and hostname.lower() not in OidcUtil._DEVELOPMENT_HTTP_HOSTS:
raise ValueError('HTTP 地址仅允许用于本机开发环境')
if uri_type == 'cors_origin' and (parsed.path or parsed.query):
raise ValueError('跨域来源只能包含协议、主机名和可选端口')
return value
@staticmethod
def validate_resource_audience(audience: str, *, max_length: int) -> str:
"""
校验 Resource audience URI
:param audience: Resource audience
:param max_length: URI 最大长度
:return: 校验后的 Resource audience URI
:raises ValueError: audience 不是无用户信息的绝对 HTTPS URI 时抛出
"""
if not isinstance(audience, str) or not audience or len(audience) > max_length:
raise ValueError('资源受众必须为非空地址')
parsed = urlsplit(audience)
if parsed.scheme != 'https' or not parsed.netloc or parsed.fragment:
raise ValueError('资源受众必须是无片段标识的绝对 HTTPS 地址')
if parsed.username is not None or parsed.password is not None:
raise ValueError('资源受众地址不得包含用户名或密码')
try:
hostname = parsed.hostname
_ = parsed.port
except ValueError as exc:
raise ValueError('资源受众地址的主机名或端口无效') from exc
if not hostname:
raise ValueError('资源受众地址必须包含主机名')
return audience
@staticmethod
def is_safe_post_logout_uri(uri: Any) -> bool:
"""
验证退出后重定向 URI 的安全格式
:param uri: 回调 URI
:return: URI 是否满足退出后重定向安全约束
"""
if not isinstance(uri, str) or not uri or len(uri) > OidcUtil._MAX_URI_LENGTH or '*' in uri:
return False
try:
parsed = urlsplit(uri)
hostname = parsed.hostname
_ = parsed.port
except ValueError:
return False
return bool(
parsed.scheme in {'http', 'https'}
and hostname
and parsed.netloc
and (parsed.scheme == 'https' or hostname.lower() in OidcUtil._DEVELOPMENT_HTTP_HOSTS)
and not parsed.username
and not parsed.password
and not parsed.fragment
)
@staticmethod
def is_public_ip(value: str) -> bool:
"""
判断地址是否不属于回环、内网、链路本地或保留网段
:param value: IPv4 或 IPv6 文本地址
:return: 仅当地址可作为公网目标时返回 ``True``
"""
try:
address = ipaddress.ip_address(value)
except ValueError:
return False
return not (
address.is_private
or address.is_loopback
or address.is_link_local
or address.is_reserved
or address.is_multicast
or address.is_unspecified
)
@staticmethod
def parse_backchannel_uri(uri: object) -> tuple[str, int] | None:
"""
解析并校验 Back-Channel URI 的非网络安全边界
:param uri: 待校验的 URI
:return: 合法时返回主机名和端口,否则返回 ``None``
"""
if not isinstance(uri, str) or not uri or len(uri) > OidcUtil._MAX_URI_LENGTH or '*' in uri:
return None
try:
parsed = urlsplit(uri)
hostname = parsed.hostname
username = parsed.username
password = parsed.password
port = parsed.port
except ValueError:
return None
if (
parsed.scheme != 'https'
or not parsed.netloc
or not hostname
or username
or password
or parsed.query
or parsed.fragment
):
return None
try:
address = ipaddress.ip_address(hostname)
except ValueError:
address = None
if address is not None and not OidcUtil.is_public_ip(hostname):
return None
return hostname, port or 443
@staticmethod
def replace_query_parameters(
uri: str,
parameters: Sequence[tuple[str, str]],
replaced_fields: Collection[str],
*,
fragment: str | None = None,
doseq: bool = False,
) -> str:
"""
替换指定查询参数,同时保留其他参数的顺序、重复项和空值。
此方法只负责 URL 编码,调用方必须先完成回调注册和地址安全校验。
:param uri: 原始地址
:param parameters: 按顺序追加的参数
:param replaced_fields: 必须从原地址移除的参数名称
:param fragment: 显式指定片段,None 表示保留原片段
:param doseq: 是否展开参数中的序列值
:return: 重新编码后的地址
"""
parsed = urlsplit(uri)
query = [
(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key not in replaced_fields
]
query.extend(parameters)
return urlunsplit(
(
parsed.scheme,
parsed.netloc,
parsed.path,
urlencode(query, doseq=doseq),
parsed.fragment if fragment is None else fragment,
)
)
@staticmethod
def append_state(redirect_uri: str, state: str | None) -> str:
"""
为已验证的退出回调地址附加状态参数
:param redirect_uri: 服务端已验证的退出回调地址
:param state: 原始退出状态参数
:return: 附加状态参数后的回调地址
"""
if state is None:
return redirect_uri
return OidcUtil.replace_query_parameters(redirect_uri, [('state', state)], {'state'})
@staticmethod
def interaction_url(base: str, interaction_id: str, csrf_token: str) -> str:
"""
构建只把原始 CSRF 放入 URL Fragment 的交互地址
:param base: 交互地址基址
:param interaction_id: 交互流程标识
:param csrf_token: CSRF Token
:return: 交互页面 URL
"""
parsed = urlsplit(base)
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
query['interaction'] = interaction_id
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), f'csrf={csrf_token}'))
@staticmethod
def normalize_prompt(value: str | None) -> str | None:
"""
校验并规范化认证交互提示,拒绝重复值和非法组合。
:param value: 空格分隔的 prompt 参数
:return: 保持原顺序的规范化参数或 None
"""
if value is None:
return None
prompts = value.split()
if (
not prompts
or len(set(prompts)) != len(prompts)
or any(item not in {'login', 'consent', 'none'} for item in prompts)
or ('none' in prompts and len(prompts) > 1)
):
raise ValueError('prompt 只能包含支持的值,且 none 不能与其他值组合')
return ' '.join(prompts)
@classmethod
def is_s256_challenge(cls, value: Any) -> bool:
"""
检查无填充 Base64URL 格式的 SHA-256 PKCE 挑战值。
:param value: 待检查的挑战值
:return: 是否恰好包含 43 个允许的 ASCII 字符
"""
return isinstance(value, str) and cls._S256_CHALLENGE.fullmatch(value) is not None
# 编码、摘要与凭据
@staticmethod
def base64url_encode(value: bytes) -> str:
"""
将字节编码为无填充的 Base64URL 文本
:param value: 待编码字节
:return: 无填充的 Base64URL 文本
"""
return base64.urlsafe_b64encode(value).rstrip(b'=').decode('ascii')
@staticmethod
def sha256_digest(value: str | bytes) -> str:
"""
计算原始内容的 SHA-256 十六进制摘要,不规范化或截断输入。
:param value: URI、一次性凭据等原始内容
:return: 小写十六进制摘要
"""
raw = value.encode('utf-8') if isinstance(value, str) else value
return hashlib.sha256(raw).hexdigest()
@staticmethod
def sha256_hex_digest(value: str | bytes) -> str:
"""
校验已生成的 SHA-256 十六进制摘要
:param value: 摘要文本或 ASCII 字节
:return: 小写十六进制摘要
:raises TypeError: 输入不是 str 或 bytes
:raises ValueError: 摘要长度或字符集不正确
"""
if not isinstance(value, (str, bytes)):
raise TypeError('摘要必须为字符串或字节数据')
if isinstance(value, bytes):
try:
value = value.decode('ascii')
except UnicodeDecodeError as exc:
raise ValueError('摘要必须是 SHA-256 十六进制字符串') from exc
if len(value) != OidcUtil._SHA256_HEX_LENGTH:
raise ValueError('摘要必须是 SHA-256 十六进制字符串')
try:
bytes.fromhex(value)
except ValueError as exc:
raise ValueError('摘要必须是 SHA-256 十六进制字符串') from exc
return value.lower()
@staticmethod
def hmac_sha256(value: str | bytes, key: str | bytes) -> str:
"""
对已由调用方校验的内容和密钥计算 HMAC-SHA256 摘要。
:param value: 原始字符串或字节数据
:param key: 原始字符串或字节形式的密钥
:return: 小写十六进制摘要
"""
raw_value = value.encode('utf-8') if isinstance(value, str) else value
raw_key = key.encode('utf-8') if isinstance(key, str) else key
return hmac.new(raw_key, raw_value, hashlib.sha256).hexdigest()
@staticmethod
def access_token_hash(access_token: str) -> str:
"""
计算 OIDC at_hash 值
:param access_token: 待计算 OIDC at_hash 的 ASCII JWT Access Token 文本
:return: SHA-256 前 128 bit 摘要的无填充 Base64URL 字符串
"""
digest = hashlib.sha256(access_token.encode('ascii')).digest()[:16]
return base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
@staticmethod
def csrf_digest(token: str, pepper: str) -> str:
"""
使用独立 Pepper 生成 CSRF HMAC 摘要
:param token: 原始 CSRF Token
:param pepper: Token 摘要 Pepper
:return: CSRF 摘要
"""
if (
not isinstance(token, str)
or not isinstance(pepper, str)
or len(pepper.encode()) < OidcUtil._MIN_PEPPER_BYTES
):
raise ValueError('CSRF 摘要密钥至少需要 32 字节')
return OidcUtil.hmac_sha256(token, pepper)
@classmethod
def credential_proof(
cls, interaction_id: str, user_id: int, subject_id: str, auth_version: int, *, pepper: str
) -> str:
"""
生成绑定交互、用户、主体和安全版本的凭据证明摘要。
:param interaction_id: 交互标识
:param user_id: 本地用户编号
:param subject_id: 稳定身份主体标识
:param auth_version: 身份安全版本
:param pepper: 调用方提供的认证摘要密钥
:return: 凭据证明摘要
"""
return cls.hmac_sha256(f'{interaction_id}:{user_id}:{subject_id}:{auth_version}', pepper)
@classmethod
def logout_confirmation_digest(cls, sso_cookie: str | None, browser_nonce: str, pepper: str | bytes) -> str:
"""
计算退出确认凭据的浏览器绑定摘要。
:param sso_cookie: 首次退出请求携带的会话 Cookie
:param browser_nonce: 绑定浏览器的一次性随机数
:param pepper: 调用方提供的认证摘要密钥
:return: 浏览器绑定摘要
"""
return cls.hmac_sha256((sso_cookie or '') + '\\0' + browser_nonce, pepper)
@classmethod
def logout_rate_scope(cls, address: Any, pepper: str | bytes) -> str:
"""
生成退出限流主体摘要,在认证配置尚未就绪时使用原有固定回退值。
:param address: 客户端地址或匿名占位值
:param pepper: 调用方读取的认证摘要密钥
:return: 限流主体摘要
"""
raw_pepper = pepper.encode() if isinstance(pepper, str) else pepper
if not isinstance(raw_pepper, bytes) or len(raw_pepper) < cls._MIN_PEPPER_BYTES:
raw_pepper = hashlib.sha256(b'oidc-logout-rate-limit-fallback').digest()
return cls.hmac_sha256(str(address), raw_pepper)
@classmethod
def redis_key_component(cls, value: str | int, *, name: str = 'Redis Key 组件') -> str:
"""
校验可作为 Key 路径组件的标识符
:param value: 待校验的标识符
:param name: 错误信息中的字段名称
:return: 原样返回的安全组件
:raises ValueError: 组件为空或包含路径/控制字符
"""
component = str(value)
if not cls._COMPONENT_PATTERN.fullmatch(component):
raise ValueError(f'{name} 包含不允许的字符')
return component
@classmethod
def hash_sensitive_identifier(cls, value: str | bytes, pepper: str | bytes) -> str:
"""
使用独立 Pepper 对敏感标识生成 HMAC-SHA256 摘要
:param value: 用户名、IP 等敏感标识
:param pepper: 独立于 JWT/传输加密密钥的 Pepper,至少 32 bytes
:return: 64 位小写 HMAC-SHA256 摘要
:raises TypeError: 标识或 Pepper 类型错误
:raises ValueError: Pepper 为空或长度不足
"""
if not isinstance(value, (str, bytes)) or not isinstance(pepper, (str, bytes)):
raise TypeError('敏感标识和摘要密钥必须为字符串或字节数据')
raw_value = value.encode('utf-8') if isinstance(value, str) else value
raw_pepper = pepper.encode('utf-8') if isinstance(pepper, str) else pepper
if len(raw_pepper) < cls._MIN_PEPPER_BYTES:
raise ValueError('敏感标识摘要密钥至少需要 32 字节')
return cls.hmac_sha256(raw_value, raw_pepper)
@staticmethod
def parse_basic_credentials(authorization: str) -> tuple[str, str]:
"""
解析 RFC 7617 Basic Header,不接受请求体 Secret 回退
:param authorization: Authorization Header
:return: 解码后的 client_id 与 client_secret
:raises ValueError: Header、Base64 或凭据格式不合法
"""
if not isinstance(authorization, str) or authorization[:6].lower() != 'basic ':
raise ValueError('客户端认证信息无效')
encoded = authorization[6:].strip()
if not encoded:
raise ValueError('客户端认证信息无效')
try:
decoded = base64.b64decode(encoded, validate=True).decode('utf-8')
except (binascii.Error, UnicodeDecodeError, ValueError):
raise ValueError('客户端认证信息无效') from None
if ':' not in decoded:
raise ValueError('客户端认证信息无效')
client_id, client_secret = decoded.split(':', 1)
# OAuth 表单客户端认证使用 application/x-www-form-urlencoded 约定
# 该约定同时正确处理 client_id 中的空格与 Secret 中的百分号编码冒号
client_id, client_secret = unquote_plus(client_id), unquote_plus(client_secret)
if not client_id or not client_secret:
raise ValueError('客户端认证信息无效')
# RFC 7617 user-id 不能包含冒号,但分割后的 Secret 可以包含冒号
if any(ord(char) < OidcUtil._ASCII_CONTROL_LIMIT for char in client_id):
raise ValueError('客户端认证信息无效')
return client_id, client_secret
@staticmethod
def generate_client_id() -> str:
"""
生成带固定前缀的随机客户端标识。
:return: cli_ 前缀的客户端标识
"""
return f'cli_{secrets.token_urlsafe(24)}'
@staticmethod
def generate_client_secret() -> str:
"""
生成仅应展示一次的高熵 Client Secret
:return: 带 cs1 类型前缀的随机 Secret
"""
return f'cs1.{secrets.token_urlsafe(32)}'
@staticmethod
def client_secret_hashes(client: Any, explicit: Iterable[str] | None = None) -> list[str]:
"""
收集 Client 当前可用的 Secret 哈希
:param client: Client 映射或对象
:param explicit: 调用方显式提供的 Secret 哈希集合
:return: 规范化后的 Secret 哈希列表
"""
values = explicit
if values is None:
values = OidcUtil.read_field(client, 'secret_hashes')
if values is None:
values = OidcUtil.read_field(client, 'secrets')
if values is None:
one = OidcUtil.read_field(client, 'secret_hash')
values = [one] if one else []
result: list[str] = []
for item in values:
if isinstance(item, str):
result.append(item)
else:
value = OidcUtil.read_field(item, 'secret_hash')
if isinstance(value, str):
result.append(value)
return result
# 会话数据与审计脱敏
@staticmethod
def is_rfc4122_uuid(value: str) -> bool:
"""
判断字符串是否为规范的 RFC 4122 UUID
:param value: 待检查的 UUID 字符串
:return: 是否为规范的 RFC 4122 UUID 字符串
"""
try:
parsed = UUID(value)
except (TypeError, ValueError, AttributeError):
return False
return str(parsed) == value and parsed.variant == RFC_4122
@staticmethod
def parse_sso_cookie(cookie: str) -> tuple[str, str]:
"""
拆分并验证 SSO Cookie 中的 Session 标识和随机 Secret
:param cookie: Cookie 原文
:return: ``(sid, secret)``
:raises ValueError: Cookie 格式、UUID 或 Secret 长度不合法
"""
if not isinstance(cookie, str):
raise ValueError('会话 Cookie 必须为字符串')
parts = cookie.split('.')
if len(parts) != OidcUtil._COOKIE_PARTS or parts[0] != OidcUtil._COOKIE_PREFIX:
raise ValueError('会话 Cookie 格式无效')
sid, secret = parts[1], parts[2]
if not OidcUtil.is_rfc4122_uuid(sid):
raise ValueError('会话 Cookie 中的 sid 格式不规范')
if len(secret) != OidcUtil._COOKIE_SECRET_TEXT_LENGTH or any(
char not in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-' for char in secret
):
raise ValueError('会话 Cookie 密钥无效')
try:
decoded = base64.urlsafe_b64decode(secret + '===')
except (binascii.Error, ValueError) as exc:
raise ValueError('会话 Cookie 密钥无效') from exc
if len(decoded) != OidcUtil._COOKIE_SECRET_BYTES:
raise ValueError('会话 Cookie 密钥必须为 256 位')
return sid, secret
@staticmethod
def session_pepper_bytes(pepper: str | bytes) -> bytes:
"""
验证并转换 Token Pepper 字节串
:param pepper: OIDC Token Pepper
:return: Pepper 字节
:raises ValueError: Pepper 类型或长度不合法
"""
if not isinstance(pepper, (str, bytes)):
raise ValueError('会话摘要密钥必须为字符串或字节数据')
raw = pepper.encode() if isinstance(pepper, str) else pepper
if len(raw) < OidcUtil._MIN_PEPPER_BYTES:
raise ValueError('会话摘要密钥至少需要 32 字节')
return raw
@classmethod
def session_secret_digest(cls, secret: str | bytes, pepper: str | bytes) -> str:
"""
使用 Pepper 计算 Cookie Secret 的 HMAC 摘要
:param secret: Cookie Secret 原文,仅在请求内存中存在
:param pepper: OIDC Token Pepper
:return: 十六进制 HMAC 摘要
:raises ValueError: secret 不是 str/bytes,或 pepper 不是 str/bytes、编码后少于 32 字节
"""
raw_secret = secret.encode() if isinstance(secret, str) else secret
if not isinstance(raw_secret, bytes):
raise ValueError('会话密钥必须为字节数据或字符串')
return cls.hmac_sha256(raw_secret, cls.session_pepper_bytes(pepper))
@classmethod
def session_user_agent_digest(cls, user_agent: str | None, pepper: str | bytes) -> str | None:
"""
使用 Pepper 计算 User-Agent 的 HMAC 摘要
:param user_agent: 请求 User-Agent
:param pepper: OIDC Token Pepper
:return: User-Agent 摘要或 None
:raises ValueError: User-Agent 不是字符串、超过 500 个字符,或 pepper 编码后少于 32 字节
"""
if user_agent is None:
return None
if not isinstance(user_agent, str) or len(user_agent) > OidcUtil._MAX_USER_AGENT_LENGTH:
raise ValueError('客户端标识 User-Agent 无效')
return cls.session_secret_digest(user_agent, pepper)
@staticmethod
def looks_like_jwt(value: str) -> bool:
"""
判断字符串是否具有 JWT 三段式秘密外观
:param value: 待检测的字符串
:return: 是否符合 JWT 外形
"""
parts = value.split('.')
return len(parts) == OidcUtil._JWT_PART_COUNT and all(
OidcUtil._JWT_PART_MIN_LENGTH <= len(part) <= OidcUtil._JWT_PART_MAX_LENGTH for part in parts
)
@classmethod
def sanitize_audit_detail(cls, detail: Any) -> Any:
"""
递归删除审计详情中的敏感字段
:param detail: 待记录的任意可序列化值
:return: 只包含安全字段的可序列化值
"""
if isinstance(detail, Mapping):
return {
str(key): cls.sanitize_audit_detail(value)
for key, value in detail.items()
if not cls._SENSITIVE_KEY.search(str(key))
}
if isinstance(detail, list):
return [cls.sanitize_audit_detail(value) for value in detail]
if isinstance(detail, tuple):
return [cls.sanitize_audit_detail(value) for value in detail]
if isinstance(detail, str):
if (
cls._SENSITIVE_VALUE.search(detail)
or cls.looks_like_jwt(detail)
or cls._OPAQUE_CAPABILITY.search(detail)
):
return '[REDACTED]'
return detail[:1024]
if isinstance(detail, (int, float, bool)) or detail is None:
return detail
return None
# RSA 公钥与签名私钥
@staticmethod
def rsa_public_jwk(public_key: RSAPublicKey, kid: str) -> dict[str, str]:
"""
将 RSA 公钥编码为仅包含公开参数的 RS256 JWK。
:param public_key: RSA 公钥
:param kid: 已由调用方校验的密钥标识
:return: 用于签名验证的公开 JWK
"""
numbers = public_key.public_numbers()
return {
'kty': 'RSA',
'use': 'sig',
'kid': kid,
'alg': 'RS256',
'n': OidcUtil.base64url_encode(numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, 'big')),
'e': OidcUtil.base64url_encode(numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, 'big')),
}
@classmethod
def normalize_public_jwk(
cls, record: Any, *, min_rsa_bits: int = 2048, min_exponent: int = 3, max_exponent: int = 2**32
) -> dict[str, str]:
"""
提取并校验公开 RSA JWK
:param record: 数据库签名密钥记录或包含 public_jwk 字段的映射
:param min_rsa_bits: 最小 RSA 模数位数
:param min_exponent: 最小公开指数
:param max_exponent: 公开指数上限(不包含)
:return: 只包含公开字段的 JWK
:raises ValueError: JWK 结构或算法不合法
"""
value = getattr(record, 'public_jwk', record)
if not isinstance(value, Mapping):
raise ValueError('公开 JWK 必须为对象')
if (
getattr(record, 'key_use', 'sig') != 'sig'
or getattr(record, 'alg', 'RS256') != 'RS256'
or value.get('kty') != 'RSA'
or value.get('use', 'sig') != 'sig'
or value.get('alg', 'RS256') != 'RS256'
):
raise ValueError('仅支持使用 RS256 签名的 RSA JWK')
record_kid = getattr(record, 'kid', None)
kid = value.get('kid', record_kid)
if not cls.is_valid_kid(kid):
raise ValueError('签名密钥标识 kid 包含不允许的字符')
if record_kid is not None and kid != record_kid:
raise ValueError('公开 JWK 的 kid 与数据库密钥标识不匹配')
result = {field: value.get(field) for field in ('kty', 'use', 'kid', 'alg', 'n', 'e')}
result.update({'kty': 'RSA', 'use': 'sig', 'kid': kid, 'alg': 'RS256'})
if (
not isinstance(result.get('n'), str)
or not result['n']
or not isinstance(result.get('e'), str)
or not result['e']
):
raise ValueError('公开 JWK 必须包含 n 和 e')
try:
n_bytes = base64.urlsafe_b64decode(result['n'] + '=' * (-len(result['n']) % 4))
e_bytes = base64.urlsafe_b64decode(result['e'] + '=' * (-len(result['e']) % 4))
except (TypeError, ValueError, binascii.Error) as exc:
raise ValueError('公开 JWK 的 n/e 必须使用 Base64URL 编码') from exc
if (
not n_bytes
or not e_bytes
or n_bytes[0] == 0
or cls.base64url_encode(n_bytes) != result['n']
or cls.base64url_encode(e_bytes) != result['e']
):
raise ValueError('公开 JWK 的 n/e 编码无效')
modulus = int.from_bytes(n_bytes, 'big')
exponent = int.from_bytes(e_bytes, 'big')
if (
modulus.bit_length() < min_rsa_bits
or exponent < min_exponent
or exponent >= max_exponent
or exponent % 2 == 0
):
raise ValueError('公开 JWK 的 RSA 参数无效')
try:
RSAPublicNumbers(exponent, modulus).public_key()
except ValueError as exc:
raise ValueError('公开 JWK 的 RSA 参数无效') from exc
return result
@staticmethod
def derive_signing_encryption_key(material: bytes, salt: bytes) -> bytes:
"""
使用随机盐派生私钥加密密钥
:param material: 部署配置中的加密主密钥
:param salt: 单条密钥记录的随机盐
:return: AES-GCM 使用的 256 位密钥
"""
return PBKDF2HMAC(
algorithm=SHA256(),
length=32,
salt=salt,
iterations=OidcUtil._ENCRYPTION_KDF_ITERATIONS,
).derive(material)
@classmethod
def encrypt_signing_private_key(cls, pem: bytes, material: bytes, *, salt: bytes, nonce: bytes) -> str:
"""
按现有 v2 格式封装使用 AES-GCM 加密的签名私钥。
:param pem: 私钥 PEM 字节
:param material: 调用方校验后的部署加密主密钥
:param salt: 调用方生成的 16 字节随机盐
:param nonce: 调用方生成的 12 字节随机数
:return: 带 v2 前缀的私钥密文
"""
encrypted = AESGCM(cls.derive_signing_encryption_key(material, salt)).encrypt(nonce, pem, None)
return 'v2.' + base64.urlsafe_b64encode(b'salt' + salt + nonce + encrypted).decode()
@classmethod
def decrypt_signing_private_key(cls, ciphertext: str, material: bytes) -> bytes:
"""
解密现有 v1/v2 私钥密文,保留历史数据格式兼容性。
:param ciphertext: 带版本前缀的私钥密文
:param material: 部署加密主密钥
:return: 已解密的私钥 PEM 字节
"""
if ciphertext.startswith('v2.'):
envelope = base64.urlsafe_b64decode(ciphertext.removeprefix('v2.'))
salt, nonce, encrypted = envelope[4:20], envelope[20:32], envelope[32:]
key = cls.derive_signing_encryption_key(material, salt)
elif ciphertext.startswith('v1.'):
envelope = base64.urlsafe_b64decode(ciphertext.removeprefix('v1.'))
nonce, encrypted = envelope[:12], envelope[12:]
key = hashlib.sha256(material).digest()
else:
raise ValueError('不支持当前签名私钥密文格式')
return AESGCM(key).decrypt(nonce, encrypted, None)