Files
aiform_prod/backend/app/api/auth_universal.py
2026-02-27 07:48:16 +03:00

226 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Универсальный auth: один endpoint для TG и MAX.
Принимает channel (tg|max) и init_data, валидирует, дергает N8N_AUTH_WEBHOOK,
пишет сессию в Redis по ключу session:{channel}:{channel_user_id} и session:{session_token}.
"""
import logging
import os
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 обязателен")
logger.debug("[AUTH] init_data length=%s", len(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")
# URL из settings или напрямую из env (если в config нет поля n8n_auth_webhook)
webhook_url = (getattr(settings, "n8n_auth_webhook", None) or os.environ.get("N8N_AUTH_WEBHOOK") 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,
}
# При мультиботе (Telegram или MAX) передаём bot_id (из extract_telegram_user / extract_max_user)
if user.get("bot_id"):
payload["bot_id"] = user["bot_id"]
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):
# n8n Respond to Webhook может вернуть { "json": { success, phone, ... } }
if "json" in raw and isinstance(raw.get("json"), dict):
data = raw["json"]
logger.info("[AUTH] парсинг: raw — dict с json, data keys=%s", list(data.keys()))
else:
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:
# Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение
msg = data.get("message") or "Ошибка авторизации."
logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку: message=%s", msg)
logger.debug("[AUTH] полный data при success=false: %s", data)
return AuthUniversalResponse(
success=False,
need_contact=False,
message=msg,
)
# 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}
_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)
if _phone is not None and not isinstance(_phone, str):
_phone = str(_phone).strip() or None
elif isinstance(_phone, str):
_phone = _phone.strip() or None
session_data = {
"unified_id": unified_id,
"phone": _phone,
"contact_id": _contact_id,
"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,
}
logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone"))
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),
)