Update files
This commit is contained in:
parent
34fdbe17ba
commit
5dabbcd690
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Middleware package
|
||||||
|
|
||||||
|
|
@ -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='Ошибка авторизации. Используйте официальный клиент.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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="Ошибка сервера"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
Loading…
Reference in New Issue