Add Telegram Auth and Dev mode
This commit is contained in:
+1
-1
@@ -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_HOST=127.0.0.1
|
||||||
MONGO_PORT=27017
|
MONGO_PORT=27017
|
||||||
|
|||||||
+44
-18
@@ -17,20 +17,20 @@ from rabbit_worker import RabbitWorker
|
|||||||
from tools.base_moderation import moderate_text
|
from tools.base_moderation import moderate_text
|
||||||
from logger import logger, setup_logging
|
from logger import logger, setup_logging
|
||||||
from middleware import RequestLoggingMiddleware
|
from middleware import RequestLoggingMiddleware
|
||||||
|
from tg_auth import get_current_user_id, DEV_MODE
|
||||||
|
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
|
|
||||||
|
|
||||||
app: FastAPI = FastAPI(
|
app: FastAPI = FastAPI(
|
||||||
title="This OR That",
|
title="This OR That",
|
||||||
summary="OpenAPI schema for \"This OR That\" project!",
|
summary="OpenAPI schema for \"This OR That\" project!",
|
||||||
version="0.1",
|
version="0.1",
|
||||||
contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
|
contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
|
||||||
docs_url=None if disable_docs else "/docs",
|
docs_url="/docs" if DEV_MODE else None,
|
||||||
redoc_url=None if disable_docs else "/redoc",
|
redoc_url="/redoc" if DEV_MODE else None,
|
||||||
openapi_url=None if disable_docs else "/openapi.json",
|
openapi_url="/openapi.json" if DEV_MODE else None,
|
||||||
)
|
)
|
||||||
config = SecurityConfig(
|
config = SecurityConfig(
|
||||||
enable_rate_limiting=True,
|
enable_rate_limiting=True,
|
||||||
@@ -38,7 +38,6 @@ config = SecurityConfig(
|
|||||||
rate_limit_window=3, # TODO: check rate limits in real usage
|
rate_limit_window=3, # TODO: check rate limits in real usage
|
||||||
enable_redis=False,
|
enable_redis=False,
|
||||||
enable_ip_banning=True,
|
enable_ip_banning=True,
|
||||||
custom_log_file="security.log",
|
|
||||||
|
|
||||||
enable_penetration_detection=True,
|
enable_penetration_detection=True,
|
||||||
auto_ban_threshold=3,
|
auto_ban_threshold=3,
|
||||||
@@ -88,6 +87,8 @@ async def verify_moderation_secret(
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
await mongo_worker.create_indexes()
|
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")
|
logger.info("Application started on :5000")
|
||||||
|
|
||||||
|
|
||||||
@@ -114,10 +115,14 @@ async def get_user(
|
|||||||
async def add_user(
|
async def add_user(
|
||||||
new_user: AddUserBody,
|
new_user: AddUserBody,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
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(
|
result = await mongo.add_user(
|
||||||
new_user.user_id,
|
user_id,
|
||||||
new_user.username,
|
new_user.username,
|
||||||
new_user.first_name,
|
new_user.first_name,
|
||||||
new_user.last_name,
|
new_user.last_name,
|
||||||
@@ -142,10 +147,14 @@ async def get_card(
|
|||||||
@app.get("/get_random_cards", status_code=200)
|
@app.get("/get_random_cards", status_code=200)
|
||||||
@guard_deco.rate_limit(requests=5, window=60)
|
@guard_deco.rate_limit(requests=5, window=60)
|
||||||
async def get_random_cards(
|
async def get_random_cards(
|
||||||
user_id: int,
|
|
||||||
response: Response,
|
response: Response,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
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:
|
if cards_visited.error:
|
||||||
response.status_code = status.HTTP_404_NOT_FOUND
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
@@ -164,9 +173,13 @@ async def get_random_cards(
|
|||||||
async def add_card(
|
async def add_card(
|
||||||
new_card: AddCardBody,
|
new_card: AddCardBody,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
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):
|
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:
|
try:
|
||||||
await get_rabbit_worker().send_to_moderation(card)
|
await get_rabbit_worker().send_to_moderation(card)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -200,17 +213,18 @@ async def card_reject(
|
|||||||
async def select_choice(
|
async def select_choice(
|
||||||
choice_data: SelectChoice,
|
choice_data: SelectChoice,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
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.
|
# 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
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
return BaseResponse(result="User doesn't exist", error=True)
|
return BaseResponse(result="User doesn't exist", error=True)
|
||||||
|
|
||||||
# Atomically mark the card as visited.
|
# Atomically mark the card as visited.
|
||||||
# try_mark_visited uses a conditional MongoDB filter ($ne) so that only one
|
newly_visited = await mongo.try_mark_visited(user_id, choice_data.card_id)
|
||||||
# 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)
|
|
||||||
if not newly_visited:
|
if not newly_visited:
|
||||||
response.status_code = status.HTTP_403_FORBIDDEN
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
return BaseResponse(result="Card already visited!", error=True)
|
return BaseResponse(result="Card already visited!", error=True)
|
||||||
@@ -227,8 +241,12 @@ async def select_choice(
|
|||||||
async def like_card(
|
async def like_card(
|
||||||
like_data: ReactionCard,
|
like_data: ReactionCard,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
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:
|
if not result.error and result.result:
|
||||||
return BaseResponse(result="Added like to card")
|
return BaseResponse(result="Added like to card")
|
||||||
response.status_code = status.HTTP_404_NOT_FOUND
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
@@ -238,8 +256,12 @@ async def like_card(
|
|||||||
async def dislike_card(
|
async def dislike_card(
|
||||||
dislike_data: ReactionCard,
|
dislike_data: ReactionCard,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
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:
|
if not result.error and result.result:
|
||||||
return BaseResponse(result="Added dislike to card")
|
return BaseResponse(result="Added dislike to card")
|
||||||
response.status_code = status.HTTP_404_NOT_FOUND
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
@@ -250,12 +272,16 @@ async def dislike_card(
|
|||||||
async def comment(
|
async def comment(
|
||||||
comment_info: AddCommentBody,
|
comment_info: AddCommentBody,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
auth_user_id: Optional[int] = Depends(get_current_user_id),
|
||||||
mongo: MongoWorker = Depends(lambda: mongo_worker),) -> BaseResponse:
|
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):
|
if not moderate_text(comment_info.comment_text):
|
||||||
response.status_code = status.HTTP_400_BAD_REQUEST
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
return BaseResponse(result="Comment has not passed base moderation", error=True)
|
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"]:
|
if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]:
|
||||||
response.status_code = status.HTTP_404_NOT_FOUND
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import typing
|
|||||||
|
|
||||||
from pydantic import BaseModel, NonNegativeInt
|
from pydantic import BaseModel, NonNegativeInt
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class BaseResponse(BaseModel):
|
class BaseResponse(BaseModel):
|
||||||
result: typing.Any
|
result: typing.Any
|
||||||
error: bool = False
|
error: bool = False
|
||||||
|
|
||||||
class AddUserBody(BaseModel):
|
class AddUserBody(BaseModel):
|
||||||
user_id: NonNegativeInt
|
user_id: Optional[NonNegativeInt] = None
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
first_name: str
|
first_name: str
|
||||||
@@ -19,20 +21,20 @@ class AddCardBody(BaseModel):
|
|||||||
choice_A: str
|
choice_A: str
|
||||||
choice_B: str
|
choice_B: str
|
||||||
|
|
||||||
author_id: NonNegativeInt
|
author_id: Optional[NonNegativeInt] = None
|
||||||
|
|
||||||
class SelectChoice(BaseModel):
|
class SelectChoice(BaseModel):
|
||||||
user_id: NonNegativeInt
|
user_id: Optional[NonNegativeInt] = None
|
||||||
card_id: NonNegativeInt
|
card_id: NonNegativeInt
|
||||||
|
|
||||||
choice: typing.Literal["A", "B"]
|
choice: typing.Literal["A", "B"]
|
||||||
|
|
||||||
class ReactionCard(BaseModel):
|
class ReactionCard(BaseModel):
|
||||||
user_id: NonNegativeInt
|
user_id: Optional[NonNegativeInt] = None
|
||||||
card_id: NonNegativeInt
|
card_id: NonNegativeInt
|
||||||
|
|
||||||
class AddCommentBody(BaseModel):
|
class AddCommentBody(BaseModel):
|
||||||
author_id: NonNegativeInt
|
author_id: Optional[NonNegativeInt] = None
|
||||||
card_id: NonNegativeInt
|
card_id: NonNegativeInt
|
||||||
|
|
||||||
comment_text: str
|
comment_text: str
|
||||||
@@ -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
|
||||||
+172
@@ -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"]
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
mongodb:
|
mongodb:
|
||||||
image: mongodb/mongodb-community-server
|
image: mongo
|
||||||
container_name: tort-mongodb
|
container_name: tort-mongodb
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
RABBIT_USER: ${RABBIT_USER}
|
RABBIT_USER: ${RABBIT_USER}
|
||||||
RABBIT_PASS: ${RABBIT_PASS}
|
RABBIT_PASS: ${RABBIT_PASS}
|
||||||
MODERATION_SECRET: ${MODERATION_SECRET}
|
MODERATION_SECRET: ${MODERATION_SECRET}
|
||||||
DISABLE_DOCS: ${DISABLE_DOCS:-true}
|
DEV_MODE: ${DEV_MODE:-false}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5000:5000"
|
- "127.0.0.1:5000:5000"
|
||||||
|
|||||||
Reference in New Issue
Block a user