feat(ws3): ADR-093 Callback User-ID Binding + ADR-094 Webhook 入口

## T3.1/T3.2 Bound User Check(security_interceptor.py)
- verify_callback() Step 0: 檢查 Redis cb_bind:{nonce}
  → 若有 binding 且 caller != bound_user_id → UserNotWhitelistedError
  → 若 key 不存在(舊格式)→ 降級走 whitelist(向後相容)
  → 若 Redis unavailable → 降級繼續(安全降級)
- bind_callback_user(nonce, user_id): async 方法,TTL=48h

## T3.3 Telegram Webhook 入口(ADR-094)
- apps/api/src/api/v1/telegram_webhook.py(新建)
  POST /api/v1/telegram/webhook
  - X-Telegram-Bot-Api-Secret-Token header 驗證
  - TELEGRAM_WEBHOOK_SECRET="" → dev 跳過(不 break 現有測試)
  - WS4 Hermes NL 接入預留佔位

## T3.4 config.py
- 新增 TELEGRAM_WEBHOOK_SECRET field(預設空字串)

## main.py
- 掛載 telegram_webhook_v1.router 到 /api/v1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-25 01:55:04 +08:00
parent ed3ba730a1
commit 294e0e3387
4 changed files with 143 additions and 0 deletions

View File

@@ -317,6 +317,36 @@ class TelegramSecurityInterceptor:
if not self._initialized:
await self.initialize()
# =======================================================================
# Step 0: ADR-093 Bound User Check群組 CSRF 防護)
# 若 Redis 中存有 cb_bind:{nonce},則嚴格比對 user_id
# 若 key 不存在(舊格式 nonce 或 Redis 暫時不可用)→ 跳過,繼續走 whitelist。
# 2026-04-24 Claude Sonnet 4.6 (ADR-093 WS3)
# =======================================================================
if nonce:
try:
redis = self._nonce_store._redis_client
if redis is not None and self._nonce_store._use_redis:
bind_key = f"cb_bind:{nonce}"
bound_raw = await redis.get(bind_key)
if bound_raw is not None:
bound_id = int(bound_raw)
if user_id != bound_id:
logger.warning(
"telegram_callback_rejected_wrong_user",
user_id=user_id,
bound_user_id=bound_id,
callback_id=callback_id,
)
raise UserNotWhitelistedError(
f"User {user_id} not bound to this approval (bound={bound_id})"
)
except UserNotWhitelistedError:
raise
except Exception as exc:
# Redis 暫時不可用 → 降級繼續走 whitelist
logger.warning("callback_bound_check_failed", error=str(exc))
# =======================================================================
# Step 1: 白名單驗證
# =======================================================================
@@ -391,6 +421,33 @@ class TelegramSecurityInterceptor:
nonce=nonce,
)
async def bind_callback_user(self, nonce: str, user_id: int) -> None:
"""
非同步綁定 callback nonce 到指定 user_idADR-093 群組 CSRF 防護)
在 SRE 群組場景中,為指定人員綁定此 nonce
handler 驗證時會確認 caller == bound_user_id。
Redis 不可用時優雅降級(繼續走 whitelist check
Args:
nonce: generate_callback_nonce 產生的 4-part nonce
user_id: 被綁定的 Telegram user_id僅此人可點按
"""
# 2026-04-24 Claude Sonnet 4.6 (ADR-093 WS3): bound user binding
try:
redis = self._nonce_store._redis_client
if redis is not None and self._nonce_store._use_redis:
bind_key = f"cb_bind:{nonce}"
await redis.setex(bind_key, 172800, str(user_id)) # TTL=48h
logger.debug(
"callback_user_bound",
user_id=user_id,
nonce_prefix=nonce[:16],
)
except Exception as exc:
# Redis unavailable → 降級無 binding依然走 whitelist check
logger.warning("callback_user_bind_failed", error=str(exc))
def generate_callback_nonce(self, approval_id: str, action: str) -> str:
"""
產生 Callback Nonce (嵌入到 callback_data)