From 5dabbcd690d07f6e73e7a968e1b052878cedbde9 Mon Sep 17 00:00:00 2001 From: glpshchn <464976@niuitmo.ru> Date: Mon, 15 Dec 2025 03:04:03 +0300 Subject: [PATCH] Update files --- moderation/backend-py/middleware/__init__.py | 2 + .../backend-py/middleware/telegram_auth.py | 125 ++++++++++++++++ moderation/backend-py/models.py | 6 +- .../backend-py/routes/moderation_auth.py | 96 +++++++++++- moderation/backend-py/utils/email_service.py | 8 +- .../backend-py/utils/telegram_initdata.py | 139 ++++++++++++++++++ 6 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 moderation/backend-py/middleware/__init__.py create mode 100644 moderation/backend-py/middleware/telegram_auth.py create mode 100644 moderation/backend-py/utils/telegram_initdata.py diff --git a/moderation/backend-py/middleware/__init__.py b/moderation/backend-py/middleware/__init__.py new file mode 100644 index 0000000..119f268 --- /dev/null +++ b/moderation/backend-py/middleware/__init__.py @@ -0,0 +1,2 @@ +# Middleware package + diff --git a/moderation/backend-py/middleware/telegram_auth.py b/moderation/backend-py/middleware/telegram_auth.py new file mode 100644 index 0000000..2239b02 --- /dev/null +++ b/moderation/backend-py/middleware/telegram_auth.py @@ -0,0 +1,125 @@ +""" +Telegram Mini App authentication middleware +""" +from typing import Optional +from fastapi import Request, HTTPException, status +from database import users_collection +from utils.telegram_initdata import validate_init_data, normalize_telegram_user +from config import settings + + +async def authenticate_telegram_moderation(request: Request) -> Optional[dict]: + """ + Authenticate user via Telegram Mini App initData + + Returns: + User dict if authenticated, None otherwise + + Raises: + HTTPException: If authentication fails + """ + # Get initData from headers + auth_header = request.headers.get('authorization', '') + init_data_raw = None + + if auth_header.startswith('tma '): + init_data_raw = auth_header[4:].strip() + + if not init_data_raw: + init_data_raw = request.headers.get('x-telegram-init-data') + if init_data_raw: + init_data_raw = init_data_raw.strip() + + if not init_data_raw: + print('[TelegramAuth] No initData found in headers') + return None + + try: + # Validate initData + payload = validate_init_data(init_data_raw, settings.MODERATION_BOT_TOKEN) + + telegram_user = payload.get('user') + if not telegram_user: + print('[TelegramAuth] No user data in initData') + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Отсутствуют данные пользователя в initData' + ) + + # Normalize user data + normalized_user = normalize_telegram_user(telegram_user) + telegram_id = normalized_user.get('id') + + if not telegram_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Неверный ID пользователя' + ) + + print(f'[TelegramAuth] Validated initData for telegramId={telegram_id}') + + # Find or create user + from bson import ObjectId + user = await users_collection().find_one({'telegramId': telegram_id}) + + if not user: + # Create new user + from datetime import datetime + user_doc = { + 'telegramId': telegram_id, + 'username': normalized_user.get('username') or normalized_user.get('firstName') or 'user', + 'firstName': normalized_user.get('firstName'), + 'lastName': normalized_user.get('lastName'), + 'photoUrl': normalized_user.get('photoUrl'), + 'role': 'user', + 'createdAt': datetime.utcnow() + } + result = await users_collection().insert_one(user_doc) + user = await users_collection().find_one({'_id': result.inserted_id}) + print(f'[TelegramAuth] Created new user: {telegram_id}') + else: + # Update user data if needed + update_fields = {} + if normalized_user.get('username') and not user.get('username'): + update_fields['username'] = normalized_user.get('username') + if normalized_user.get('firstName') and not user.get('firstName'): + update_fields['firstName'] = normalized_user.get('firstName') + if normalized_user.get('lastName') is not None: + update_fields['lastName'] = normalized_user.get('lastName') + if normalized_user.get('photoUrl') and not user.get('photoUrl'): + update_fields['photoUrl'] = normalized_user.get('photoUrl') + + if update_fields: + from datetime import datetime + update_fields['lastActiveAt'] = datetime.utcnow() + await users_collection().update_one( + {'_id': user['_id']}, + {'$set': update_fields} + ) + user.update(update_fields) + + if user.get('banned'): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Пользователь заблокирован' + ) + + return user + + except HTTPException: + raise + except ValueError as e: + print(f'[TelegramAuth] Validation error: {e}') + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f'{str(e)}. Используйте официальный клиент.' + ) + except Exception as e: + print(f'[TelegramAuth] Error: {type(e).__name__}: {e}') + import traceback + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Ошибка авторизации. Используйте официальный клиент.' + ) + diff --git a/moderation/backend-py/models.py b/moderation/backend-py/models.py index 8d1b70e..7c30fc5 100644 --- a/moderation/backend-py/models.py +++ b/moderation/backend-py/models.py @@ -39,12 +39,12 @@ class LoginRequest(BaseModel): class TelegramWidgetAuth(BaseModel): id: int - first_name: str + first_name: Optional[str] = None last_name: Optional[str] = None username: Optional[str] = None photo_url: Optional[str] = None - auth_date: int - hash: str + auth_date: Optional[int] = None + hash: Optional[str] = None # User models diff --git a/moderation/backend-py/routes/moderation_auth.py b/moderation/backend-py/routes/moderation_auth.py index 17bed50..07ed6ae 100644 --- a/moderation/backend-py/routes/moderation_auth.py +++ b/moderation/backend-py/routes/moderation_auth.py @@ -315,23 +315,31 @@ async def login(request: LoginRequest, response: Response): @router.post("/telegram-widget") async def telegram_widget_auth(request: TelegramWidgetAuth, response: Response): """Authenticate via Telegram Login Widget""" + print(f"[ModerationAuth] 🔍 Запрос авторизации через Telegram виджет: id={request.id}, username={request.username}") + try: # Find user by telegramId + print(f"[ModerationAuth] Поиск пользователя с telegramId={request.id}") user = await users_collection().find_one({'telegramId': str(request.id)}) if not user: + print(f"[ModerationAuth] ❌ Пользователь не найден с telegramId={request.id}") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден. Сначала зарегистрируйтесь через бота." ) + print(f"[ModerationAuth] ✅ Пользователь найден: username={user.get('username')}, role={user.get('role')}") + if user.get('role') not in ['moderator', 'admin']: + print(f"[ModerationAuth] ❌ Недостаточно прав: role={user.get('role')}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещен. У вас нет прав модератора." ) if user.get('banned'): + print(f"[ModerationAuth] ❌ Аккаунт заблокирован") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Аккаунт заблокирован" @@ -377,10 +385,13 @@ async def telegram_widget_auth(request: TelegramWidgetAuth, response: Response): "accessToken": access_token } - except HTTPException: + except HTTPException as e: + print(f"[ModerationAuth] ❌ HTTP ошибка при авторизации через виджет: {e.status_code} - {e.detail}") raise except Exception as e: - print(f"[ModerationAuth] Ошибка авторизации через виджет: {e}") + print(f"[ModerationAuth] ❌ Ошибка авторизации через виджет: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка сервера" @@ -427,7 +438,82 @@ async def get_current_user_info(user: dict = Depends(get_current_user)): @router.post("/telegram") -async def telegram_auth_alias(request: TelegramWidgetAuth, response: Response): - """Alias for /telegram-widget for compatibility with frontend""" - return await telegram_widget_auth(request, response) +async def telegram_miniapp_auth(request: Request, response: Response): + """Authenticate via Telegram Mini App initData""" + print(f"[ModerationAuth] 🔍 Запрос авторизации через Telegram Mini App (initData)") + + from middleware.telegram_auth import authenticate_telegram_moderation + from utils.auth import require_moderator, require_owner + + try: + # Authenticate via initData + user = await authenticate_telegram_moderation(request) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Отсутствует initData. Используйте официальный клиент." + ) + + print(f"[ModerationAuth] ✅ Пользователь найден: username={user.get('username')}, role={user.get('role')}") + + # Check moderation access + username = user.get('username', '').lower() + is_owner = username in settings.OWNER_USERNAMES_LIST + is_admin_by_role = user.get('role') in ['moderator', 'admin'] + + # Check if user is moderation admin in DB + from database import moderation_admins_collection + is_admin_by_db = await moderation_admins_collection().find_one({ + '$or': [ + {'telegramId': user.get('telegramId')}, + {'username': username} + ] + }) is not None + + if not is_owner and not is_admin_by_role and not is_admin_by_db: + print(f"[ModerationAuth] ❌ Недостаточно прав: role={user.get('role')}, isOwner={is_owner}, isAdminByDB={is_admin_by_db}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Доступ запрещен. У вас нет прав модератора. Обратитесь к администратору." + ) + + # Update last active + from datetime import datetime + await users_collection().update_one( + {'_id': user['_id']}, + {'$set': {'lastActiveAt': datetime.utcnow()}} + ) + + # Generate tokens + user_id_str = str(user['_id']) + access_token = create_access_token(user_id_str) + refresh_token = create_refresh_token(user_id_str) + + # Set cookies + set_auth_cookies(response, access_token, refresh_token) + + print(f"[ModerationAuth] ✅ Успешная авторизация через Mini App: {user.get('username')}") + + return { + "success": True, + "user": { + "id": user_id_str, + "username": user.get('username'), + "role": user.get('role'), + "telegramId": user.get('telegramId') + }, + "accessToken": access_token + } + + except HTTPException: + raise + except Exception as e: + print(f"[ModerationAuth] ❌ Ошибка авторизации через Mini App: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка сервера" + ) diff --git a/moderation/backend-py/utils/email_service.py b/moderation/backend-py/utils/email_service.py index 6109b5e..f55d76b 100644 --- a/moderation/backend-py/utils/email_service.py +++ b/moderation/backend-py/utils/email_service.py @@ -58,8 +58,10 @@ async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str] msg.attach(MIMEText(html, 'html', 'utf-8')) # Connect and send - if settings.EMAIL_PROVIDER == 'yandex': - logger.info(f"[Email] Отправка через Yandex SMTP: {settings.YANDEX_SMTP_HOST}:{settings.YANDEX_SMTP_PORT}") + # Поддерживаем 'yandex' и 'smtp' как алиасы + email_provider = settings.EMAIL_PROVIDER.lower() + if email_provider in ['yandex', 'smtp']: + logger.info(f"[Email] Отправка через SMTP ({email_provider}): {settings.YANDEX_SMTP_HOST}:{settings.YANDEX_SMTP_PORT}") if not settings.YANDEX_SMTP_USER or not settings.YANDEX_SMTP_PASSWORD: raise ValueError("YANDEX_SMTP_USER и YANDEX_SMTP_PASSWORD должны быть установлены в .env") @@ -79,7 +81,7 @@ async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str] logger.info(f"✅ Email отправлен на {to}") return {"success": True, "to": to} else: - raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'yandex'") + raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'yandex' или 'smtp'") except Exception as e: logger.error(f"❌ Ошибка отправки email: {e}") diff --git a/moderation/backend-py/utils/telegram_initdata.py b/moderation/backend-py/utils/telegram_initdata.py new file mode 100644 index 0000000..ee59930 --- /dev/null +++ b/moderation/backend-py/utils/telegram_initdata.py @@ -0,0 +1,139 @@ +""" +Telegram Mini App initData validation +""" +import hmac +import hashlib +import urllib.parse +from typing import Optional, Dict, Any +from config import settings + +MAX_AUTH_AGE_SECONDS = 60 * 60 # 1 час +AUTH_AGE_TOLERANCE_SECONDS = 300 # 5 минут допуск + + +def validate_init_data(init_data_raw: str, bot_token: Optional[str] = None) -> Dict[str, Any]: + """ + Validate and parse Telegram Mini App initData + + Args: + init_data_raw: Raw initData string from Telegram + bot_token: Bot token (uses MODERATION_BOT_TOKEN if not provided) + + Returns: + Parsed initData payload with user data + + Raises: + ValueError: If validation fails + """ + token_to_use = bot_token or settings.MODERATION_BOT_TOKEN + + if not token_to_use or not isinstance(token_to_use, str) or not token_to_use.strip(): + error_msg = ( + 'Bot token модерации не настроен (MODERATION_BOT_TOKEN)' + if bot_token + else 'Bot token не настроен (TELEGRAM_BOT_TOKEN)' + ) + print(f'[Telegram] Token error: {error_msg}') + raise ValueError(error_msg) + + if not init_data_raw or not isinstance(init_data_raw, str): + raise ValueError('initData не передан') + + print(f'[Telegram] validate_init_data called: hasInitData={bool(init_data_raw)}, length={len(init_data_raw)}') + + # Parse query string + params = urllib.parse.parse_qs(init_data_raw) + + # Get hash + hash_value = params.get('hash', [None])[0] + if not hash_value: + raise ValueError('Отсутствует hash в initData') + + # Remove hash from params + params_without_hash = {k: v[0] for k, v in params.items() if k != 'hash'} + + # Create data check string (sorted keys) + data_check_arr = sorted(params_without_hash.items()) + data_check_string = '\n'.join(f'{key}={value}' for key, value in data_check_arr) + + # Create secret key + secret_key = hmac.new( + 'WebAppData'.encode(), + token_to_use.encode(), + hashlib.sha256 + ).digest() + + # Create signature + signature = hmac.new( + secret_key, + data_check_string.encode(), + hashlib.sha256 + ).hexdigest() + + # Compare signatures + if signature != hash_value: + print(f'[Telegram] Hash mismatch: calculated={signature[:20]}..., received={hash_value[:20]}...') + raise ValueError('Неверная подпись initData') + + # Check auth_date if present + auth_date_str = params_without_hash.get('auth_date') + if auth_date_str: + try: + auth_date = int(auth_date_str) + import time + now = int(time.time()) + age = abs(now - auth_date) + + if age > MAX_AUTH_AGE_SECONDS: + # Allow small tolerance in development + if settings.IS_DEVELOPMENT and age <= MAX_AUTH_AGE_SECONDS + AUTH_AGE_TOLERANCE_SECONDS: + print(f'⚠️ InitData expired ({age}s), but allowing due to tolerance in development.') + else: + raise ValueError(f'Данные авторизации устарели (возраст: {age}с, макс: {MAX_AUTH_AGE_SECONDS}с)') + except ValueError: + pass # Invalid auth_date, skip check + + # Parse user data if present + user_str = params_without_hash.get('user') + user_data = None + if user_str: + import json + try: + user_data = json.loads(user_str) + except json.JSONDecodeError: + raise ValueError('Неверный формат user данных в initData') + + print('[Telegram] initData validation complete') + + return { + 'user': user_data, + 'auth_date': auth_date_str, + 'hash': hash_value, + 'raw': params_without_hash + } + + +def normalize_telegram_user(user_data: Dict[str, Any]) -> Dict[str, Any]: + """Normalize Telegram user data (handle both camelCase and snake_case)""" + if not user_data: + return {} + + normalized = {} + + # ID + normalized['id'] = str(user_data.get('id') or user_data.get('id')) + + # Username + normalized['username'] = user_data.get('username') or user_data.get('username') + + # First name + normalized['firstName'] = user_data.get('first_name') or user_data.get('firstName') + + # Last name + normalized['lastName'] = user_data.get('last_name') or user_data.get('lastName') + + # Photo URL + normalized['photoUrl'] = user_data.get('photo_url') or user_data.get('photoUrl') + + return normalized +