From 90ed19b2f679a4ff4a6debe74a4b15f43a2bf1f3 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 31 Aug 2026 13:34:47 +0300 Subject: [PATCH] Add Telegram Auth and Dev mode --- .env_example | 2 +- app/main.py | 62 +++++++++---- app/schemas/api_schemas.py | 12 +-- app/tests/test_tg_auth.py | 150 ++++++++++++++++++++++++++++++++ app/tg_auth.py | 172 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 4 +- 6 files changed, 376 insertions(+), 26 deletions(-) create mode 100644 app/tests/test_tg_auth.py create mode 100644 app/tg_auth.py diff --git a/.env_example b/.env_example index 4f1ec9e..dfb56d3 100644 --- a/.env_example +++ b/.env_example @@ -1,4 +1,4 @@ -DISABLE_DOCS=true +DEV_MODE=true # true = docs enabled + auth disabled; false = production mode MONGO_HOST=127.0.0.1 MONGO_PORT=27017 diff --git a/app/main.py b/app/main.py index f611de1..8e408c0 100644 --- a/app/main.py +++ b/app/main.py @@ -17,20 +17,20 @@ from rabbit_worker import RabbitWorker from tools.base_moderation import moderate_text from logger import logger, setup_logging from middleware import RequestLoggingMiddleware +from tg_auth import get_current_user_id, DEV_MODE setup_logging() load_dotenv() -disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" app: FastAPI = FastAPI( title="This OR That", summary="OpenAPI schema for \"This OR That\" project!", version="0.1", contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"}, - docs_url=None if disable_docs else "/docs", - redoc_url=None if disable_docs else "/redoc", - openapi_url=None if disable_docs else "/openapi.json", + docs_url="/docs" if DEV_MODE else None, + redoc_url="/redoc" if DEV_MODE else None, + openapi_url="/openapi.json" if DEV_MODE else None, ) config = SecurityConfig( enable_rate_limiting=True, @@ -38,7 +38,6 @@ config = SecurityConfig( rate_limit_window=3, # TODO: check rate limits in real usage enable_redis=False, enable_ip_banning=True, - custom_log_file="security.log", enable_penetration_detection=True, auto_ban_threshold=3, @@ -88,6 +87,8 @@ async def verify_moderation_secret( @app.on_event("startup") async def startup_event(): await mongo_worker.create_indexes() + if DEV_MODE: + logger.warning("⚠️ DEV_MODE is enabled — docs are exposed and Telegram initData auth is DISABLED") logger.info("Application started on :5000") @@ -114,10 +115,14 @@ async def get_user( async def add_user( new_user: AddUserBody, response: Response, + auth_user_id: Optional[int] = Depends(get_current_user_id), mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: - if not await mongo.check_user(new_user.user_id): + user_id = auth_user_id if auth_user_id is not None else new_user.user_id + if user_id is None: + raise HTTPException(status_code=422, detail="user_id is required") + if not await mongo.check_user(user_id): result = await mongo.add_user( - new_user.user_id, + user_id, new_user.username, new_user.first_name, new_user.last_name, @@ -142,10 +147,14 @@ async def get_card( @app.get("/get_random_cards", status_code=200) @guard_deco.rate_limit(requests=5, window=60) async def get_random_cards( - user_id: int, response: Response, + user_id: Optional[int] = None, + auth_user_id: Optional[int] = Depends(get_current_user_id), mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: - cards_visited = await mongo.get_visited_cards(user_id) + resolved_user_id = auth_user_id if auth_user_id is not None else user_id + if resolved_user_id is None: + raise HTTPException(status_code=422, detail="user_id is required") + cards_visited = await mongo.get_visited_cards(resolved_user_id) if cards_visited.error: response.status_code = status.HTTP_404_NOT_FOUND @@ -164,9 +173,13 @@ async def get_random_cards( async def add_card( new_card: AddCardBody, response: Response, + auth_user_id: Optional[int] = Depends(get_current_user_id), mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + author_id = auth_user_id if auth_user_id is not None else new_card.author_id + if author_id is None: + raise HTTPException(status_code=422, detail="author_id is required") if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B): - card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, new_card.author_id) + card = await mongo.add_card_by_api(new_card.choice_A, new_card.choice_B, author_id) try: await get_rabbit_worker().send_to_moderation(card) except Exception as exc: @@ -200,17 +213,18 @@ async def card_reject( async def select_choice( choice_data: SelectChoice, response: Response, + auth_user_id: Optional[int] = Depends(get_current_user_id), mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + user_id = auth_user_id if auth_user_id is not None else choice_data.user_id + if user_id is None: + raise HTTPException(status_code=422, detail="user_id is required") # Verify that the user exists before proceeding. - if not await mongo.check_user(choice_data.user_id): + if not await mongo.check_user(user_id): response.status_code = status.HTTP_404_NOT_FOUND return BaseResponse(result="User doesn't exist", error=True) # Atomically mark the card as visited. - # try_mark_visited uses a conditional MongoDB filter ($ne) so that only one - # concurrent request can "win" — eliminating the TOCTOU race condition where - # two parallel requests both pass the visited-check before either writes. - newly_visited = await mongo.try_mark_visited(choice_data.user_id, choice_data.card_id) + newly_visited = await mongo.try_mark_visited(user_id, choice_data.card_id) if not newly_visited: response.status_code = status.HTTP_403_FORBIDDEN return BaseResponse(result="Card already visited!", error=True) @@ -227,8 +241,12 @@ async def select_choice( async def like_card( like_data: ReactionCard, response: Response, + auth_user_id: Optional[int] = Depends(get_current_user_id), mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: - result = await mongo.like_card(like_data.card_id, like_data.user_id) + user_id = auth_user_id if auth_user_id is not None else like_data.user_id + if user_id is None: + raise HTTPException(status_code=422, detail="user_id is required") + result = await mongo.like_card(like_data.card_id, user_id) if not result.error and result.result: return BaseResponse(result="Added like to card") response.status_code = status.HTTP_404_NOT_FOUND @@ -238,8 +256,12 @@ async def like_card( async def dislike_card( dislike_data: ReactionCard, response: Response, + auth_user_id: Optional[int] = Depends(get_current_user_id), mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: - result = await mongo.dislike_card(dislike_data.card_id, dislike_data.user_id) + user_id = auth_user_id if auth_user_id is not None else dislike_data.user_id + if user_id is None: + raise HTTPException(status_code=422, detail="user_id is required") + result = await mongo.dislike_card(dislike_data.card_id, user_id) if not result.error and result.result: return BaseResponse(result="Added dislike to card") response.status_code = status.HTTP_404_NOT_FOUND @@ -250,12 +272,16 @@ async def dislike_card( async def comment( comment_info: AddCommentBody, response: Response, + auth_user_id: Optional[int] = Depends(get_current_user_id), mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse: + author_id = auth_user_id if auth_user_id is not None else comment_info.author_id + if author_id is None: + raise HTTPException(status_code=422, detail="author_id is required") if not moderate_text(comment_info.comment_text): response.status_code = status.HTTP_400_BAD_REQUEST return BaseResponse(result="Comment has not passed base moderation", error=True) - result = await mongo.add_comment(comment_info.author_id, comment_info.card_id, comment_info.comment_text) + result = await mongo.add_comment(author_id, comment_info.card_id, comment_info.comment_text) if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]: response.status_code = status.HTTP_404_NOT_FOUND return result diff --git a/app/schemas/api_schemas.py b/app/schemas/api_schemas.py index 745d791..605d83a 100644 --- a/app/schemas/api_schemas.py +++ b/app/schemas/api_schemas.py @@ -2,13 +2,15 @@ import typing from pydantic import BaseModel, NonNegativeInt +from typing import Optional + class BaseResponse(BaseModel): result: typing.Any error: bool = False class AddUserBody(BaseModel): - user_id: NonNegativeInt + user_id: Optional[NonNegativeInt] = None username: str first_name: str @@ -19,20 +21,20 @@ class AddCardBody(BaseModel): choice_A: str choice_B: str - author_id: NonNegativeInt + author_id: Optional[NonNegativeInt] = None class SelectChoice(BaseModel): - user_id: NonNegativeInt + user_id: Optional[NonNegativeInt] = None card_id: NonNegativeInt choice: typing.Literal["A", "B"] class ReactionCard(BaseModel): - user_id: NonNegativeInt + user_id: Optional[NonNegativeInt] = None card_id: NonNegativeInt class AddCommentBody(BaseModel): - author_id: NonNegativeInt + author_id: Optional[NonNegativeInt] = None card_id: NonNegativeInt comment_text: str \ No newline at end of file diff --git a/app/tests/test_tg_auth.py b/app/tests/test_tg_auth.py new file mode 100644 index 0000000..7b66c26 --- /dev/null +++ b/app/tests/test_tg_auth.py @@ -0,0 +1,150 @@ +""" +Tests for Telegram initData HMAC-SHA256 validation (tg_auth module). + +These tests directly exercise the ``validate_init_data`` function with +synthetic initData, covering happy-path and all failure modes. +""" + +import hashlib +import hmac +import json +import time +from urllib.parse import urlencode + +import pytest +from fastapi import HTTPException + +from tg_auth import validate_init_data + +BOT_TOKEN = "7765587867:AAHYpUR_XHEZ1YjCKCAgjWaOiepeDY4XtPA" + + +def _build_init_data( + bot_token: str, + user: dict, + auth_date: int | None = None, + tamper_hash: bool = False, + omit_hash: bool = False, + omit_user: bool = False, +) -> str: + """Helper that constructs a valid (or intentionally broken) initData string.""" + if auth_date is None: + auth_date = int(time.time()) + + params: dict[str, str] = { + "auth_date": str(auth_date), + } + if not omit_user: + params["user"] = json.dumps(user) + + # Build data-check-string (sorted, \n-separated). + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(params.items())) + + # secret_key = HMAC-SHA256("WebAppData", bot_token) + secret_key = hmac.new( + key=b"WebAppData", + msg=bot_token.encode(), + digestmod=hashlib.sha256, + ).digest() + + computed_hash = hmac.new( + key=secret_key, + msg=data_check_string.encode(), + digestmod=hashlib.sha256, + ).hexdigest() + + if tamper_hash: + computed_hash = "a" * 64 # obviously wrong + + if not omit_hash: + params["hash"] = computed_hash + + return urlencode(params) + + +VALID_USER = { + "id": 123456789, + "first_name": "Igor", + "last_name": "Volochay", + "username": "IgorVolochay", + "photo_url": "https://t.me/photo.jpg", +} + + +# ── Happy path ────────────────────────────────────────────────────────── + + +def test_valid_init_data(): + raw = _build_init_data(BOT_TOKEN, VALID_USER) + result = validate_init_data(raw, BOT_TOKEN) + assert result["user_id"] == 123456789 + assert result["username"] == "IgorVolochay" + assert result["first_name"] == "Igor" + assert result["last_name"] == "Volochay" + assert result["photo_url"] == "https://t.me/photo.jpg" + + +# ── Failure modes ─────────────────────────────────────────────────────── + + +def test_empty_init_data(): + with pytest.raises(HTTPException) as exc: + validate_init_data("", BOT_TOKEN) + assert exc.value.status_code == 403 + + +def test_missing_hash(): + raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_hash=True) + with pytest.raises(HTTPException) as exc: + validate_init_data(raw, BOT_TOKEN) + assert exc.value.status_code == 403 + assert "hash" in str(exc.value.detail).lower() + + +def test_tampered_hash(): + raw = _build_init_data(BOT_TOKEN, VALID_USER, tamper_hash=True) + with pytest.raises(HTTPException) as exc: + validate_init_data(raw, BOT_TOKEN) + assert exc.value.status_code == 403 + assert "signature" in str(exc.value.detail).lower() + + +def test_expired_auth_date(): + old_date = int(time.time()) - 7200 # 2 hours ago + raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=old_date) + with pytest.raises(HTTPException) as exc: + validate_init_data(raw, BOT_TOKEN, max_age=3600) + assert exc.value.status_code == 403 + assert "expired" in str(exc.value.detail).lower() + + +def test_missing_user(): + raw = _build_init_data(BOT_TOKEN, VALID_USER, omit_user=True) + with pytest.raises(HTTPException) as exc: + validate_init_data(raw, BOT_TOKEN) + assert exc.value.status_code == 403 + assert "user" in str(exc.value.detail).lower() + + +def test_missing_user_id(): + user_no_id = {"first_name": "Igor", "username": "test"} + raw = _build_init_data(BOT_TOKEN, user_no_id) + with pytest.raises(HTTPException) as exc: + validate_init_data(raw, BOT_TOKEN) + assert exc.value.status_code == 403 + assert "user.id" in str(exc.value.detail).lower() + + +def test_wrong_bot_token(): + raw = _build_init_data(BOT_TOKEN, VALID_USER) + with pytest.raises(HTTPException) as exc: + validate_init_data(raw, "wrong:token") + assert exc.value.status_code == 403 + + +def test_fresh_auth_date_passes(): + """auth_date exactly 5 seconds ago should be fine with default max_age.""" + recent = int(time.time()) - 5 + raw = _build_init_data(BOT_TOKEN, VALID_USER, auth_date=recent) + result = validate_init_data(raw, BOT_TOKEN) + assert result["user_id"] == 123456789 diff --git a/app/tg_auth.py b/app/tg_auth.py new file mode 100644 index 0000000..e1034a9 --- /dev/null +++ b/app/tg_auth.py @@ -0,0 +1,172 @@ +""" +Telegram Mini App initData authentication module. + +Validates initData from the Telegram WebApp using HMAC-SHA256 +per the official specification: +https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app + +In DEV_MODE (default) authentication is skipped — user_id is taken +from the request body / query parameters as-is. +""" + +import hashlib +import hmac +import json +import os +import time +from typing import Optional +from urllib.parse import parse_qs + +from dotenv import load_dotenv +from fastapi import HTTPException, Request, status + +from logger import logger + +load_dotenv() + +DEV_MODE: bool = os.getenv("DEV_MODE", "true").lower() == "true" +TG_BOT_TOKEN: str = os.getenv("TG_BOT_TOKEN", "") + +# Maximum allowed age of initData in seconds (1 hour). +INIT_DATA_MAX_AGE: int = int(os.getenv("INIT_DATA_MAX_AGE", "3600")) + + +def validate_init_data( + init_data_raw: str, + bot_token: str, + max_age: int = INIT_DATA_MAX_AGE, +) -> dict: + """ + Validates Telegram Mini App initData and returns the parsed ``user`` dict. + + Algorithm (per Telegram docs): + 1. Parse the query-string into key→value pairs. + 2. Extract the ``hash`` value; build ``data-check-string`` from the + remaining fields sorted by key, joined with ``\\n``. + 3. ``secret_key = HMAC-SHA256(bot_token, "WebAppData")`` + 4. ``computed = HMAC-SHA256(data_check_string, secret_key)`` + 5. Compare ``computed`` with ``hash`` using constant-time comparison. + 6. Optionally verify ``auth_date`` freshness. + + Returns a dict with keys: user_id, username, first_name, last_name, photo_url. + + Raises ``HTTPException(403)`` on any validation failure. + """ + if not init_data_raw: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Missing initData", + ) + + parsed = parse_qs(init_data_raw, keep_blank_values=True) + + # parse_qs returns lists — flatten to single values. + flat: dict[str, str] = {k: v[0] for k, v in parsed.items()} + + received_hash = flat.pop("hash", None) + if not received_hash: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Missing hash in initData", + ) + + # Build data-check-string: sorted key=value pairs joined by \n. + data_check_string = "\n".join( + f"{k}={v}" for k, v in sorted(flat.items()) + ) + + # secret_key = HMAC-SHA256("WebAppData", bot_token) + secret_key = hmac.new( + key=b"WebAppData", + msg=bot_token.encode(), + digestmod=hashlib.sha256, + ).digest() + + computed_hash = hmac.new( + key=secret_key, + msg=data_check_string.encode(), + digestmod=hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(computed_hash, received_hash): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid initData signature", + ) + + # Verify auth_date freshness. + auth_date_str = flat.get("auth_date") + if auth_date_str: + try: + auth_date = int(auth_date_str) + if time.time() - auth_date > max_age: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="initData expired", + ) + except ValueError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid auth_date", + ) + + # Extract user data. + user_raw = flat.get("user") + if not user_raw: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Missing user in initData", + ) + + try: + user = json.loads(user_raw) + except (json.JSONDecodeError, TypeError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid user JSON in initData", + ) + + user_id = user.get("id") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Missing user.id in initData", + ) + + return { + "user_id": int(user_id), + "username": user.get("username", ""), + "first_name": user.get("first_name", ""), + "last_name": user.get("last_name", ""), + "photo_url": user.get("photo_url", ""), + } + + +async def get_current_user_id(request: Request) -> Optional[int]: + """ + FastAPI dependency that resolves the authenticated user_id. + + - **DEV_MODE=true**: returns ``None`` — endpoints use user_id from + body/params as before (backward compatible). + - **DEV_MODE=false**: reads ``X-Init-Data`` header, validates it + via HMAC-SHA256, and returns the verified ``user_id``. + """ + if DEV_MODE: + return None + + init_data_raw = request.headers.get("X-Init-Data", "") + if not init_data_raw: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="X-Init-Data header is required", + ) + + if not TG_BOT_TOKEN: + logger.error("TG_BOT_TOKEN is not set but DEV_MODE is disabled") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Server authentication misconfiguration", + ) + + user_data = validate_init_data(init_data_raw, TG_BOT_TOKEN) + return user_data["user_id"] diff --git a/docker-compose.yml b/docker-compose.yml index 06a4be9..5032aa0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: mongodb: - image: mongodb/mongodb-community-server + image: mongo container_name: tort-mongodb restart: always environment: @@ -56,7 +56,7 @@ services: RABBIT_USER: ${RABBIT_USER} RABBIT_PASS: ${RABBIT_PASS} MODERATION_SECRET: ${MODERATION_SECRET} - DISABLE_DOCS: ${DISABLE_DOCS:-true} + DEV_MODE: ${DEV_MODE:-false} LOG_LEVEL: ${LOG_LEVEL:-INFO} ports: - "127.0.0.1:5000:5000"