uploader-bot/app/api/fastapi_auth_routes.py

612 lines
23 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.

"""
FastAPI маршруты для аутентификации с поддержкой TON Connect и Telegram WebApp
Полная совместимость с web2-client требованиями
"""
import asyncio
import json
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.responses import JSONResponse
from sqlalchemy import select, update, and_, or_
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field
from app.core.config import get_settings
from app.core.database import db_manager, get_cache_manager
from app.core.logging import get_logger
from app.core.models.user import User, UserSession, UserRole
from app.core.security import (
hash_password, verify_password, generate_access_token,
verify_access_token, generate_refresh_token, generate_api_key,
sanitize_input, generate_csrf_token
)
from app.api.fastapi_middleware import get_current_user, require_auth
# Initialize router for auth endpoints
router = APIRouter(prefix="", tags=["auth"])
logger = get_logger(__name__)
settings = get_settings()
# Pydantic models для валидации
class TWAAuthRequest(BaseModel):
"""Модель для аутентификации через Telegram WebApp"""
twa_data: str
ton_proof: Optional[Dict[str, Any]] = None
class TWAAuthResponse(BaseModel):
"""Модель ответа аутентификации"""
connected_wallet: Optional[Dict[str, Any]] = None
auth_v1_token: str
class SelectWalletRequest(BaseModel):
"""Модель для выбора кошелька"""
wallet_address: str
class UserRegistrationRequest(BaseModel):
"""Модель для регистрации пользователя"""
username: str = Field(..., min_length=3, max_length=50)
email: str = Field(..., pattern=r'^[^@]+@[^@]+\.[^@]+$')
password: str = Field(..., min_length=8)
full_name: Optional[str] = Field(None, max_length=100)
class UserLoginRequest(BaseModel):
"""Модель для входа пользователя"""
username: str
password: str
remember_me: bool = False
class RefreshTokenRequest(BaseModel):
"""Модель для обновления токенов"""
refresh_token: str
@router.post("/auth.twa", response_model=TWAAuthResponse)
async def auth_twa(request: Request, auth_data: TWAAuthRequest):
"""
Аутентификация через Telegram WebApp с поддержкой TON proof
Критически важный эндпоинт для web2-client
"""
try:
client_ip = request.client.host
await logger.ainfo("TWA auth started", step="begin", twa_data_length=len(auth_data.twa_data))
# Основная аутентификация без TON proof
if not auth_data.ton_proof:
await logger.ainfo("TWA auth: no TON proof path", step="no_ton_proof")
# Валидация TWA данных
if not auth_data.twa_data:
raise HTTPException(status_code=400, detail="TWA data required")
# Здесь должна быть валидация Telegram WebApp данных
# Для демо возвращаем базовый токен
await logger.ainfo("TWA auth: calling _process_twa_data", step="processing_twa")
user_data = await _process_twa_data(auth_data.twa_data)
await logger.ainfo("TWA auth: _process_twa_data completed", step="twa_processed", user_data=user_data)
# Генерируем токен
try:
expires_minutes = int(getattr(settings, 'ACCESS_TOKEN_EXPIRE_MINUTES', 30))
expires_in_seconds = expires_minutes * 60
except (ValueError, TypeError):
expires_in_seconds = 30 * 60 # fallback to 30 minutes
auth_token = generate_access_token(
{"user_id": user_data["user_id"], "username": user_data["username"]},
expires_in=expires_in_seconds
)
await logger.ainfo(
"TWA authentication successful",
user_id=user_data["user_id"],
ip=client_ip,
has_ton_proof=False
)
return TWAAuthResponse(
connected_wallet=None,
auth_v1_token=auth_token
)
# Аутентификация с TON proof
else:
# Валидация TWA данных
user_data = await _process_twa_data(auth_data.twa_data)
# Обработка TON proof
ton_proof_data = auth_data.ton_proof
account = ton_proof_data.get("account")
proof = ton_proof_data.get("ton_proof")
if not account or not proof:
raise HTTPException(status_code=400, detail="Invalid TON proof format")
# Валидация TON proof (здесь должна быть реальная проверка)
is_valid_proof = await _validate_ton_proof(proof, account, auth_data.twa_data)
if not is_valid_proof:
raise HTTPException(status_code=400, detail="Invalid TON proof")
# Генерируем токен с подтвержденным кошельком
auth_token = generate_access_token(
{
"user_id": user_data["user_id"],
"username": user_data["username"],
"wallet_verified": True,
"wallet_address": account.get("address")
},
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
# Формируем информацию о подключенном кошельке
connected_wallet = {
"version": account.get("chain", "unknown"),
"address": account.get("address"),
"ton_balance": "0" # Здесь должен быть запрос баланса
}
await logger.ainfo(
"TWA authentication with TON proof successful",
user_id=user_data["user_id"],
wallet_address=account.get("address"),
ip=client_ip
)
return TWAAuthResponse(
connected_wallet=connected_wallet,
auth_v1_token=auth_token
)
except HTTPException:
raise
except Exception as e:
await logger.aerror(
"TWA authentication failed",
error=str(e),
ip=client_ip
)
raise HTTPException(status_code=500, detail="Authentication failed")
@router.post("/auth.selectWallet")
async def auth_select_wallet(
request: Request,
wallet_data: SelectWalletRequest,
current_user: User = Depends(require_auth)
):
"""
Выбор кошелька для аутентифицированного пользователя
Критически важный эндпоинт для web2-client
"""
try:
wallet_address = wallet_data.wallet_address
# Валидация адреса кошелька
if not wallet_address or len(wallet_address) < 10:
raise HTTPException(status_code=400, detail="Invalid wallet address")
# Проверка существования кошелька в сети TON
is_valid_wallet = await _validate_ton_wallet(wallet_address)
if not is_valid_wallet:
# Возвращаем 404 если кошелек не найден или невалиден
raise HTTPException(status_code=404, detail="Wallet not found or invalid")
# Обновляем информацию о кошельке пользователя
async with db_manager.get_session() as session:
user_stmt = select(User).where(User.id == current_user.id)
user_result = await session.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Обновляем адрес кошелька
user.wallet_address = wallet_address
user.wallet_connected_at = datetime.utcnow()
await session.commit()
await logger.ainfo(
"Wallet selected successfully",
user_id=str(current_user.id),
wallet_address=wallet_address
)
return {
"message": "Wallet selected successfully",
"wallet_address": wallet_address,
"selected_at": datetime.utcnow().isoformat()
}
except HTTPException:
raise
except Exception as e:
await logger.aerror(
"Wallet selection failed",
user_id=str(current_user.id),
wallet_address=wallet_data.wallet_address,
error=str(e)
)
raise HTTPException(status_code=500, detail="Wallet selection failed")
@router.post("/api/v1/auth/register")
async def register_user(request: Request, user_data: UserRegistrationRequest):
"""
Регистрация нового пользователя (дополнительный эндпоинт)
"""
try:
client_ip = request.client.host
# Проверка rate limiting (через middleware)
cache_manager = await get_cache_manager()
ip_reg_key = f"registration_ip:{client_ip}"
ip_registrations = await cache_manager.get(ip_reg_key, default=0)
if ip_registrations >= 3: # Max 3 registrations per IP per day
raise HTTPException(status_code=429, detail="Too many registrations from this IP")
async with db_manager.get_session() as session:
# Check if username already exists
username_stmt = select(User).where(User.username == user_data.username)
username_result = await session.execute(username_stmt)
if username_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already exists")
# Check if email already exists
email_stmt = select(User).where(User.email == user_data.email)
email_result = await session.execute(email_stmt)
if email_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
# Hash password
password_hash = hash_password(user_data.password)
# Create user
new_user = User(
id=uuid4(),
username=sanitize_input(user_data.username),
email=sanitize_input(user_data.email),
password_hash=password_hash,
full_name=sanitize_input(user_data.full_name or ""),
is_active=True,
email_verified=False,
registration_ip=client_ip,
last_login_ip=client_ip,
settings={"theme": "light", "notifications": True}
)
session.add(new_user)
await session.commit()
await session.refresh(new_user)
# Update IP registration counter
await cache_manager.increment(ip_reg_key, ttl=86400)
# Generate tokens
access_token = generate_access_token(
{"user_id": str(new_user.id), "username": user_data.username},
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
refresh_token = generate_refresh_token(new_user.id)
await logger.ainfo(
"User registered successfully",
user_id=str(new_user.id),
username=user_data.username,
email=user_data.email,
ip=client_ip
)
return {
"message": "Registration successful",
"user": {
"id": str(new_user.id),
"username": user_data.username,
"email": user_data.email,
"full_name": user_data.full_name,
"created_at": new_user.created_at.isoformat()
},
"tokens": {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
}
}
except HTTPException:
raise
except Exception as e:
await logger.aerror(
"User registration failed",
username=user_data.username,
email=user_data.email,
error=str(e)
)
raise HTTPException(status_code=500, detail="Registration failed")
@router.post("/api/v1/auth/login")
async def login_user(request: Request, login_data: UserLoginRequest):
"""
Вход пользователя с JWT токенами
"""
try:
client_ip = request.client.host
# Check login rate limiting
cache_manager = await get_cache_manager()
login_key = f"login_attempts:{login_data.username}:{client_ip}"
attempts = await cache_manager.get(login_key, default=0)
if attempts >= 5: # Max 5 failed attempts
raise HTTPException(status_code=429, detail="Too many login attempts")
async with db_manager.get_session() as session:
# Find user by username or email
user_stmt = select(User).where(
or_(User.username == login_data.username, User.email == login_data.username)
).options(selectinload(User.roles))
user_result = await session.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user or not verify_password(login_data.password, user.password_hash):
# Increment failed attempts
await cache_manager.increment(login_key, ttl=900) # 15 minutes
await logger.awarning(
"Failed login attempt",
username=login_data.username,
ip=client_ip,
attempts=attempts + 1
)
raise HTTPException(status_code=401, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account deactivated")
# Successful login - clear failed attempts
await cache_manager.delete(login_key)
# Update user login info
user.last_login_at = datetime.utcnow()
user.last_login_ip = client_ip
user.login_count = (user.login_count or 0) + 1
await session.commit()
# Generate tokens
user_permissions = []
for role in user.roles:
user_permissions.extend(role.permissions)
expires_in = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
if login_data.remember_me:
expires_in *= 24 # 24x longer for remember me
access_token = generate_access_token(
{
"user_id": str(user.id),
"username": user.username,
"permissions": list(set(user_permissions))
},
expires_in=expires_in
)
refresh_token = generate_refresh_token(user.id)
await logger.ainfo(
"User logged in successfully",
user_id=str(user.id),
username=user.username,
ip=client_ip,
remember_me=login_data.remember_me
)
return {
"message": "Login successful",
"user": {
"id": str(user.id),
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"last_login": user.last_login_at.isoformat() if user.last_login_at else None,
"permissions": user_permissions
},
"tokens": {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expires_in": expires_in
}
}
except HTTPException:
raise
except Exception as e:
await logger.aerror(
"Login failed",
username=login_data.username,
error=str(e)
)
raise HTTPException(status_code=500, detail="Login failed")
@router.post("/api/v1/auth/refresh")
async def refresh_tokens(request: Request, refresh_data: RefreshTokenRequest):
"""
Обновление access токена используя refresh токен
"""
try:
# Verify refresh token
payload = verify_access_token(refresh_data.refresh_token, token_type="refresh")
if not payload:
raise HTTPException(status_code=401, detail="Invalid refresh token")
user_id = UUID(payload["user_id"])
async with db_manager.get_session() as session:
# Get user with permissions
user_stmt = select(User).where(User.id == user_id).options(selectinload(User.roles))
user_result = await session.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status_code=401, detail="User not found or inactive")
# Generate new tokens (token rotation)
user_permissions = []
for role in user.roles:
user_permissions.extend(role.permissions)
new_access_token = generate_access_token(
{
"user_id": str(user.id),
"username": user.username,
"permissions": list(set(user_permissions))
},
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
new_refresh_token = generate_refresh_token(user.id)
await logger.adebug(
"Tokens refreshed",
user_id=str(user_id)
)
return {
"tokens": {
"access_token": new_access_token,
"refresh_token": new_refresh_token,
"token_type": "Bearer",
"expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
}
}
except HTTPException:
raise
except Exception as e:
await logger.aerror("Token refresh failed", error=str(e))
raise HTTPException(status_code=500, detail="Token refresh failed")
@router.get("/api/v1/auth/me")
async def get_current_user_info(current_user: User = Depends(require_auth)):
"""
Получение информации о текущем пользователе
"""
try:
async with db_manager.get_session() as session:
# Get user with full details
user_stmt = select(User).where(User.id == current_user.id).options(
selectinload(User.roles),
selectinload(User.api_keys)
)
user_result = await session.execute(user_stmt)
full_user = user_result.scalar_one_or_none()
if not full_user:
raise HTTPException(status_code=404, detail="User not found")
# Get user permissions
permissions = []
roles = []
for role in full_user.roles:
roles.append({
"name": role.name,
"description": role.description
})
permissions.extend(role.permissions)
return {
"user": {
"id": str(full_user.id),
"username": full_user.username,
"email": full_user.email,
"full_name": full_user.full_name,
"bio": full_user.bio,
"avatar_url": full_user.avatar_url,
"is_active": full_user.is_active,
"email_verified": full_user.email_verified,
"created_at": full_user.created_at.isoformat(),
"last_login_at": full_user.last_login_at.isoformat() if full_user.last_login_at else None,
"login_count": full_user.login_count,
"settings": full_user.settings
},
"roles": roles,
"permissions": list(set(permissions))
}
except HTTPException:
raise
except Exception as e:
await logger.aerror(
"Failed to get current user",
user_id=str(current_user.id),
error=str(e)
)
raise HTTPException(status_code=500, detail="Failed to get user information")
# Helper functions
async def _process_twa_data(twa_data: str) -> Dict[str, Any]:
"""Обработка данных Telegram WebApp"""
await logger.ainfo("_process_twa_data started", twa_data_length=len(twa_data))
# Здесь должна быть валидация TWA данных
# Для демо возвращаем фиктивные данные
result = {
"user_id": str(uuid4()),
"username": "twa_user",
"first_name": "TWA",
"last_name": "User"
}
await logger.ainfo("_process_twa_data completed", result=result)
return result
async def _validate_ton_proof(proof: Dict[str, Any], account: Dict[str, Any], twa_data: str) -> bool:
"""Валидация TON proof"""
# Здесь должна быть реальная валидация TON proof
# Для демо возвращаем True
try:
# Базовые проверки
if not proof.get("timestamp") or not proof.get("domain"):
return False
if not account.get("address") or not account.get("chain"):
return False
# Здесь должна быть криптографическая проверка подписи
return True
except Exception as e:
logger.error(f"TON proof validation error: {e}")
return False
async def _validate_ton_wallet(wallet_address: str) -> bool:
"""Валидация TON кошелька"""
# Здесь должна быть проверка существования кошелька в TON сети
# Для демо возвращаем True для валидных адресов
try:
# Базовая проверка формата адреса
if len(wallet_address) < 40:
return False
# Здесь должен быть запрос к TON API
return True
except Exception as e:
logger.error(f"TON wallet validation error: {e}")
return False