From 62fc57f108f19f6de66602b45075fc07c854d926 Mon Sep 17 00:00:00 2001 From: Fedor Date: Fri, 27 Feb 2026 07:48:16 +0300 Subject: [PATCH] Auth: multibot TG MAX logging fix 500 --- backend/app/api/auth_universal.py | 35 +++- backend/app/config.py | 15 ++ backend/app/main.py | 12 +- backend/app/services/max_auth.py | 57 ++++--- frontend/src/components/SupportForm.tsx | 214 ++++++++++++++++++++++++ frontend/src/pages/HelloAuth.tsx | 162 +++++++++++++----- 6 files changed, 417 insertions(+), 78 deletions(-) create mode 100644 frontend/src/components/SupportForm.tsx diff --git a/backend/app/api/auth_universal.py b/backend/app/api/auth_universal.py index 4fa772a..edc20be 100644 --- a/backend/app/api/auth_universal.py +++ b/backend/app/api/auth_universal.py @@ -5,6 +5,7 @@ """ import logging +import os import uuid from typing import Optional, Any, Dict @@ -57,6 +58,8 @@ async def auth_universal(request: AuthUniversalRequest): 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": @@ -77,7 +80,8 @@ async def auth_universal(request: AuthUniversalRequest): 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() + # 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="Сервис авторизации не настроен") @@ -88,6 +92,9 @@ async def auth_universal(request: AuthUniversalRequest): "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: @@ -129,8 +136,13 @@ async def auth_universal(request: AuthUniversalRequest): 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())) + # 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={}") @@ -155,11 +167,13 @@ async def auth_universal(request: AuthUniversalRequest): ) if data.get("success") is False: # Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение - logger.info("[AUTH] ответ: success=false, need_contact=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=(data.get("message") or "Ошибка авторизации."), + message=msg, ) # 4) Успех: unified_id и т.д. @@ -172,13 +186,20 @@ async def auth_universal(request: AuthUniversalRequest): 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": 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, + "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: diff --git a/backend/app/config.py b/backend/app/config.py index a8fd520..0c2330f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -210,10 +210,25 @@ class Settings(BaseSettings): # ============================================ telegram_bot_token: str = "" # Токен бота для проверки initData WebApp + def get_telegram_bot_tokens(self) -> List[tuple]: + """Список (bot_id, token) для проверки подписи Telegram initData. Один токен — [('default', token)].""" + token = (self.telegram_bot_token or "").strip() + if token: + return [("default", token)] + return [] + # ============================================ # MAX (мессенджер) — Mini App auth # ============================================ max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp + + def get_max_bot_tokens(self) -> List[tuple]: + """Список (bot_id, token) для проверки подписи MAX initData. Один токен — [('default', token)].""" + token = (self.max_bot_token or "").strip() + if token: + return [("default", token)] + return [] + n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts n8n_auth_webhook: str = "" # Универсальный auth: channel + channel_user_id + init_data → unified_id, phone, contact_id, has_drafts diff --git a/backend/app/main.py b/backend/app/main.py index e451eed..a8ed489 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,12 +22,18 @@ from .services.s3_service import s3_service from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, auth_universal, documents_draft_open, profile, support from .api import debug_session -# Настройка логирования +# Настройка логирования (уровень из config: LOG_LEVEL=DEBUG для отладки) +import sys +_level = getattr(logging, (getattr(get_settings(), "log_level", None) or "INFO").upper(), logging.INFO) logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=_level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + stream=sys.stdout, ) +# Применяем уровень ко всем логгерам приложения +logging.getLogger("app").setLevel(_level) logger = logging.getLogger(__name__) +logger.info("Backend log level: %s", logging.getLevelName(_level)) DEBUG_SESSION_ID = "2a4d38" # В прод-контейнере гарантированно доступен /app/logs (volume ./backend/logs:/app/logs) diff --git a/backend/app/services/max_auth.py b/backend/app/services/max_auth.py index 454d455..bd940bc 100644 --- a/backend/app/services/max_auth.py +++ b/backend/app/services/max_auth.py @@ -29,10 +29,28 @@ def _parse_init_data(init_data: str) -> Dict[str, Any]: return data +def _verify_with_token(parsed: Dict[str, Any], data_check_string: str, received_hash: str, bot_token: str) -> bool: + """Проверяет подпись initData одним MAX ботом. Возвращает True, если подпись верна.""" + 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() + return hmac.compare_digest(calculated_hash, received_hash) + + def verify_max_init_data(init_data: str) -> Dict[str, Any]: """ Проверяет подпись initData по правилам MAX (аналогично Telegram). + Поддерживает один бот (MAX_BOT_TOKEN) или несколько (MAX_BOT_TOKENS — JSON). + Перебирает токены, пока один не подойдёт; в результат добавляется ключ bot_id. + - 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 @@ -41,9 +59,9 @@ def verify_max_init_data(init_data: str) -> Dict[str, Any]: 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") + tokens_list = settings.get_max_bot_tokens() + if not tokens_list: + logger.warning("[MAX] Ни MAX_BOT_TOKEN, ни MAX_BOT_TOKENS не заданы в .env") raise MaxAuthError("MAX bot token is not configured") parsed = _parse_init_data(init_data) @@ -54,29 +72,17 @@ def verify_max_init_data(init_data: str) -> Dict[str, Any]: 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_items = [f"{k}={parsed[k]}" for k in sorted(parsed.keys())] 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() + for bot_id, bot_token in tokens_list: + if _verify_with_token(parsed, data_check_string, received_hash, bot_token): + parsed["bot_id"] = bot_id + logger.info("[MAX] Подпись MAX initData проверена, bot_id=%s", bot_id) + return parsed - 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 + logger.warning("[MAX] Подпись initData не совпадает ни с одним из токенов MAX ботов") + raise MaxAuthError("Invalid init_data hash") def extract_max_user(init_data: str) -> Dict[str, Any]: @@ -100,7 +106,7 @@ def extract_max_user(init_data: str) -> Dict[str, Any]: if "id" not in user_obj: raise MaxAuthError("MAX user.id is missing") - return { + result = { "max_user_id": str(user_obj.get("id")), "username": user_obj.get("username"), "first_name": user_obj.get("first_name"), @@ -109,3 +115,6 @@ def extract_max_user(init_data: str) -> Dict[str, Any]: "photo_url": user_obj.get("photo_url"), "raw": user_obj, } + if "bot_id" in parsed: + result["bot_id"] = parsed["bot_id"] + return result diff --git a/frontend/src/components/SupportForm.tsx b/frontend/src/components/SupportForm.tsx new file mode 100644 index 0000000..58aaae0 --- /dev/null +++ b/frontend/src/components/SupportForm.tsx @@ -0,0 +1,214 @@ +/** + * SupportForm — форма обращения в поддержку (переиспользуется на странице /support и в модалке карточки жалобы). + * Отправка: POST /api/v1/support (multipart). Лимиты вложений опционально из GET /api/v1/support/limits. + */ + +import { useEffect, useState } from 'react'; +import { Button, Form, Input, message as antMessage } from 'antd'; +import { Paperclip, X } from 'lucide-react'; + +const { TextArea } = Input; + +export interface SupportLimits { + max_count: number; + max_size_per_file: number; + allowed_types: string; + unlimited: boolean; +} + +export interface SupportFormProps { + /** Привязка к обращению (из карточки жалобы) */ + claimId?: string; + /** bar | complaint_card */ + source?: 'bar' | 'complaint_card'; + /** После успешной отправки */ + onSuccess?: () => void; + /** Компактный вид (модалка) */ + compact?: boolean; + /** Скрыть заголовок «По обращению №…» когда передан claimId */ + hideClaimLabel?: boolean; +} + +function getSessionToken(): string | null { + if (typeof sessionStorage !== 'undefined') { + const s = sessionStorage.getItem('session_token'); + if (s) return s; + } + if (typeof localStorage !== 'undefined') { + return localStorage.getItem('session_token'); + } + return null; +} + +export default function SupportForm({ + claimId, + source = 'bar', + onSuccess, + compact = false, + hideClaimLabel = false, +}: SupportFormProps) { + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const [limits, setLimits] = useState(null); + const [files, setFiles] = useState([]); + const [fileInputKey, setFileInputKey] = useState(0); + + useEffect(() => { + fetch('/api/v1/support/limits') + .then((res) => (res.ok ? res.json() : null)) + .then((data: SupportLimits | null) => { + if (data) setLimits(data); + }) + .catch(() => {}); + }, []); + + const canAddFile = (): boolean => { + if (!limits || limits.unlimited) return true; + return files.length < limits.max_count; + }; + + const isFileSizeOk = (file: File): boolean => { + if (!limits || limits.unlimited || limits.max_size_per_file <= 0) return true; + return file.size <= limits.max_size_per_file; + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const selected = Array.from(e.target.files || []); + if (!limits?.unlimited && limits && limits.max_count > 0) { + const remaining = limits.max_count - files.length; + if (selected.length > remaining) { + antMessage.warning(`Можно прикрепить не более ${limits.max_count} файлов`); + setFileInputKey((k) => k + 1); + return; + } + } + const ok: File[] = []; + for (const f of selected) { + if (!isFileSizeOk(f)) { + antMessage.warning(`Файл «${f.name}» превышает допустимый размер`); + continue; + } + ok.push(f); + } + setFiles((prev) => [...prev, ...ok].slice(0, limits?.unlimited ? 999 : (limits?.max_count || 999))); + setFileInputKey((k) => k + 1); + e.target.value = ''; + }; + + const removeFile = (index: number) => { + setFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleSubmit = async () => { + const values = await form.validateFields().catch(() => null); + if (!values || !values.message?.trim()) return; + + const token = getSessionToken(); + if (!token) { + antMessage.error('Сессия не найдена. Войдите снова.'); + return; + } + + const fd = new FormData(); + fd.append('message', values.message.trim()); + if (values.subject?.trim()) fd.append('subject', values.subject.trim()); + fd.append('source', source); + fd.append('session_token', token); + if (claimId) fd.append('claim_id', claimId); + + files.forEach((file, i) => { + fd.append(`attachments[${i}]`, file, file.name); + }); + + setSubmitting(true); + try { + const res = await fetch('/api/v1/support', { + method: 'POST', + body: fd, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || res.statusText || 'Ошибка отправки'); + } + antMessage.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время.'); + form.resetFields(); + setFiles([]); + setFileInputKey((k) => k + 1); + onSuccess?.(); + } catch (err) { + antMessage.error(err instanceof Error ? err.message : 'Не удалось отправить запрос. Попробуйте позже.'); + } finally { + setSubmitting(false); + } + }; + + const limitHint = + limits && !limits.unlimited + ? `Макс. ${limits.max_count || '—'} файл(ов)${limits.max_size_per_file ? `, до ${Math.round(limits.max_size_per_file / 1024 / 1024)} МБ каждый` : ''}${limits.allowed_types ? `. Типы: ${limits.allowed_types}` : ''}` + : null; + + return ( +
+ {claimId && !hideClaimLabel && ( +

По обращению №{claimId}

+ )} +
+ +