diff --git a/backend/Dockerfile b/backend/Dockerfile index 078f4a1..5a1b1e0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/api/auth2.py b/backend/app/api/auth2.py new file mode 100644 index 0000000..ea6a03f --- /dev/null +++ b/backend/app/api/auth2.py @@ -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="Неподдерживаемая платформа") diff --git a/backend/app/api/max_auth.py b/backend/app/api/max_auth.py new file mode 100644 index 0000000..c27733e --- /dev/null +++ b/backend/app/api/max_auth.py @@ -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, + ) diff --git a/backend/app/api/n8n_proxy.py b/backend/app/api/n8n_proxy.py index ce342c6..a127ec6 100644 --- a/backend/app/api/n8n_proxy.py +++ b/backend/app/api/n8n_proxy.py @@ -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): """ diff --git a/backend/app/config.py b/backend/app/config.py index e56694e..49e8282 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 # ============================================ diff --git a/backend/app/main.py b/backend/app/main.py index 4630703..940e341 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/services/max_auth.py b/backend/app/services/max_auth.py new file mode 100644 index 0000000..454d455 --- /dev/null +++ b/backend/app/services/max_auth.py @@ -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, + } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 53b0bae..9109d80 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -7,12 +7,12 @@ version: '3.8' services: ticket_form_frontend_prod: - container_name: ticket_form_frontend_prod + container_name: miniapp_front build: context: ./frontend dockerfile: Dockerfile.prod ports: - - "5176:3000" + - "4176:3000" environment: - VITE_API_URL=https://aiform.clientright.ru - NODE_ENV=production @@ -28,7 +28,7 @@ services: retries: 3 ticket_form_backend_prod: - container_name: ticket_form_backend_prod + container_name: miniapp_back build: context: ./backend dockerfile: Dockerfile @@ -45,7 +45,7 @@ services: labels: - "environment=production" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8200/health"] + test: ["CMD", "curl", "-f", "http://localhost:4200/health"] interval: 30s timeout: 10s retries: 3 diff --git a/frontend/index.html b/frontend/index.html index 0002d09..fe5dc8f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,8 @@
Теперь ты в системе — можно продолжать
+ ) : status === 'error' ? ( +{error}
+ ) : ( +