feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
132 lines
5.1 KiB
Python
132 lines
5.1 KiB
Python
import base64
|
|
import json
|
|
import time
|
|
from hashlib import sha256
|
|
from secrets import compare_digest
|
|
from typing import Any, Mapping
|
|
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
from cryptography.hazmat.primitives.padding import PKCS7
|
|
from fastapi import HTTPException, status
|
|
|
|
from app.core.config import get_settings
|
|
from app.modules.feishu.constants import FeishuPayloadKey
|
|
|
|
_SIGNATURE_MAX_AGE_SECONDS = 300
|
|
_SIGNATURE_HEADER = "x-lark-signature"
|
|
_TIMESTAMP_HEADER = "x-lark-request-timestamp"
|
|
_NONCE_HEADER = "x-lark-request-nonce"
|
|
|
|
|
|
class FeishuWebhookVerifier:
|
|
"""Verify, decrypt, and normalize an HTTP webhook before business handling."""
|
|
|
|
def verify(
|
|
self,
|
|
raw_body: bytes,
|
|
headers: Mapping[str, str],
|
|
) -> dict[str, Any]:
|
|
settings = get_settings()
|
|
if settings.feishu_encrypt_key:
|
|
self._verify_signature(raw_body, headers, settings.feishu_encrypt_key)
|
|
payload = self._load_json(raw_body)
|
|
if FeishuPayloadKey.ENCRYPT in payload:
|
|
if not settings.feishu_encrypt_key:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="FEISHU_ENCRYPT_KEY is required for encrypted webhooks",
|
|
)
|
|
payload = self._decrypt(
|
|
str(payload[FeishuPayloadKey.ENCRYPT]),
|
|
settings.feishu_encrypt_key,
|
|
)
|
|
self._verify_token(payload, settings.feishu_verification_token)
|
|
return payload
|
|
|
|
@staticmethod
|
|
def _load_json(raw_body: bytes) -> dict[str, Any]:
|
|
try:
|
|
payload = json.loads(raw_body)
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid Feishu webhook JSON",
|
|
) from exc
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid Feishu webhook payload",
|
|
)
|
|
return payload
|
|
|
|
@staticmethod
|
|
def _verify_signature(
|
|
raw_body: bytes,
|
|
headers: Mapping[str, str],
|
|
encrypt_key: str,
|
|
) -> None:
|
|
normalized = {str(key).lower(): str(value) for key, value in headers.items()}
|
|
timestamp = normalized.get(_TIMESTAMP_HEADER)
|
|
nonce = normalized.get(_NONCE_HEADER)
|
|
signature = normalized.get(_SIGNATURE_HEADER)
|
|
if not timestamp or not nonce or not signature:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Missing Feishu webhook signature headers",
|
|
)
|
|
try:
|
|
request_time = int(timestamp)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid Feishu webhook timestamp",
|
|
) from exc
|
|
if abs(int(time.time()) - request_time) > _SIGNATURE_MAX_AGE_SECONDS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Expired Feishu webhook signature",
|
|
)
|
|
signed = (
|
|
timestamp.encode("utf-8")
|
|
+ nonce.encode("utf-8")
|
|
+ encrypt_key.encode("utf-8")
|
|
+ raw_body
|
|
)
|
|
expected = sha256(signed).hexdigest()
|
|
if not compare_digest(signature, expected):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid Feishu webhook signature",
|
|
)
|
|
|
|
@staticmethod
|
|
def _decrypt(encrypted: str, encrypt_key: str) -> dict[str, Any]:
|
|
try:
|
|
key = sha256(encrypt_key.encode("utf-8")).digest()
|
|
encrypted_bytes = base64.b64decode(encrypted, validate=True)
|
|
decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
|
|
padded = decryptor.update(encrypted_bytes) + decryptor.finalize()
|
|
unpadder = PKCS7(algorithms.AES.block_size).unpadder()
|
|
cleartext = unpadder.update(padded) + unpadder.finalize()
|
|
except (ValueError, TypeError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid encrypted Feishu webhook",
|
|
) from exc
|
|
return FeishuWebhookVerifier._load_json(cleartext)
|
|
|
|
@staticmethod
|
|
def _verify_token(payload: dict[str, Any], expected: str | None) -> None:
|
|
if not expected:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="FEISHU_VERIFICATION_TOKEN is required",
|
|
)
|
|
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
|
token = payload.get(FeishuPayloadKey.TOKEN) or header.get(FeishuPayloadKey.TOKEN)
|
|
if not token or not compare_digest(str(token), expected):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid Feishu token",
|
|
)
|