| from __future__ import annotations |
|
|
| import hashlib |
| import hmac |
| import json |
| from urllib.parse import parse_qs, unquote |
|
|
| from fastapi import Header, HTTPException, status |
|
|
| from app.core.config import get_settings, is_user_allowed |
|
|
| _DEV_USER_ID = 377324522 |
|
|
|
|
| def _extract_user_id(init_data: str) -> int: |
| """Parse user.id from an initData query string without verifying the signature.""" |
| parsed = parse_qs(init_data) |
| user_data = parsed.get("user", [""])[0] |
| if not user_data: |
| raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing user") |
| try: |
| user_obj = json.loads(unquote(user_data)) |
| return int(user_obj["id"]) |
| except (json.JSONDecodeError, KeyError, ValueError) as exc: |
| raise HTTPException(status.HTTP_401_UNAUTHORIZED, "bad user data") from exc |
|
|
|
|
| def _validate_init_data(init_data: str, bot_token: str) -> int: |
| """Verify Telegram Mini App initData HMAC-SHA256 and return the user_id.""" |
| parsed = parse_qs(init_data) |
| check_hash = parsed.get("hash", [""])[0] |
| if not check_hash: |
| raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing hash") |
|
|
| data_pairs = [] |
| for key_val in init_data.split("&"): |
| key, _, val = key_val.partition("=") |
| if key != "hash": |
| data_pairs.append(f"{key}={unquote(val)}") |
| data_pairs.sort() |
| data_check_string = "\n".join(data_pairs) |
|
|
| secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest() |
| computed = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() |
|
|
| if not hmac.compare_digest(computed, check_hash): |
| raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid hash") |
|
|
| return _extract_user_id(init_data) |
|
|
|
|
| def _is_dev_mode() -> bool: |
| """True when running behind ngrok (local dev tunnel).""" |
| return "ngrok" in (get_settings().webhook_base_url or "") |
|
|
|
|
| def resolve_user_id(init_data: str | None) -> int: |
| """Resolve the Telegram user_id from Mini App initData. |
| |
| With initData present: verify the HMAC in production; in dev (ngrok) parse the |
| real user.id leniently so each Telegram user keeps a separate account instead of |
| all collapsing onto the dev fallback. Without initData: dev returns a fixed id, |
| production rejects with 401. |
| """ |
| if init_data: |
| if _is_dev_mode(): |
| return _extract_user_id(init_data) |
| token = get_settings().telegram_bot_token.get_secret_value() |
| return _validate_init_data(init_data, token) |
| if _is_dev_mode(): |
| return _DEV_USER_ID |
| raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing initData") |
|
|
|
|
| async def get_current_user_id( |
| x_telegram_init_data: str | None = Header(default=None), |
| ) -> int: |
| """FastAPI dependency: resolve the Telegram user_id and enforce the allowlist.""" |
| user_id = resolve_user_id(x_telegram_init_data) |
| if not is_user_allowed(user_id): |
| raise HTTPException(status.HTTP_403_FORBIDDEN, "user not allowed") |
| return user_id |
|
|