612 lines
23 KiB
Python
612 lines
23 KiB
Python
"""
|
||
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 |