mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor: 收口 V3 分层架构与插件兼容边界
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user