- Improved formatting and readability in config.py and other files by adding line breaks. - Introduced INIT_DATA_MAX_AGE_SECONDS to enforce replay protection for Telegram initData. - Updated validate_init_data function to include max_age_seconds parameter for validation. - Enhanced API to reject old initData based on the new max_age_seconds setting. - Added tests for auth_date expiry and validation of initData in test_telegram_auth.py. - Updated README with details on the new INIT_DATA_MAX_AGE_SECONDS configuration.
82 lines
2.8 KiB
Python
82 lines
2.8 KiB
Python
"""Validate Telegram Web App initData and extract user username."""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import time
|
|
from urllib.parse import unquote
|
|
|
|
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
|
# Data-check string must use the same key=value pairs as received (sorted by key); we preserve raw values.
|
|
|
|
|
|
def validate_init_data(
|
|
init_data: str,
|
|
bot_token: str,
|
|
max_age_seconds: int | None = None,
|
|
) -> str | None:
|
|
"""
|
|
Validate initData signature and return the Telegram username (lowercase, no @).
|
|
Returns None if data is invalid, forged, or user has no username.
|
|
|
|
If max_age_seconds is set, initData must include auth_date and it must be no older
|
|
than max_age_seconds (replay protection). Example: 86400 = 24 hours.
|
|
"""
|
|
if not init_data or not bot_token:
|
|
return None
|
|
init_data = init_data.strip()
|
|
# Parse preserving raw values for HMAC (key -> raw value)
|
|
params = {}
|
|
for part in init_data.split("&"):
|
|
if "=" not in part:
|
|
continue
|
|
key, _, value = part.partition("=")
|
|
if not key:
|
|
continue
|
|
params[key] = value
|
|
hash_val = params.pop("hash", None)
|
|
if not hash_val:
|
|
return None
|
|
# Build data-check string: keys sorted, format key=value per line (raw values)
|
|
data_pairs = sorted(params.items())
|
|
data_string = "\n".join(f"{k}={v}" for k, v in data_pairs)
|
|
# secret_key = HMAC-SHA256(key=b"WebAppData", msg=bot_token.encode()).digest()
|
|
secret_key = hmac.new(
|
|
b"WebAppData",
|
|
msg=bot_token.encode(),
|
|
digestmod=hashlib.sha256,
|
|
).digest()
|
|
# computed = HMAC-SHA256(key=secret_key, msg=data_string.encode()).hexdigest()
|
|
computed = hmac.new(
|
|
secret_key,
|
|
msg=data_string.encode(),
|
|
digestmod=hashlib.sha256,
|
|
).hexdigest()
|
|
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
|
|
return None
|
|
# Optional replay protection: reject initData older than max_age_seconds
|
|
if max_age_seconds is not None and max_age_seconds > 0:
|
|
auth_date_raw = params.get("auth_date")
|
|
if not auth_date_raw:
|
|
return None
|
|
try:
|
|
auth_date = int(float(auth_date_raw))
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if time.time() - auth_date > max_age_seconds:
|
|
return None
|
|
# Parse user JSON (value may be URL-encoded in the raw string)
|
|
user_raw = params.get("user")
|
|
if not user_raw:
|
|
return None
|
|
try:
|
|
user = json.loads(unquote(user_raw))
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
if not isinstance(user, dict):
|
|
return None
|
|
username = user.get("username")
|
|
if not username or not isinstance(username, str):
|
|
return None
|
|
return username.strip().lstrip("@").lower()
|