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:
76
apps/api/src/api/v1/telegram_webhook.py
Normal file
76
apps/api/src/api/v1/telegram_webhook.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Telegram Webhook Handler — ADR-094
|
||||
===================================
|
||||
接收 Telegram Bot API 的 webhook push,支援 secret_token 驗證。
|
||||
WS4 Hermes NL 接入點框架(目前只記錄 update,不執行動作)。
|
||||
|
||||
2026-04-24 Claude Sonnet 4.6 (ADR-093 WS3 / ADR-094)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/telegram", tags=["Telegram Webhook"])
|
||||
|
||||
|
||||
def _verify_secret_token(
|
||||
x_telegram_bot_api_secret_token: Optional[str] = Header(None),
|
||||
) -> None:
|
||||
"""
|
||||
驗證 Telegram Webhook Secret Token(ADR-094 P0-2 修)
|
||||
|
||||
若 TELEGRAM_WEBHOOK_SECRET 未配置(空字串)→ 跳過驗證(dev 環境相容)。
|
||||
生產環境必須配置此 secret,與 setWebhook 呼叫時的 secret_token 相同。
|
||||
"""
|
||||
expected = getattr(settings, "TELEGRAM_WEBHOOK_SECRET", "")
|
||||
if not expected:
|
||||
# dev 環境:未配置 secret → 跳過驗證
|
||||
return
|
||||
if x_telegram_bot_api_secret_token != expected:
|
||||
logger.warning(
|
||||
"telegram_webhook_invalid_secret",
|
||||
provided=bool(x_telegram_bot_api_secret_token),
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Invalid webhook secret")
|
||||
|
||||
|
||||
@router.post("/webhook", dependencies=[Depends(_verify_secret_token)])
|
||||
async def telegram_webhook(request: Request) -> dict:
|
||||
"""
|
||||
接收 Telegram Bot API webhook update。
|
||||
|
||||
目前:記錄 update 類型 + 回傳 200 OK。
|
||||
WS4 Hermes NL:在此呼叫 Hermes NL router 處理自然語言指令。
|
||||
|
||||
Telegram Bot API 要求此端點在 1 秒內回傳 200,耗時操作須非同步排程。
|
||||
"""
|
||||
body = await request.json()
|
||||
|
||||
if "message" in body:
|
||||
update_type = "message"
|
||||
elif "callback_query" in body:
|
||||
update_type = "callback_query"
|
||||
elif "edited_message" in body:
|
||||
update_type = "edited_message"
|
||||
elif "channel_post" in body:
|
||||
update_type = "channel_post"
|
||||
else:
|
||||
update_type = "other"
|
||||
|
||||
logger.info(
|
||||
"telegram_webhook_received",
|
||||
update_type=update_type,
|
||||
update_id=body.get("update_id"),
|
||||
)
|
||||
|
||||
# WS4: 在此呼叫 Hermes NL router
|
||||
# hermes_router.dispatch(body)
|
||||
|
||||
return {"ok": True}
|
||||
@@ -455,6 +455,12 @@ class Settings(BaseSettings):
|
||||
default="",
|
||||
description="HMAC secret for webhook signature verification",
|
||||
)
|
||||
# 2026-04-24 Claude Sonnet 4.6 (ADR-094): Telegram Webhook Secret Token
|
||||
# 與 setWebhook API 呼叫時的 secret_token 相同;空字串 → dev 環境跳過驗證
|
||||
TELEGRAM_WEBHOOK_SECRET: str = Field(
|
||||
default="",
|
||||
description="Telegram Webhook Secret Token(setWebhook 設定的同一值)",
|
||||
)
|
||||
WEBHOOK_NONCE_TTL: int = Field(
|
||||
default=300,
|
||||
description="Nonce TTL in seconds for replay attack prevention",
|
||||
|
||||
@@ -68,6 +68,7 @@ from src.api.v1 import monitoring as monitoring_v1 # 2026-04-03: 監控工具
|
||||
from src.api.v1 import notifications as notifications_v1 # 2026-04-10: 通知頻道狀態
|
||||
from src.api.v1 import stats as stats_v1 # Phase 6.5: Statistics Analytics
|
||||
from src.api.v1 import telegram as telegram_v1 # Phase 5.4: Telegram Gateway
|
||||
from src.api.v1 import telegram_webhook as telegram_webhook_v1 # ADR-094: Webhook入口
|
||||
from src.api.v1 import terminal as terminal_v1 # Phase 19.1: Omni-Terminal SSE
|
||||
from src.api.v1 import timeline as timeline_v1
|
||||
from src.api.v1 import webhooks as webhooks_v1
|
||||
@@ -719,6 +720,9 @@ app.include_router(
|
||||
app.include_router(
|
||||
telegram_v1.router, prefix="/api/v1", tags=["Telegram Gateway"]
|
||||
) # Phase 5.4
|
||||
app.include_router(
|
||||
telegram_webhook_v1.router, prefix="/api/v1", tags=["Telegram Webhook"]
|
||||
) # ADR-094: Webhook 入口(WS4 Hermes NL 預留)
|
||||
app.include_router(
|
||||
metrics_v1.router, prefix="/api/v1", tags=["Gold Metrics"]
|
||||
) # Phase 7: 真實血脈
|
||||
|
||||
@@ -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_id(ADR-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)
|
||||
|
||||
Reference in New Issue
Block a user