refactor: 收口 V3 分层架构与插件兼容边界

This commit is contained in:
jxxghp
2026-08-18 13:22:02 +08:00
parent cca99bd421
commit 8472bcff43
274 changed files with 10730 additions and 6130 deletions
-471
View File
@@ -1,471 +0,0 @@
import base64
import datetime
import hashlib
import hmac
import json
import os
import traceback
from datetime import timedelta
from typing import Any, Union, Annotated, Optional, Callable
import bcrypt
import jwt
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from cryptography.fernet import Fernet
from fastapi import HTTPException, status, Security, Request, Response
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, APIKeyCookie, HTTPBearer
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.runtime.cache import cached
from app.runtime.config import settings
from app.runtime.log import logger
BCRYPT_PASSWORD_MAX_BYTES = 72
BCRYPT_ROUNDS = 12
ALGORITHM = "HS256"
SuperuserTokenPayloadProvider = Callable[[], _SchemaTokenPayload]
_superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None
class PasswordTooLongError(ValueError):
"""密码的 UTF-8 字节长度超过 bcrypt 可安全处理的上限。"""
def _encode_bcrypt_password(
password: str, *, allow_legacy_truncation: bool = False
) -> bytes:
"""编码 bcrypt 密码;仅验证既有哈希时允许按历史语义截断。"""
password_bytes = password.encode("utf-8")
if len(password_bytes) > BCRYPT_PASSWORD_MAX_BYTES:
if allow_legacy_truncation:
return password_bytes[:BCRYPT_PASSWORD_MAX_BYTES]
raise PasswordTooLongError(
f"密码 UTF-8 编码后不能超过 {BCRYPT_PASSWORD_MAX_BYTES} 字节"
)
return password_bytes
def set_superuser_token_payload_provider(
provider: SuperuserTokenPayloadProvider,
) -> None:
"""注入 API 密钥认证所需的超级用户载荷提供器。"""
global _superuser_token_payload_provider
_superuser_token_payload_provider = provider
# OAuth2PasswordBearer 用于 JWT Token 认证
oauth2_scheme_manual_error = OAuth2PasswordBearer(
auto_error=False, # 禁用自动错误处理,用以支持API令牌鉴权
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
# RESOURCE TOKEN 通过 Cookie 认证
resource_token_cookie = APIKeyCookie(name=settings.PROJECT_NAME, auto_error=False, scheme_name="resource_token_cookie")
# API TOKEN 通过 QUERY 认证
api_token_query = APIKeyQuery(name="token", auto_error=False, scheme_name="api_token_query")
# API KEY 通过 Header 认证
api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False, scheme_name="api_key_header")
# API KEY 通过 QUERY 认证
api_key_query = APIKeyQuery(name="apikey", auto_error=False, scheme_name="api_key_query")
# OpenAI compatible Bearer Token 认证
openai_bearer_scheme = HTTPBearer(auto_error=False)
# Anthropic compatible API Key 认证
anthropic_api_key_header = APIKeyHeader(name="x-api-key", auto_error=False, scheme_name="anthropic_api_key_header")
def __get_api_token(
token_query: Annotated[str | None, Security(api_token_query)] = None
) -> str | None:
"""
从 URL 查询参数中获取 API Token
:param token_query: 从 URL 中的 `token` 查询参数获取 API Token
:return: 返回获取到的 API Token,若无则返回 None
"""
return token_query
def __get_api_key(
key_query: Annotated[str | None, Security(api_key_query)] = None,
key_header: Annotated[str | None, Security(api_key_header)] = None
) -> str | None:
"""
从 URL 查询参数或请求头部获取 API Key,优先使用请求头
:param key_query: URL 中的 `apikey` 查询参数
:param key_header: 请求头中的 `X-API-KEY` 参数
:return: 返回从 URL 或请求头中获取的 API Key,若无则返回 None
"""
return key_header or key_query # 首选请求头
@cached(maxsize=1, ttl=600)
def __create_superuser_token_payload() -> _SchemaTokenPayload:
"""
创建管理员用户的TokenPayload
:return: 管理员TokenPayload
"""
if not _superuser_token_payload_provider:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="认证服务尚未初始化",
)
return _superuser_token_payload_provider()
def create_access_token(
userid: Union[str, Any],
username: str,
super_user: Optional[bool] = False,
expires_delta: Optional[timedelta] = None,
level: Optional[int] = 1,
purpose: Optional[str] = "authentication"
) -> str:
"""
创建 JWT 访问令牌,包含用户 ID、用户名、是否为超级用户以及权限等级
:param userid: 用户的唯一标识符,通常是字符串或整数
:param username: 用户名,用于标识用户的账户名
:param super_user: 是否为超级用户,默认值为 False
:param expires_delta: 令牌的有效期时长,如果不提供则根据用途使用默认过期时间
:param level: 用户的权限级别,默认为 1
:param purpose: 令牌的用途,"authentication""resource"
:return: 编码后的 JWT 令牌字符串
:raises ValueError: 如果 expires_delta 为负数
"""
if purpose == "resource":
default_expire = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)
secret_key = settings.RESOURCE_SECRET_KEY
else:
default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
secret_key = settings.SECRET_KEY
if expires_delta is not None:
if expires_delta.total_seconds() <= 0:
raise ValueError("过期时间必须为正数")
expire = datetime.datetime.now(datetime.UTC) + expires_delta
else:
expire = datetime.datetime.now(datetime.UTC) + default_expire
to_encode = {
"exp": expire,
"iat": datetime.datetime.now(datetime.UTC),
"sub": str(userid),
"username": username,
"super_user": super_user,
"level": level,
"purpose": purpose
}
encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM)
return encoded_jwt
def set_or_refresh_resource_token_cookie(
request: Request, response: Response, payload: _SchemaTokenPayload
) -> None:
"""
设置资源令牌 Cookie
:param request: 包含请求相关的上下文数据
:param response: 用于在服务器响应时设置 Cookie
:param payload: 已通过身份验证的 TokenPayload 对象
"""
resource_token = request.cookies.get(settings.PROJECT_NAME)
if resource_token:
# 检查令牌剩余时间
try:
decoded_token = jwt.decode(resource_token, settings.RESOURCE_SECRET_KEY, algorithms=[ALGORITHM])
exp = decoded_token.get("exp")
if exp:
remaining_time = datetime.datetime.fromtimestamp(exp, tz=datetime.UTC) - datetime.datetime.now(datetime.UTC)
# 根据剩余时长提前刷新令牌
if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)):
raise jwt.ExpiredSignatureError
expected_claims = {
"sub": str(payload.sub),
"username": payload.username,
"super_user": payload.super_user,
"level": payload.level,
"purpose": "resource",
}
if any(decoded_token.get(claim) != value for claim, value in expected_claims.items()):
raise jwt.InvalidTokenError("资源令牌身份或权限上下文不匹配")
except jwt.PyJWTError:
logger.debug(f"Token error occurred. refreshing token")
except Exception as e:
logger.debug(f"Unexpected error occurred while decoding token: {e}")
else:
# 如果令牌有效且没有即将过期,则不需要刷新
return
# 创建新的资源访问令牌
resource_token_expires = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)
resource_token = create_access_token(
userid=payload.sub,
username=payload.username,
super_user=payload.super_user,
expires_delta=resource_token_expires,
level=payload.level,
purpose="resource"
)
# 判断请求是否为 HTTPS:直连协议为 https,或经反向代理转发时携带 X-Forwarded-Proto: https。
# 无法确认为明文 HTTP 时按 fail-safe 默认设置 secure=True,避免代理终止 HTTPS 后以 HTTP 转发导致 Cookie 明文传输。
is_https = (
request.url.scheme == "https"
or request.headers.get("x-forwarded-proto", "").lower() == "https"
)
# 设置会话级别的 HttpOnly Cookie
response.set_cookie(
key=settings.PROJECT_NAME,
value=resource_token,
httponly=True,
secure=is_https, # 根据当前请求协议(含反向代理转发标识)设置 secure 属性
samesite="lax" # 不同浏览器对 "Strict" 的处理可能不同,设置 SameSite 为 "Lax",以平衡安全性和兼容性
)
def __verify_token(token: str, purpose: Optional[str] = "authentication") -> _SchemaTokenPayload:
"""
使用 JWT Token 进行身份认证并解析 Token 的内容
:param token: JWT 令牌
:param purpose: 期望的令牌用途,默认为 "authentication"
:return: 包含用户身份信息的 Token 负载数据
:raises HTTPException: 如果令牌无效或用途不匹配
"""
try:
if purpose == "resource":
secret_key = settings.RESOURCE_SECRET_KEY
else:
secret_key = settings.SECRET_KEY
if not token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"{purpose} token not found"
)
payload = jwt.decode(
token, secret_key, algorithms=[ALGORITHM]
)
token_payload = _SchemaTokenPayload(**payload)
if token_payload.purpose != purpose:
raise jwt.InvalidTokenError("令牌用途不匹配")
return _SchemaTokenPayload(**payload)
except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="token校验不通过",
)
def verify_token(
request: Request,
response: Response,
jwt_token: Annotated[str | None, Security(oauth2_scheme_manual_error)],
api_key: Annotated[str | None, Security(__get_api_key)],
api_token: Annotated[str | None, Security(__get_api_token)],
) -> _SchemaTokenPayload:
"""
验证 JWT 令牌并自动处理 resource_token 写入
如果缺少JWT令牌再尝试用API令牌鉴权
:param request: 请求对象,用于访问 Cookie 和请求信息
:param response: 响应对象,用于设置 Cookie
:param jwt_token: 从 Authorization 头部获取的 JWT 令牌
:param api_key: 从 查询参数`apikey` 或 请求头`X-API-KEY` 获取 API Token
:param api_token: 从 查询参数`token` 获取 API Token
:return: 解析后的 TokenPayload
:raises HTTPException: 如果令牌无效或用途不匹配
"""
if jwt_token:
# 验证并解析 JWT 认证令牌
payload = __verify_token(token=jwt_token, purpose="authentication")
# 如果没有 resource_token,生成并写入到 Cookie
set_or_refresh_resource_token_cookie(request, response, payload)
return payload
elif api_key:
verify_apikey(api_key)
return __create_superuser_token_payload()
elif api_token:
verify_apitoken(api_token)
return __create_superuser_token_payload()
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
def verify_resource_token(
resource_token: Annotated[str, Security(resource_token_cookie)]
) -> _SchemaTokenPayload:
"""
验证资源访问令牌(从 Cookie 中获取)
:param resource_token: 从 Cookie 中获取的资源访问令牌
:return: 解析后的 TokenPayload
:raises HTTPException: 如果资源访问令牌无效
"""
# 验证并解析资源访问令牌
return __verify_token(token=resource_token, purpose="resource")
def __verify_key(key: str | None, expected_key: str, key_type: str) -> str:
"""
通用的 API Key 或 Token 验证函数
:param key: 从请求中获取的 API Key 或 Token
:param expected_key: 系统配置中的期望值,用于验证的 API Key 或 Token
:param key_type: 键的类型(例如 "API_KEY""API_TOKEN"),用于错误消息
:return: 返回校验通过的 API Key 或 Token
:raises HTTPException: 如果校验不通过,抛出 401 错误
"""
if not key or key != expected_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"{key_type} 校验不通过"
)
return key
def verify_apitoken(token: Annotated[str | None, Security(__get_api_token)]) -> str:
"""
使用 API Token 进行受信第三方集成认证。
校验值来自 settings.API_TOKEN;通过后只确认集成凭据有效,不生成 per-user 权限上下文。
:param token: API Token,从 URL 查询参数中获取 token=xxx
:return: 返回校验通过的 API Token
"""
return __verify_key(token, settings.API_TOKEN, "token")
def verify_apikey(apikey: Annotated[str | None, Security(__get_api_key)]) -> str:
"""
使用 API Key 形式进行受信第三方集成认证。
请求字段名兼容 API Key,实际校验值来自 settings.API_TOKEN,不生成 per-user 权限上下文。
:param apikey: API Key,从 URL 查询参数中获取 apikey=xxx,或请求头中获取 X-API-KEY=xxx
:return: 返回校验通过的 API Key
"""
return __verify_key(apikey, settings.API_TOKEN, "apikey")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证既有 bcrypt 哈希,并保留超长历史密码的截断语义。"""
try:
return bcrypt.checkpw(
_encode_bcrypt_password(
plain_password, allow_legacy_truncation=True
),
hashed_password.encode("ascii"),
)
except (UnicodeEncodeError, ValueError):
return False
def get_password_hash(password: str) -> str:
"""使用 $2b$ 前缀和 cost 12 生成可持久化的 bcrypt 密码哈希。"""
return bcrypt.hashpw(
_encode_bcrypt_password(password),
bcrypt.gensalt(rounds=BCRYPT_ROUNDS, prefix=b"2b"),
).decode("ascii")
def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
"""
解密二进制数据
"""
fernet = Fernet(key)
try:
return fernet.decrypt(data)
except Exception as e:
logger.error(f"解密失败:{str(e)} - {traceback.format_exc()}")
return None
def encrypt_message(message: str, key: bytes) -> str:
"""
使用给定的key对消息进行加密,并返回加密后的字符串
"""
f = Fernet(key)
encrypted_message = f.encrypt(message.encode())
return encrypted_message.decode()
def hash_sha256(message: str) -> str:
"""
对字符串做hash运算
"""
return hashlib.sha256(message.encode()).hexdigest()
def aes_decrypt(data: str, key: str) -> str:
"""
AES解密
"""
if not data:
return ""
data = base64.b64decode(data)
iv = data[:16]
encrypted = data[16:]
# 使用AES-256-CBC解密
cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv)
result = cipher.decrypt(encrypted)
# 去除填充
padding = result[-1]
if padding < 1 or padding > AES.block_size:
return ""
result = result[:-padding]
return result.decode('utf-8')
def aes_encrypt(data: str, key: str) -> str:
"""
AES加密
"""
if not data:
return ""
# 使用AES-256-CBC加密
cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC)
# 填充
padding = AES.block_size - len(data) % AES.block_size
data += chr(padding) * padding
result = cipher.encrypt(data.encode('utf-8'))
# 使用base64编码
return base64.b64encode(cipher.iv + result).decode('utf-8')
def nexusphp_encrypt(data_str: str, key: bytes) -> str:
"""
NexusPHP加密
"""
# 生成16字节长的随机字符串
iv = os.urandom(16)
# 对向量进行 Base64 编码
iv_base64 = base64.b64encode(iv)
# 加密数据
cipher = AES.new(key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(pad(data_str.encode(), AES.block_size))
ciphertext_base64 = base64.b64encode(ciphertext)
# 对向量的字符串表示进行签名
mac = hmac.new(key, msg=iv_base64 + ciphertext_base64, digestmod=hashlib.sha256).hexdigest()
# 构造 JSON 字符串
json_str = json.dumps({
'iv': iv_base64.decode(),
'value': ciphertext_base64.decode(),
'mac': mac,
'tag': ''
})
# 对 JSON 字符串进行 Base64 编码
return base64.b64encode(json_str.encode()).decode()
+121 -47
View File
@@ -2,17 +2,12 @@ import secrets
import threading
import time
from datetime import timedelta
from typing import Any, Optional
from fastapi import HTTPException, status
from typing import Any, Optional, Protocol
from app.schemas.token import Token as _SchemaToken
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.application.security import access as security
from app.application.security.token import create_access_token
from app.runtime.config import settings
from app.db.models.user import User
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.user import UserOper
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.schemas.types import SystemConfigKey
from app.foundation.singleton import Singleton
@@ -119,49 +114,128 @@ def consume_plugin_auth_ticket(ticket: str) -> Optional[dict[str, Any]]:
return AuthTicketStore().consume(ticket)
def build_superuser_token_payload() -> _SchemaTokenPayload:
"""从持久化用户和站点认证状态构造超级用户令牌载荷"""
user = UserOper().get_by_name(settings.SUPERUSER)
if not user or not user.is_superuser:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户权限不足",
)
return _SchemaTokenPayload(
sub=user.id,
username=user.name,
super_user=user.is_superuser,
level=SitesHelper().auth_level,
purpose="authentication",
)
class AuthUser(Protocol):
"""认证服务需要的最小用户投影"""
id: int
name: str
is_active: bool
is_superuser: bool
avatar: Optional[str]
permissions: Optional[dict]
def build_token_response(user: User) -> _SchemaToken:
"""
使用系统统一逻辑构造登录 Token 响应。
class AuthUserRepository(Protocol):
"""认证服务的用户数据端口。"""
:param user: 已认证的本地用户
:return: 标准 Token 响应
"""
level = SitesHelper().auth_level
show_wizard = (
not SystemConfigOper().get(SystemConfigKey.SetupWizardState)
and not settings.ADVANCED_MODE
)
return _SchemaToken(
access_token=security.create_access_token(
userid=user.id,
def get_by_name(self, name: str) -> Optional[AuthUser]:
"""按用户名查询用户。"""
def get_by_id(self, user_id: int) -> Optional[AuthUser]:
"""按 ID 查询用户。"""
class AuthPasskeyRepository(Protocol):
"""认证提供方查询端口。"""
def list(self) -> list[Any]:
"""返回已启用的 PassKey。"""
class AuthConfigRepository(Protocol):
"""认证配置读取端口。"""
def get(self, key: Any) -> Any:
"""读取配置值。"""
class AuthService:
"""认证应用服务,编排用户、配置和 PassKey 端口。"""
def __init__(
self,
users: AuthUserRepository,
config: AuthConfigRepository,
passkeys: AuthPasskeyRepository,
) -> None:
"""注入认证所需的数据端口。"""
self._users = users
self._config = config
self._passkeys = passkeys
def get_user_by_id(self, user_id: int) -> Optional[AuthUser]:
"""按 ID 查询本地用户。"""
return self._users.get_by_id(user_id)
def has_passkey(self) -> bool:
"""判断系统是否已有 PassKey。"""
return bool(self._passkeys.list())
def build_superuser_token_payload(self) -> _SchemaTokenPayload:
"""从持久化用户和站点认证状态构造超级用户令牌载荷。"""
user = self._users.get_by_name(settings.SUPERUSER)
if not user or not user.is_superuser:
raise PermissionError("用户权限不足")
return _SchemaTokenPayload(
sub=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=SitesHelper().auth_level,
purpose="authentication",
)
def build_token_response(self, user: AuthUser) -> _SchemaToken:
"""使用统一逻辑构造登录 Token 响应。"""
level = SitesHelper().auth_level
show_wizard = (
not self._config.get(SystemConfigKey.SetupWizardState)
and not settings.ADVANCED_MODE
)
return _SchemaToken(
access_token=create_access_token(
userid=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level,
),
token_type="bearer",
super_user=user.is_superuser,
user_id=user.id,
user_name=user.name,
avatar=user.avatar,
level=level,
),
token_type="bearer",
super_user=user.is_superuser,
user_id=user.id,
user_name=user.name,
avatar=user.avatar,
level=level,
permissions=user.permissions or {},
wizard=show_wizard,
)
permissions=user.permissions or {},
wizard=show_wizard,
)
_configured_auth_service: AuthService | None = None
def configure_auth_service(service: AuthService) -> None:
"""由启动组合根登记认证应用服务。"""
global _configured_auth_service
_configured_auth_service = service
def _get_auth_service() -> AuthService:
"""返回启动阶段登记的认证应用服务。"""
if _configured_auth_service is None:
raise RuntimeError("认证服务尚未配置")
return _configured_auth_service
def get_configured_auth_service() -> AuthService:
"""返回启动阶段登记的认证服务。"""
return _get_auth_service()
def build_superuser_token_payload() -> _SchemaTokenPayload:
"""使用启动组合根注入的认证服务构造超级用户令牌载荷。"""
return _get_auth_service().build_superuser_token_payload()
def build_token_response(user: AuthUser) -> _SchemaToken:
"""使用启动组合根注入的认证服务构造登录 Token 响应。"""
return _get_auth_service().build_token_response(user)
+75
View File
@@ -0,0 +1,75 @@
"""PassKey 认证凭证应用服务。"""
from __future__ import annotations
from typing import Any, Optional, Protocol
class PasskeyRepository(Protocol):
"""PassKey 用例需要的最小同步数据端口。"""
def list(self) -> list[Any]:
"""列出全部启用凭证。"""
def list_by_user_id(self, user_id: int) -> list[Any]:
"""列出指定用户凭证。"""
def get_by_credential_id(self, credential_id: str) -> Optional[Any]:
"""按凭证 ID 查找凭证。"""
def create(self, payload: dict[str, Any]) -> Any:
"""创建凭证。"""
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
"""更新凭证使用计数。"""
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
"""删除用户凭证。"""
class PasskeyService:
"""编排 PassKey 凭证生命周期。"""
def __init__(self, repository: PasskeyRepository) -> None:
"""注入 PassKey 数据端口。"""
self._repository = repository
def list(self) -> list[Any]:
"""列出全部启用凭证。"""
return self._repository.list()
def list_by_user_id(self, user_id: int) -> list[Any]:
"""列出指定用户凭证。"""
return self._repository.list_by_user_id(user_id)
def get_by_credential_id(self, credential_id: str) -> Optional[Any]:
"""按凭证 ID 查找凭证。"""
return self._repository.get_by_credential_id(credential_id)
def create(self, payload: dict[str, Any]) -> Any:
"""创建凭证。"""
return self._repository.create(payload)
def update_last_used(self, passkey: Any, sign_count: int) -> bool:
"""更新凭证使用计数。"""
return self._repository.update_last_used(passkey, sign_count)
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
"""删除用户凭证。"""
return self._repository.delete_by_id(passkey_id, user_id)
_configured_passkey_service: PasskeyService | None = None
def configure_passkey_service(service: PasskeyService) -> None:
"""由启动组合根登记 PassKey 应用服务。"""
global _configured_passkey_service
_configured_passkey_service = service
def get_configured_passkey_service() -> PasskeyService:
"""返回启动阶段登记的 PassKey 应用服务。"""
if _configured_passkey_service is None:
raise RuntimeError("PassKey 服务尚未配置")
return _configured_passkey_service
+201
View File
@@ -0,0 +1,201 @@
"""与传输框架无关的令牌、密码和对称加密能力。"""
import base64
import datetime
import hashlib
import hmac
import json
import os
import traceback
from datetime import timedelta
from typing import Any, Optional, Union
import bcrypt
import jwt
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from cryptography.fernet import Fernet
from app.runtime.config import settings
from app.runtime.log import logger
from app.schemas.token import TokenPayload
BCRYPT_PASSWORD_MAX_BYTES = 72
BCRYPT_ROUNDS = 12
ALGORITHM = "HS256"
class PasswordTooLongError(ValueError):
"""密码的 UTF-8 字节长度超过 bcrypt 可安全处理的上限。"""
class TokenValidationError(ValueError):
"""令牌缺失、签名无效或用途不符合调用方要求。"""
def _encode_bcrypt_password(
password: str,
*,
allow_legacy_truncation: bool = False,
) -> bytes:
"""编码 bcrypt 密码;仅验证既有哈希时允许按历史语义截断。"""
password_bytes = password.encode("utf-8")
if len(password_bytes) > BCRYPT_PASSWORD_MAX_BYTES:
if allow_legacy_truncation:
return password_bytes[:BCRYPT_PASSWORD_MAX_BYTES]
raise PasswordTooLongError(
f"密码 UTF-8 编码后不能超过 {BCRYPT_PASSWORD_MAX_BYTES} 字节"
)
return password_bytes
def create_access_token(
userid: Union[str, Any],
username: str,
super_user: Optional[bool] = False,
expires_delta: Optional[timedelta] = None,
level: Optional[int] = 1,
purpose: Optional[str] = "authentication",
) -> str:
"""创建带身份、权限等级和用途声明的 JWT 访问令牌。"""
if purpose == "resource":
default_expire = timedelta(
seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS
)
secret_key = settings.RESOURCE_SECRET_KEY
else:
default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
secret_key = settings.SECRET_KEY
if expires_delta is not None:
if expires_delta.total_seconds() <= 0:
raise ValueError("过期时间必须为正数")
expire = datetime.datetime.now(datetime.UTC) + expires_delta
else:
expire = datetime.datetime.now(datetime.UTC) + default_expire
now = datetime.datetime.now(datetime.UTC)
payload = {
"exp": expire,
"iat": now,
"sub": str(userid),
"username": username,
"super_user": super_user,
"level": level,
"purpose": purpose,
}
return jwt.encode(payload, secret_key, algorithm=ALGORITHM)
def decode_access_token(
token: str | None,
purpose: str = "authentication",
) -> TokenPayload:
"""校验 JWT 签名和用途并返回框架无关的令牌载荷。"""
if not token:
raise TokenValidationError(f"{purpose} token not found")
secret_key = (
settings.RESOURCE_SECRET_KEY
if purpose == "resource"
else settings.SECRET_KEY
)
try:
payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM])
token_payload = TokenPayload(**payload)
if token_payload.purpose != purpose:
raise jwt.InvalidTokenError("令牌用途不匹配")
return token_payload
except (
jwt.DecodeError,
jwt.InvalidTokenError,
jwt.ImmatureSignatureError,
) as error:
raise TokenValidationError("token校验不通过") from error
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证既有 bcrypt 哈希,并保留超长历史密码的截断语义。"""
try:
return bcrypt.checkpw(
_encode_bcrypt_password(
plain_password,
allow_legacy_truncation=True,
),
hashed_password.encode("ascii"),
)
except (UnicodeEncodeError, ValueError):
return False
def get_password_hash(password: str) -> str:
"""使用 ``$2b$`` 前缀和 cost 12 生成可持久化的 bcrypt 哈希。"""
return bcrypt.hashpw(
_encode_bcrypt_password(password),
bcrypt.gensalt(rounds=BCRYPT_ROUNDS, prefix=b"2b"),
).decode("ascii")
def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
"""使用 Fernet 解密二进制数据,失败时记录诊断并返回空值。"""
try:
return Fernet(key).decrypt(data)
except Exception as error:
logger.error(f"解密失败:{str(error)} - {traceback.format_exc()}")
return None
def encrypt_message(message: str, key: bytes) -> str:
"""使用 Fernet 加密文本并返回可传输字符串。"""
return Fernet(key).encrypt(message.encode()).decode()
def hash_sha256(message: str) -> str:
"""返回文本的 SHA-256 十六进制摘要。"""
return hashlib.sha256(message.encode()).hexdigest()
def aes_decrypt(data: str, key: str) -> str:
"""按历史 AES-256-CBC 合同解密 Base64 文本。"""
if not data:
return ""
raw_data = base64.b64decode(data)
iv = raw_data[:16]
encrypted = raw_data[16:]
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv)
result = cipher.decrypt(encrypted)
padding = result[-1]
if padding < 1 or padding > AES.block_size:
return ""
return result[:-padding].decode("utf-8")
def aes_encrypt(data: str, key: str) -> str:
"""按历史 AES-256-CBC 合同加密文本并返回 Base64 字符串。"""
if not data:
return ""
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC)
padding = AES.block_size - len(data) % AES.block_size
padded = data + chr(padding) * padding
result = cipher.encrypt(padded.encode("utf-8"))
return base64.b64encode(cipher.iv + result).decode("utf-8")
def nexusphp_encrypt(data_str: str, key: bytes) -> str:
"""生成 NexusPHP 兼容的 AES-CBC 加密载荷。"""
iv = os.urandom(16)
iv_base64 = base64.b64encode(iv)
cipher = AES.new(key, AES.MODE_CBC, iv)
ciphertext = cipher.encrypt(pad(data_str.encode(), AES.block_size))
ciphertext_base64 = base64.b64encode(ciphertext)
mac = hmac.new(
key,
msg=iv_base64 + ciphertext_base64,
digestmod=hashlib.sha256,
).hexdigest()
payload = json.dumps({
"iv": iv_base64.decode(),
"value": ciphertext_base64.decode(),
"mac": mac,
"tag": "",
})
return base64.b64encode(payload.encode()).decode()
+108
View File
@@ -0,0 +1,108 @@
"""用户管理用例。
该模块承接用户端点需要的异步用户操作。具体数据库访问由请求组合根注入,
避免 API 层同时承担 HTTP 编排和 ORM 适配职责。
"""
from collections.abc import Callable
from typing import Any, Protocol
class UserRepository(Protocol):
"""用户用例所需的最小异步数据端口。"""
async def async_list(self) -> list[Any]:
"""返回全部用户。"""
async def async_get_by_name(self, name: str) -> Any | None:
"""按用户名返回用户。"""
async def async_get_by_id(self, user_id: int) -> Any | None:
"""按用户 ID 返回用户。"""
async def async_create(self, payload: dict[str, Any]) -> Any | None:
"""创建用户并返回持久化对象。"""
async def async_update(self, user_id: int, payload: dict[str, Any]) -> Any | None:
"""更新用户并返回原用户对象。"""
async def async_delete(self, user_id: int) -> None:
"""删除用户。"""
async def async_update_otp_by_name(self, name: str, otp: bool, secret: str) -> None:
"""更新用户 OTP 状态。"""
class UserService:
"""用户管理应用服务。"""
def __init__(self, repository: UserRepository) -> None:
"""创建用户服务。"""
self._repository = repository
async def list(self) -> list[Any]:
"""返回用户列表。"""
return await self._repository.async_list()
async def get_by_name(self, name: str) -> Any | None:
"""按用户名查询用户。"""
return await self._repository.async_get_by_name(name)
async def get_by_id(self, user_id: int) -> Any | None:
"""按用户 ID 查询用户。"""
return await self._repository.async_get_by_id(user_id)
async def create(self, payload: dict[str, Any]) -> Any | None:
"""创建用户。"""
return await self._repository.async_create(payload)
async def update(self, user_id: int, payload: dict[str, Any]) -> Any | None:
"""更新用户。"""
return await self._repository.async_update(user_id, payload)
async def delete(self, user_id: int) -> None:
"""删除用户。"""
await self._repository.async_delete(user_id)
async def update_otp(self, name: str, otp: bool, secret: str) -> None:
"""更新用户 OTP 状态。"""
await self._repository.async_update_otp_by_name(name, otp, secret)
_configured_user_id_lookup: Callable[[int], Any | None] | None = None
_configured_user_name_lookup: Callable[[str], Any | None] | None = None
_configured_user_channel_lookup: Callable[..., str | None] | None = None
def configure_user_lookups(
by_id: Callable[[int], Any | None],
by_name: Callable[[str], Any | None],
by_channel: Callable[..., str | None],
) -> None:
"""由启动组合根登记 ID、用户名和渠道身份查询能力。"""
global _configured_user_id_lookup, _configured_user_name_lookup
global _configured_user_channel_lookup
_configured_user_id_lookup = by_id
_configured_user_name_lookup = by_name
_configured_user_channel_lookup = by_channel
def get_configured_user_id_lookup() -> Callable[[int], Any | None]:
"""返回启动阶段登记的按 ID 用户查询函数。"""
if _configured_user_id_lookup is None:
raise RuntimeError("按 ID 的用户查询能力尚未配置")
return _configured_user_id_lookup
def get_configured_user_name_lookup() -> Callable[[str], Any | None]:
"""返回启动阶段登记的按用户名查询函数。"""
if _configured_user_name_lookup is None:
raise RuntimeError("按用户名的用户查询能力尚未配置")
return _configured_user_name_lookup
def get_configured_user_channel_lookup() -> Callable[..., str | None]:
"""返回启动阶段登记的渠道身份到用户名查询函数。"""
if _configured_user_channel_lookup is None:
raise RuntimeError("渠道用户查询能力尚未配置")
return _configured_user_channel_lookup
+47
View File
@@ -0,0 +1,47 @@
"""用户个性化配置应用服务。"""
from __future__ import annotations
from typing import Any, Protocol
class UserConfigurationRepository(Protocol):
"""用户配置数据端口。"""
def get(self, username: str, key: str) -> Any:
"""读取用户配置。"""
def set(self, username: str, key: str, value: Any) -> Any:
"""写入用户配置。"""
class UserConfigurationService:
"""编排用户个性化配置读写。"""
def __init__(self, repository: UserConfigurationRepository) -> None:
"""注入用户配置数据端口。"""
self._repository = repository
def get(self, username: str, key: str) -> Any:
"""读取用户配置。"""
return self._repository.get(username=username, key=key)
def set(self, username: str, key: str, value: Any) -> Any:
"""写入用户配置。"""
return self._repository.set(username=username, key=key, value=value)
_configured_user_configuration: UserConfigurationService | None = None
def configure_user_configuration(service: UserConfigurationService) -> None:
"""由启动组合根登记用户配置服务。"""
global _configured_user_configuration
_configured_user_configuration = service
def get_configured_user_configuration() -> UserConfigurationService:
"""返回启动阶段登记的用户配置服务。"""
if _configured_user_configuration is None:
raise RuntimeError("用户配置服务尚未配置")
return _configured_user_configuration