Update files

This commit is contained in:
glpshchn 2025-12-15 03:04:03 +03:00
parent 34fdbe17ba
commit 5dabbcd690
6 changed files with 365 additions and 11 deletions

View File

@ -0,0 +1,2 @@
# Middleware package

View File

@ -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='Ошибка авторизации. Используйте официальный клиент.'
)

View File

@ -39,12 +39,12 @@ class LoginRequest(BaseModel):
class TelegramWidgetAuth(BaseModel): class TelegramWidgetAuth(BaseModel):
id: int id: int
first_name: str first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
username: Optional[str] = None username: Optional[str] = None
photo_url: Optional[str] = None photo_url: Optional[str] = None
auth_date: int auth_date: Optional[int] = None
hash: str hash: Optional[str] = None
# User models # User models

View File

@ -315,23 +315,31 @@ async def login(request: LoginRequest, response: Response):
@router.post("/telegram-widget") @router.post("/telegram-widget")
async def telegram_widget_auth(request: TelegramWidgetAuth, response: Response): async def telegram_widget_auth(request: TelegramWidgetAuth, response: Response):
"""Authenticate via Telegram Login Widget""" """Authenticate via Telegram Login Widget"""
print(f"[ModerationAuth] 🔍 Запрос авторизации через Telegram виджет: id={request.id}, username={request.username}")
try: try:
# Find user by telegramId # Find user by telegramId
print(f"[ModerationAuth] Поиск пользователя с telegramId={request.id}")
user = await users_collection().find_one({'telegramId': str(request.id)}) user = await users_collection().find_one({'telegramId': str(request.id)})
if not user: if not user:
print(f"[ModerationAuth] ❌ Пользователь не найден с telegramId={request.id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден. Сначала зарегистрируйтесь через бота." detail="Пользователь не найден. Сначала зарегистрируйтесь через бота."
) )
print(f"[ModerationAuth] ✅ Пользователь найден: username={user.get('username')}, role={user.get('role')}")
if user.get('role') not in ['moderator', 'admin']: if user.get('role') not in ['moderator', 'admin']:
print(f"[ModerationAuth] ❌ Недостаточно прав: role={user.get('role')}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Доступ запрещен. У вас нет прав модератора." detail="Доступ запрещен. У вас нет прав модератора."
) )
if user.get('banned'): if user.get('banned'):
print(f"[ModerationAuth] ❌ Аккаунт заблокирован")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Аккаунт заблокирован" detail="Аккаунт заблокирован"
@ -377,10 +385,13 @@ async def telegram_widget_auth(request: TelegramWidgetAuth, response: Response):
"accessToken": access_token "accessToken": access_token
} }
except HTTPException: except HTTPException as e:
print(f"[ModerationAuth] ❌ HTTP ошибка при авторизации через виджет: {e.status_code} - {e.detail}")
raise raise
except Exception as e: except Exception as e:
print(f"[ModerationAuth] Ошибка авторизации через виджет: {e}") print(f"[ModerationAuth] ❌ Ошибка авторизации через виджет: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Ошибка сервера" detail="Ошибка сервера"
@ -427,7 +438,82 @@ async def get_current_user_info(user: dict = Depends(get_current_user)):
@router.post("/telegram") @router.post("/telegram")
async def telegram_auth_alias(request: TelegramWidgetAuth, response: Response): async def telegram_miniapp_auth(request: Request, response: Response):
"""Alias for /telegram-widget for compatibility with frontend""" """Authenticate via Telegram Mini App initData"""
return await telegram_widget_auth(request, response) 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="Ошибка сервера"
)

View File

@ -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')) msg.attach(MIMEText(html, 'html', 'utf-8'))
# Connect and send # Connect and send
if settings.EMAIL_PROVIDER == 'yandex': # Поддерживаем 'yandex' и 'smtp' как алиасы
logger.info(f"[Email] Отправка через Yandex SMTP: {settings.YANDEX_SMTP_HOST}:{settings.YANDEX_SMTP_PORT}") 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: if not settings.YANDEX_SMTP_USER or not settings.YANDEX_SMTP_PASSWORD:
raise ValueError("YANDEX_SMTP_USER и YANDEX_SMTP_PASSWORD должны быть установлены в .env") 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}") logger.info(f"✅ Email отправлен на {to}")
return {"success": True, "to": to} return {"success": True, "to": to}
else: else:
raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'yandex'") raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'yandex' или 'smtp'")
except Exception as e: except Exception as e:
logger.error(f"❌ Ошибка отправки email: {e}") logger.error(f"❌ Ошибка отправки email: {e}")

View File

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