feat: add soft ui auth page

This commit is contained in:
root
2026-02-20 09:31:13 +03:00
parent a4cc4f9de6
commit 8c3e993eb7
15 changed files with 1014 additions and 24 deletions

View File

@@ -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
View 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
View 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,
)

View File

@@ -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):
"""

View File

@@ -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
# ============================================

View File

@@ -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("/")

View 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,
}