feat: add soft ui auth page
This commit is contained in:
@@ -14,8 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
# Открываем порт
|
||||
EXPOSE 8200
|
||||
EXPOSE 4200
|
||||
|
||||
# Запускаем приложение
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8200"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "4200"]
|
||||
|
||||
|
||||
240
backend/app/api/auth2.py
Normal file
240
backend/app/api/auth2.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Alternative auth endpoint (tg/max/sms) without touching existing flow.
|
||||
|
||||
/api/v1/auth2/login:
|
||||
- platform=tg|max|sms
|
||||
- Validates init_data for TG/MAX and calls n8n webhook
|
||||
- For SMS: verifies code, calls n8n contact webhook, creates session
|
||||
- Returns greeting message
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Literal, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.sms_service import sms_service
|
||||
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
|
||||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||||
from ..config import settings
|
||||
from . import n8n_proxy
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth2", tags=["auth2"])
|
||||
|
||||
|
||||
class Auth2LoginRequest(BaseModel):
|
||||
platform: Literal["tg", "max", "sms"]
|
||||
init_data: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
session_token: Optional[str] = None
|
||||
form_id: str = "ticket_form"
|
||||
|
||||
|
||||
class Auth2LoginResponse(BaseModel):
|
||||
success: bool
|
||||
greeting: str
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_contact: Optional[bool] = None
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
import uuid
|
||||
return f"sess-{uuid.uuid4()}"
|
||||
|
||||
|
||||
@router.post("/login", response_model=Auth2LoginResponse)
|
||||
async def login(request: Auth2LoginRequest):
|
||||
platform = request.platform
|
||||
logger.info("[AUTH2] login: platform=%s", platform)
|
||||
|
||||
if platform == "tg":
|
||||
if not request.init_data:
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен для tg")
|
||||
try:
|
||||
tg_user = extract_telegram_user(request.init_data)
|
||||
except TelegramAuthError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
n8n_payload = {
|
||||
"telegram_user_id": tg_user["telegram_user_id"],
|
||||
"username": tg_user.get("username"),
|
||||
"first_name": tg_user.get("first_name"),
|
||||
"last_name": tg_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": request.form_id,
|
||||
"init_data": request.init_data,
|
||||
}
|
||||
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: Dict[str, Any]):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_telegram_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
))
|
||||
|
||||
first_name = tg_user.get("first_name") or ""
|
||||
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
|
||||
|
||||
return Auth2LoginResponse(
|
||||
success=True,
|
||||
greeting=greeting,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone,
|
||||
has_drafts=has_drafts,
|
||||
avatar_url=tg_user.get("photo_url") or None,
|
||||
)
|
||||
|
||||
if platform == "max":
|
||||
if not request.init_data:
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен для max")
|
||||
try:
|
||||
max_user = extract_max_user(request.init_data)
|
||||
except MaxAuthError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
n8n_payload = {
|
||||
"max_user_id": max_user["max_user_id"],
|
||||
"username": max_user.get("username"),
|
||||
"first_name": max_user.get("first_name"),
|
||||
"last_name": max_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": request.form_id,
|
||||
"init_data": request.init_data,
|
||||
}
|
||||
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: Dict[str, Any]):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
|
||||
need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact")
|
||||
if need_contact:
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
))
|
||||
|
||||
first_name = max_user.get("first_name") or ""
|
||||
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
|
||||
|
||||
return Auth2LoginResponse(
|
||||
success=True,
|
||||
greeting=greeting,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone,
|
||||
has_drafts=has_drafts,
|
||||
avatar_url=max_user.get("photo_url") or None,
|
||||
)
|
||||
|
||||
if platform == "sms":
|
||||
phone = (request.phone or "").strip()
|
||||
code = (request.code or "").strip()
|
||||
if not phone or not code:
|
||||
raise HTTPException(status_code=400, detail="phone и code обязательны для sms")
|
||||
|
||||
is_valid = await sms_service.verify_code(phone, code)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail="Неверный код или код истек")
|
||||
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: Dict[str, Any]):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_payload = {
|
||||
"phone": phone,
|
||||
"session_id": request.session_token or "",
|
||||
"form_id": request.form_id,
|
||||
}
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_create_contact(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
if isinstance(n8n_data, list) and n8n_data:
|
||||
n8n_data = n8n_data[0]
|
||||
|
||||
if not n8n_data or not isinstance(n8n_data, dict) or not n8n_data.get("success"):
|
||||
raise HTTPException(status_code=500, detail="Ошибка создания контакта в n8n")
|
||||
|
||||
result = n8n_data.get("result") or n8n_data
|
||||
unified_id = result.get("unified_id")
|
||||
contact_id = result.get("contact_id")
|
||||
phone_res = result.get("phone") or phone
|
||||
has_drafts = result.get("has_drafts")
|
||||
session_token = result.get("session") or request.session_token or _generate_session_token()
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone_res or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
))
|
||||
|
||||
return Auth2LoginResponse(
|
||||
success=True,
|
||||
greeting="Привет!",
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone_res,
|
||||
has_drafts=has_drafts,
|
||||
avatar_url=None,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Неподдерживаемая платформа")
|
||||
146
backend/app/api/max_auth.py
Normal file
146
backend/app/api/max_auth.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
MAX Mini App (WebApp) auth endpoint.
|
||||
|
||||
/api/v1/max/auth:
|
||||
- Принимает init_data от MAX Bridge (window.WebApp.initData)
|
||||
- Валидирует init_data и извлекает данные пользователя MAX
|
||||
- Проксирует max_user_id в n8n для получения unified_id/контакта
|
||||
- Создаёт сессию в Redis (аналогично Telegram — без SMS)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||||
from ..config import settings
|
||||
from . import n8n_proxy
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/max", tags=["MAX"])
|
||||
|
||||
|
||||
class MaxAuthRequest(BaseModel):
|
||||
init_data: str
|
||||
session_token: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
class MaxAuthResponse(BaseModel):
|
||||
success: bool
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_contact: Optional[bool] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
import uuid
|
||||
return f"sess-{uuid.uuid4()}"
|
||||
|
||||
|
||||
@router.post("/auth", response_model=MaxAuthResponse)
|
||||
async def max_auth(request: MaxAuthRequest):
|
||||
"""
|
||||
Авторизация пользователя через MAX WebApp (Mini App).
|
||||
"""
|
||||
init_data = request.init_data or ""
|
||||
phone = (request.phone or "").strip()
|
||||
logger.info(
|
||||
"[MAX] POST /api/v1/max/auth: init_data длина=%s, phone=%s, session_token=%s",
|
||||
len(init_data),
|
||||
bool(phone),
|
||||
bool(request.session_token),
|
||||
)
|
||||
if not init_data:
|
||||
logger.warning("[MAX] init_data пустой")
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен")
|
||||
|
||||
bot_configured = bool((getattr(settings, "max_bot_token", None) or "").strip())
|
||||
webhook_configured = bool((getattr(settings, "n8n_max_auth_webhook", None) or "").strip())
|
||||
logger.info("[MAX] Конфиг: MAX_BOT_TOKEN=%s, N8N_MAX_AUTH_WEBHOOK=%s", bot_configured, webhook_configured)
|
||||
|
||||
try:
|
||||
max_user = extract_max_user(request.init_data)
|
||||
except MaxAuthError as e:
|
||||
logger.warning("[MAX] Ошибка валидации initData: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
max_user_id = max_user["max_user_id"]
|
||||
logger.info("[MAX] MAX user валиден: id=%s, username=%s", max_user_id, max_user.get("username"))
|
||||
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
|
||||
n8n_payload = {
|
||||
"max_user_id": max_user_id,
|
||||
"username": max_user.get("username"),
|
||||
"first_name": max_user.get("first_name"),
|
||||
"last_name": max_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": "ticket_form",
|
||||
"init_data": request.init_data,
|
||||
}
|
||||
if phone:
|
||||
n8n_payload["phone"] = phone
|
||||
|
||||
logger.info("[MAX] Валидация OK → вызов n8n webhook (max_user_id=%s)", max_user_id)
|
||||
try:
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: dict):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[MAX] Ошибка вызова n8n MAX auth webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||
|
||||
need_contact = n8n_data.get("need_contact") or (n8n_data.get("result") or {}).get("need_contact")
|
||||
if need_contact:
|
||||
logger.info("[MAX] n8n: need_contact — юзер не в базе, закрываем приложение")
|
||||
return MaxAuthResponse(success=False, need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone_res = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.error("[MAX] n8n не вернул unified_id. Ответ: %s", n8n_data)
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для пользователя MAX")
|
||||
|
||||
session_request = session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone_res or phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
)
|
||||
|
||||
try:
|
||||
await session_api.create_session(session_request)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[MAX] Ошибка создания сессии в Redis")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
|
||||
|
||||
return MaxAuthResponse(
|
||||
success=True,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone_res or phone,
|
||||
has_drafts=has_drafts,
|
||||
)
|
||||
@@ -21,6 +21,7 @@ N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None
|
||||
N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook
|
||||
N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook
|
||||
N8N_TG_AUTH_WEBHOOK = settings.n8n_tg_auth_webhook or None
|
||||
N8N_MAX_AUTH_WEBHOOK = getattr(settings, "n8n_max_auth_webhook", None) or None
|
||||
|
||||
|
||||
@router.post("/policy/check")
|
||||
@@ -286,6 +287,54 @@ async def proxy_telegram_auth(request: Request):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка авторизации Telegram: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/max/auth")
|
||||
async def proxy_max_auth(request: Request):
|
||||
"""
|
||||
Проксирует авторизацию MAX WebApp в n8n webhook.
|
||||
Используется /api/v1/max/auth: backend валидирует initData, затем вызывает этот роут.
|
||||
"""
|
||||
if not N8N_MAX_AUTH_WEBHOOK:
|
||||
logger.error("[MAX] N8N_MAX_AUTH_WEBHOOK не задан в .env")
|
||||
raise HTTPException(status_code=500, detail="N8N MAX auth webhook не настроен")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
logger.info(
|
||||
"[MAX] Proxy → n8n: max_user_id=%s, session_token=%s",
|
||||
body.get("max_user_id", "unknown"),
|
||||
body.get("session_token", "unknown"),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
N8N_MAX_AUTH_WEBHOOK,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
logger.info("[MAX] n8n webhook ответ: status=%s, len=%s", response.status_code, len(response_text))
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error("[MAX] Парсинг JSON: %s. Response: %s", e, response_text[:500])
|
||||
raise HTTPException(status_code=500, detail="Ошибка парсинга ответа n8n")
|
||||
|
||||
logger.error("[MAX] n8n вернул %s: %s", response.status_code, response_text[:500])
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"N8N MAX auth error: {response_text}",
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("[MAX] Таймаут n8n MAX auth webhook")
|
||||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (MAX auth)")
|
||||
except Exception as e:
|
||||
logger.exception("[MAX] Ошибка вызова n8n MAX auth: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка авторизации MAX: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/claim/create")
|
||||
async def proxy_create_claim(request: Request):
|
||||
"""
|
||||
|
||||
@@ -191,7 +191,13 @@ class Settings(BaseSettings):
|
||||
# TELEGRAM BOT
|
||||
# ============================================
|
||||
telegram_bot_token: str = "" # Токен бота для проверки initData WebApp
|
||||
|
||||
|
||||
# ============================================
|
||||
# MAX (мессенджер) — Mini App auth
|
||||
# ============================================
|
||||
max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp
|
||||
n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts
|
||||
|
||||
# ============================================
|
||||
# LOGGING
|
||||
# ============================================
|
||||
|
||||
@@ -13,7 +13,7 @@ from .services.rabbitmq_service import rabbitmq_service
|
||||
from .services.policy_service import policy_service
|
||||
from .services.crm_mysql_service import crm_mysql_service
|
||||
from .services.s3_service import s3_service
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
@@ -119,6 +119,8 @@ app.include_router(session.router) # 🔑 Session management через Redis
|
||||
app.include_router(documents.router) # 📄 Documents upload and processing
|
||||
app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
|
||||
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
|
||||
app.include_router(max_auth.router) # 📱 MAX Mini App auth
|
||||
app.include_router(auth2.router) # 🆕 Alt auth endpoint (tg/max/sms)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
111
backend/app/services/max_auth.py
Normal file
111
backend/app/services/max_auth.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
MAX WebApp (Mini App) auth helper.
|
||||
|
||||
Валидация initData от MAX Bridge — тот же алгоритм, что и у Telegram:
|
||||
secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN), data_check_string без hash, сравнение с hash.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaxAuthError(Exception):
|
||||
"""Ошибка проверки подлинности MAX initData."""
|
||||
|
||||
|
||||
def _parse_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""Разбирает строку initData (query string) в словарь."""
|
||||
data: Dict[str, Any] = {}
|
||||
for key, value in parse_qsl(init_data, keep_blank_values=True):
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
def verify_max_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверяет подпись initData по правилам MAX (аналогично Telegram).
|
||||
|
||||
- secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
|
||||
- data_check_string: пары key=value без hash, сортировка по key, разделитель \n
|
||||
- hex(HMAC_SHA256(secret_key, data_check_string)) === hash из initData
|
||||
"""
|
||||
if not init_data:
|
||||
logger.warning("[MAX] verify_max_init_data: init_data пустой")
|
||||
raise MaxAuthError("init_data is empty")
|
||||
|
||||
bot_token = (getattr(settings, "max_bot_token", None) or "").strip()
|
||||
if not bot_token:
|
||||
logger.warning("[MAX] MAX_BOT_TOKEN не задан в .env")
|
||||
raise MaxAuthError("MAX bot token is not configured")
|
||||
|
||||
parsed = _parse_init_data(init_data)
|
||||
logger.info("[MAX] initData распарсен, ключи: %s", list(parsed.keys()))
|
||||
|
||||
received_hash = parsed.pop("hash", None)
|
||||
if not received_hash:
|
||||
logger.warning("[MAX] В initData отсутствует поле hash")
|
||||
raise MaxAuthError("Missing hash in init_data")
|
||||
|
||||
data_check_items = []
|
||||
for key in sorted(parsed.keys()):
|
||||
value = parsed[key]
|
||||
data_check_items.append(f"{key}={value}")
|
||||
data_check_string = "\n".join(data_check_items)
|
||||
|
||||
secret_key = hmac.new(
|
||||
key="WebAppData".encode("utf-8"),
|
||||
msg=bot_token.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
calculated_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("[MAX] Подпись initData не совпадает")
|
||||
raise MaxAuthError("Invalid init_data hash")
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def extract_max_user(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Валидирует initData и возвращает данные пользователя MAX.
|
||||
|
||||
В поле `user` — JSON с id, first_name, last_name, username, language_code, photo_url и т.д.
|
||||
"""
|
||||
parsed = verify_max_init_data(init_data)
|
||||
|
||||
user_raw = parsed.get("user")
|
||||
if not user_raw:
|
||||
logger.warning("[MAX] В initData отсутствует поле user")
|
||||
raise MaxAuthError("No user field in init_data")
|
||||
|
||||
try:
|
||||
user_obj = json.loads(user_raw)
|
||||
except Exception as e:
|
||||
raise MaxAuthError(f"Failed to parse user JSON: {e}") from e
|
||||
|
||||
if "id" not in user_obj:
|
||||
raise MaxAuthError("MAX user.id is missing")
|
||||
|
||||
return {
|
||||
"max_user_id": str(user_obj.get("id")),
|
||||
"username": user_obj.get("username"),
|
||||
"first_name": user_obj.get("first_name"),
|
||||
"last_name": user_obj.get("last_name"),
|
||||
"language_code": user_obj.get("language_code"),
|
||||
"photo_url": user_obj.get("photo_url"),
|
||||
"raw": user_obj,
|
||||
}
|
||||
Reference in New Issue
Block a user