205 lines
9.7 KiB
Python
205 lines
9.7 KiB
Python
"""
|
||
Универсальный auth: один endpoint для TG и MAX.
|
||
Принимает channel (tg|max) и init_data, валидирует, дергает N8N_AUTH_WEBHOOK,
|
||
пишет сессию в Redis по ключу session:{channel}:{channel_user_id} и session:{session_token}.
|
||
"""
|
||
|
||
import logging
|
||
import uuid
|
||
from typing import Optional, Any, Dict
|
||
|
||
import httpx
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
from ..config import settings
|
||
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
|
||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||
from . import session as session_api
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/v1/auth", tags=["auth-universal"])
|
||
|
||
|
||
class AuthUniversalRequest(BaseModel):
|
||
channel: str # tg | max
|
||
init_data: str
|
||
|
||
|
||
class AuthUniversalResponse(BaseModel):
|
||
success: bool
|
||
need_contact: Optional[bool] = None
|
||
message: Optional[str] = None
|
||
session_token: Optional[str] = None
|
||
unified_id: Optional[str] = None
|
||
phone: Optional[str] = None
|
||
contact_id: Optional[str] = None
|
||
has_drafts: Optional[bool] = None
|
||
|
||
|
||
@router.post("", response_model=AuthUniversalResponse)
|
||
async def auth_universal(request: AuthUniversalRequest):
|
||
"""
|
||
Универсальная авторизация: channel (tg|max) + init_data.
|
||
Валидируем init_data, получаем channel_user_id, вызываем N8N_AUTH_WEBHOOK,
|
||
при успехе пишем сессию в Redis по session:{channel}:{channel_user_id}.
|
||
"""
|
||
logger.info("[AUTH] POST /api/v1/auth вызван: channel=%s", request.channel)
|
||
channel = (request.channel or "").strip().lower()
|
||
if channel not in ("tg", "telegram", "max"):
|
||
channel = "telegram" if channel.startswith("tg") else "max"
|
||
# В n8n и Redis всегда передаём telegram, не tg
|
||
if channel == "tg":
|
||
channel = "telegram"
|
||
|
||
init_data = (request.init_data or "").strip()
|
||
if not init_data:
|
||
raise HTTPException(status_code=400, detail="init_data обязателен")
|
||
|
||
# 1) Извлечь channel_user_id из init_data
|
||
channel_user_id: Optional[str] = None
|
||
if channel == "telegram":
|
||
try:
|
||
user = extract_telegram_user(init_data)
|
||
channel_user_id = user.get("telegram_user_id")
|
||
except TelegramAuthError as e:
|
||
logger.warning("[TG] Ошибка валидации init_data: %s", e)
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
else:
|
||
try:
|
||
user = extract_max_user(init_data)
|
||
channel_user_id = user.get("max_user_id")
|
||
except MaxAuthError as e:
|
||
logger.warning("[MAX] Ошибка валидации init_data: %s", e)
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
|
||
if not channel_user_id:
|
||
raise HTTPException(status_code=400, detail="Не удалось получить channel_user_id из init_data")
|
||
|
||
webhook_url = (getattr(settings, "n8n_auth_webhook", None) or "").strip()
|
||
if not webhook_url:
|
||
logger.error("N8N_AUTH_WEBHOOK не задан в .env")
|
||
raise HTTPException(status_code=503, detail="Сервис авторизации не настроен")
|
||
|
||
# 2) Вызвать n8n
|
||
payload = {
|
||
"channel": channel,
|
||
"channel_user_id": channel_user_id,
|
||
"init_data": init_data,
|
||
}
|
||
logger.info("[AUTH] Вызов N8N_AUTH_WEBHOOK: channel=%s, channel_user_id=%s", channel, channel_user_id)
|
||
try:
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
response = await client.post(
|
||
webhook_url,
|
||
json=payload,
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
except httpx.TimeoutException:
|
||
logger.error("[AUTH] Таймаут N8N_AUTH_WEBHOOK")
|
||
raise HTTPException(status_code=504, detail="Таймаут сервиса авторизации")
|
||
except Exception as e:
|
||
logger.exception("[AUTH] Ошибка вызова N8N_AUTH_WEBHOOK: %s", e)
|
||
raise HTTPException(status_code=502, detail="Ошибка сервиса авторизации")
|
||
|
||
# Лог: что пришло от n8n (сырой ответ)
|
||
try:
|
||
_body = response.text or ""
|
||
logger.info("[AUTH] n8n ответ: status=%s, body_len=%s, body_preview=%s", response.status_code, len(_body), _body[:500] if _body else "")
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
raw = response.json()
|
||
logger.info("[AUTH] raw type=%s, is_list=%s, len=%s", type(raw).__name__, isinstance(raw, list), len(raw) if isinstance(raw, (list, dict)) else 0)
|
||
if isinstance(raw, list) and len(raw) > 0:
|
||
logger.info("[AUTH] raw[0] keys=%s", list(raw[0].keys()) if isinstance(raw[0], dict) else type(raw[0]).__name__)
|
||
|
||
# n8n может вернуть: массив [{ json: { ... } }] или массив объектов напрямую [{ success, unified_id, ... }]
|
||
if isinstance(raw, list) and len(raw) > 0 and isinstance(raw[0], dict):
|
||
first = raw[0]
|
||
if "json" in first:
|
||
data = first["json"]
|
||
logger.info("[AUTH] парсинг: взяли first['json'], data keys=%s", list(data.keys()) if isinstance(data, dict) else "?")
|
||
elif "success" in first or "unified_id" in first:
|
||
data = first
|
||
logger.info("[AUTH] парсинг: взяли first как data, keys=%s", list(data.keys()))
|
||
else:
|
||
data = {}
|
||
logger.warning("[AUTH] парсинг: first без json/success/unified_id, data={}")
|
||
elif isinstance(raw, dict):
|
||
data = raw
|
||
logger.info("[AUTH] парсинг: raw — dict, keys=%s", list(data.keys()))
|
||
else:
|
||
data = {}
|
||
logger.warning("[AUTH] парсинг: неизвестный формат raw, data={}")
|
||
except Exception as e:
|
||
logger.warning("[AUTH] Ответ n8n не JSON: %s", (response.text or "")[:300])
|
||
raise HTTPException(status_code=502, detail="Некорректный ответ сервиса авторизации")
|
||
|
||
logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id"))
|
||
|
||
# 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате)
|
||
need_contact = (
|
||
data.get("need_contact") is True
|
||
or data.get("need_contact") == 1
|
||
or (isinstance(data.get("need_contact"), str) and data.get("need_contact", "").strip().lower() in ("true", "1"))
|
||
)
|
||
if need_contact:
|
||
logger.info("[AUTH] ответ: need_contact=true → закрыть приложение")
|
||
return AuthUniversalResponse(
|
||
success=False,
|
||
need_contact=True,
|
||
message=(data.get("message") or "Пользователь не найден. Поделитесь контактом в чате с ботом."),
|
||
)
|
||
if data.get("success") is False:
|
||
# Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение
|
||
logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку")
|
||
return AuthUniversalResponse(
|
||
success=False,
|
||
need_contact=False,
|
||
message=(data.get("message") or "Ошибка авторизации."),
|
||
)
|
||
|
||
# 4) Успех: unified_id и т.д.
|
||
unified_id = data.get("unified_id")
|
||
if not unified_id and isinstance(data.get("result"), dict):
|
||
unified_id = (data.get("result") or {}).get("unified_id")
|
||
if not unified_id:
|
||
logger.warning("[AUTH] n8n не вернул unified_id: %s", data)
|
||
logger.info("[AUTH] ответ: нет unified_id → need_contact=true, закрыть приложение")
|
||
return AuthUniversalResponse(success=False, need_contact=True, message="Контакт не найден.")
|
||
|
||
# 5) Записать сессию в Redis по session:{channel}:{channel_user_id} и session:{session_token}
|
||
session_data = {
|
||
"unified_id": unified_id,
|
||
"phone": data.get("phone") or (data.get("result") or {}).get("phone") if isinstance(data.get("result"), dict) else None,
|
||
"contact_id": data.get("contact_id") or (data.get("result") or {}).get("contact_id") if isinstance(data.get("result"), dict) else None,
|
||
"has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False,
|
||
"chat_id": channel_user_id,
|
||
}
|
||
try:
|
||
await session_api.set_session_by_channel_user(channel, channel_user_id, session_data)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.exception("[AUTH] Ошибка записи сессии в Redis: %s", e)
|
||
raise HTTPException(status_code=500, detail="Ошибка сохранения сессии")
|
||
|
||
session_token = str(uuid.uuid4())
|
||
try:
|
||
await session_api.set_session_by_token(session_token, session_data)
|
||
except Exception as e:
|
||
logger.warning("[AUTH] Двойная запись session_token в Redis: %s", e)
|
||
|
||
logger.info("[AUTH] ответ: success=true, session_token=%s..., unified_id=%s", session_token[:8] if session_token else "", unified_id)
|
||
return AuthUniversalResponse(
|
||
success=True,
|
||
session_token=session_token,
|
||
unified_id=unified_id,
|
||
phone=session_data.get("phone"),
|
||
contact_id=session_data.get("contact_id"),
|
||
has_drafts=session_data.get("has_drafts", False),
|
||
)
|