Files
Cat 87561a90d1 fix(agent): route agent command receipts to the signed channel user (#7228)
- Agent 代渠道用户调用内部 API 时,把会话渠道与渠道用户写入宿主签发令牌的签名
  声明(agent_channel、agent_channel_user),不再新增可伪造的请求头。
- /commands/run 对 Telegram 等通知渠道只使用与请求渠道一致的签名渠道用户执行命令
  和投递回执;缺少该声明时明确拒绝,不再把 MoviePilot 用户表主键当作渠道 chat id。
  Web、WebAgent 与无渠道调用仍按 MoviePilot 用户 ID 处理。
- 只读公共资料借用宿主身份,不携带渠道发起人声明。
- 同步依赖基线与架构总览中的内部导入边数量。

Refs #7225

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-28 19:05:52 +08:00

275 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
与传输框架无关的令牌、密码和对称加密能力。
"""
import base64
import datetime
import hashlib
import hmac
import importlib
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.application.configuration import TokenRuntimeConfig, get_token_runtime_config
from app.runtime.log import logger
from app.schemas.token import TokenPayload
BCRYPT_PASSWORD_MAX_BYTES = 72
BCRYPT_ROUNDS = 12
ALGORITHM = "HS256"
def _token_config() -> TokenRuntimeConfig:
"""
读取启动快照;未装配时保留旧插件的独立调用兼容。
"""
try:
return get_token_runtime_config()
except RuntimeError:
legacy_settings = importlib.import_module("app.runtime.config").settings
return TokenRuntimeConfig(
secret_key=legacy_settings.SECRET_KEY,
resource_secret_key=legacy_settings.RESOURCE_SECRET_KEY,
access_token_expire_minutes=legacy_settings.ACCESS_TOKEN_EXPIRE_MINUTES,
resource_access_token_expire_seconds=(
legacy_settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS
),
)
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",
agent_channel: Optional[str] = None,
agent_channel_user: Optional[str] = None,
) -> str:
"""
创建带身份、权限等级和用途声明的 JWT 访问令牌。
宿主 Agent 代渠道用户调用内部 API 时,可把发起会话的渠道及渠道用户标识写入签名声明;
两项需同时提供且令牌用途为认证时才会写入,资源令牌不携带这组声明。
:param userid: 令牌主体的 MoviePilot 用户 ID
:param username: 令牌主体的用户名
:param super_user: 是否为超级用户
:param expires_delta: 自定义有效期;None 时按用途使用默认有效期
:param level: 权限级别
:param purpose: 令牌用途,authentication 或 resource
:param agent_channel: 宿主 Agent 会话所属的消息渠道
:param agent_channel_user: 发起该会话的用户在渠道内的原始标识
:return: 已签名的 JWT 字符串
:raises ValueError: 自定义有效期不是正数
"""
config = _token_config()
if purpose == "resource":
default_expire = timedelta(
seconds=config.resource_access_token_expire_seconds
)
secret_key = config.resource_secret_key
else:
default_expire = timedelta(minutes=config.access_token_expire_minutes)
secret_key = config.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,
}
if purpose == "authentication" and agent_channel and agent_channel_user:
payload["agent_channel"] = agent_channel
payload["agent_channel_user"] = agent_channel_user
encoded: str = jwt.encode(payload, secret_key, algorithm=ALGORITHM)
return encoded
def decode_access_token(
token: str | None,
purpose: str = "authentication",
) -> TokenPayload:
"""
校验 JWT 签名和用途并返回框架无关的令牌载荷。
"""
config = _token_config()
if not token:
raise TokenValidationError(f"{purpose} token not found")
secret_key = (
config.resource_secret_key
if purpose == "resource"
else config.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:
verified: bool = bcrypt.checkpw(
_encode_bcrypt_password(
plain_password,
allow_legacy_truncation=True,
),
hashed_password.encode("ascii"),
)
return verified
except (UnicodeEncodeError, ValueError):
return False
def get_password_hash(password: str) -> str:
"""
使用 ``$2b$`` 前缀和 cost 12 生成可持久化的 bcrypt 哈希。
"""
hashed: bytes = bcrypt.hashpw(
_encode_bcrypt_password(password),
bcrypt.gensalt(rounds=BCRYPT_ROUNDS, prefix=b"2b"),
)
return hashed.decode("ascii")
def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
"""
使用 Fernet 解密二进制数据,失败时记录诊断并返回空值。
"""
try:
decrypted: bytes = Fernet(key).decrypt(data)
return decrypted
except Exception as error:
logger.error(f"解密失败:{str(error)} - {traceback.format_exc()}")
return None
def encrypt_message(message: str, key: bytes) -> str:
"""
使用 Fernet 加密文本并返回可传输字符串。
"""
encrypted: str = Fernet(key).encrypt(message.encode()).decode()
return encrypted
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)
# CBC 按字节分块;先编码再填充,保留 ASCII 密文并支持多字节文本。
encoded = data.encode("utf-8")
result = cipher.encrypt(pad(encoded, AES.block_size))
return base64.b64encode(bytes(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()