"""Authentication routes for cabinet.""" import asyncio import hashlib import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.user import create_user, create_user_by_email, get_user_by_id, get_user_by_telegram_id from app.database.models import CabinetRefreshToken, User from ..auth import ( create_access_token, create_refresh_token, get_token_payload, hash_password, validate_telegram_init_data, validate_telegram_login_widget, verify_password, ) from ..auth.email_verification import ( generate_password_reset_token, generate_verification_token, get_password_reset_expires_at, get_verification_expires_at, is_token_expired, ) from ..auth.jwt_handler import get_refresh_token_expires_at from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.auth import ( AuthResponse, EmailLoginRequest, EmailRegisterRequest, EmailRegisterStandaloneRequest, EmailVerifyRequest, PasswordForgotRequest, PasswordResetRequest, RefreshTokenRequest, TelegramAuthRequest, TelegramWidgetAuthRequest, TokenResponse, UserResponse, ) from ..services.email_service import email_service logger = logging.getLogger(__name__) router = APIRouter(prefix='/auth', tags=['Cabinet Auth']) def _user_to_response(user: User) -> UserResponse: """Convert User model to UserResponse.""" return UserResponse( id=user.id, telegram_id=user.telegram_id, username=user.username, first_name=user.first_name, last_name=user.last_name, email=user.email, email_verified=user.email_verified, balance_kopeks=user.balance_kopeks, balance_rubles=user.balance_rubles, referral_code=user.referral_code, language=user.language, created_at=user.created_at, auth_type=getattr(user, 'auth_type', 'telegram'), # Поддержка старых записей ) def _create_auth_response(user: User) -> AuthResponse: """Create full auth response with tokens.""" access_token = create_access_token(user.id, user.telegram_id) refresh_token = create_refresh_token(user.id) expires_in = settings.get_cabinet_access_token_expire_minutes() * 60 return AuthResponse( access_token=access_token, refresh_token=refresh_token, token_type='bearer', expires_in=expires_in, user=_user_to_response(user), ) async def _store_refresh_token( db: AsyncSession, user_id: int, refresh_token: str, device_info: str | None = None, ) -> None: """Store refresh token hash in database.""" token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() expires_at = get_refresh_token_expires_at() # Check if token already exists (handles race conditions) existing = await db.execute(select(CabinetRefreshToken).where(CabinetRefreshToken.token_hash == token_hash)) if existing.scalar_one_or_none(): # Token already stored, skip return token_record = CabinetRefreshToken( user_id=user_id, token_hash=token_hash, device_info=device_info, expires_at=expires_at, ) db.add(token_record) try: await db.commit() except Exception: # Handle race condition if token was inserted between check and insert await db.rollback() @router.post('/telegram', response_model=AuthResponse) async def auth_telegram( request: TelegramAuthRequest, db: AsyncSession = Depends(get_cabinet_db), ): """ Authenticate using Telegram WebApp initData. This endpoint validates the initData from Telegram WebApp and returns JWT tokens for authenticated access. """ user_data = validate_telegram_init_data(request.init_data) if not user_data: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid or expired Telegram authentication data', ) telegram_id = user_data.get('id') if not telegram_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Missing Telegram user ID', ) user = await get_user_by_telegram_id(db, telegram_id) # Get user data from initData tg_username = user_data.get('username') tg_first_name = user_data.get('first_name') tg_last_name = user_data.get('last_name') tg_language = user_data.get('language_code', 'ru') if not user: # Create new user from Telegram initData logger.info(f'Creating new user from cabinet (initData): telegram_id={telegram_id}') user = await create_user( db=db, telegram_id=telegram_id, username=tg_username, first_name=tg_first_name, last_name=tg_last_name, language=tg_language, ) logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}') else: # Update user info from initData (like bot middleware does) updated = False if tg_username and tg_username != user.username: user.username = tg_username updated = True if tg_first_name and tg_first_name != user.first_name: user.first_name = tg_first_name updated = True if tg_last_name and tg_last_name != user.last_name: user.last_name = tg_last_name updated = True if updated: logger.info(f'User {user.id} profile updated from initData') if user.status != 'active': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail='User account is not active', ) # Update last login user.cabinet_last_login = datetime.utcnow() await db.commit() response = _create_auth_response(user) # Store refresh token await _store_refresh_token(db, user.id, response.refresh_token) return response @router.post('/telegram/widget', response_model=AuthResponse) async def auth_telegram_widget( request: TelegramWidgetAuthRequest, db: AsyncSession = Depends(get_cabinet_db), ): """ Authenticate using Telegram Login Widget data. This endpoint validates data from Telegram Login Widget and returns JWT tokens for authenticated access. """ widget_data = request.model_dump() if not validate_telegram_login_widget(widget_data): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid or expired Telegram authentication data', ) user = await get_user_by_telegram_id(db, request.id) if not user: # Create new user from Telegram data logger.info(f'Creating new user from cabinet: telegram_id={request.id}, username={request.username}') user = await create_user( db=db, telegram_id=request.id, username=request.username, first_name=request.first_name, last_name=request.last_name, language='ru', ) logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}') if user.status != 'active': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail='User account is not active', ) # Update user info from widget data if request.username and request.username != user.username: user.username = request.username if request.first_name and request.first_name != user.first_name: user.first_name = request.first_name if request.last_name != user.last_name: user.last_name = request.last_name user.cabinet_last_login = datetime.utcnow() await db.commit() response = _create_auth_response(user) await _store_refresh_token(db, user.id, response.refresh_token) return response @router.post('/email/register') async def register_email( request: EmailRegisterRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """ Register/link email to existing Telegram account. Requires valid JWT token from Telegram authentication. Sends verification email to the provided address. """ # Check if email already exists existing_user = await db.execute(select(User).where(User.email == request.email)) if existing_user.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='This email is already registered', ) # Check if user already has email if user.email and user.email_verified: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='You already have a verified email', ) # Generate verification token verification_token = generate_verification_token() verification_expires = get_verification_expires_at() # Update user user.email = request.email user.email_verified = False user.password_hash = hash_password(request.password) user.email_verification_token = verification_token user.email_verification_expires = verification_expires await db.commit() # Send verification email asynchronously (smtplib is blocking) if settings.is_cabinet_email_verification_enabled() and email_service.is_configured(): # TODO: Get actual verification URL from settings verification_url = 'https://example.com/cabinet/verify-email' await asyncio.to_thread( email_service.send_verification_email, to_email=request.email, verification_token=verification_token, verification_url=verification_url, username=user.first_name, ) return { 'message': 'Verification email sent', 'email': request.email, } @router.post('/email/register/standalone', response_model=AuthResponse) async def register_email_standalone( request: EmailRegisterStandaloneRequest, db: AsyncSession = Depends(get_cabinet_db), ): """ Register new account with email and password. This endpoint creates a new user WITHOUT requiring Telegram authentication. An email verification link will be sent to confirm the email address. The user can login immediately but some features may be restricted until email is verified. If TEST_EMAIL is configured, test email accounts are auto-verified. """ # Check if this is a test email registration is_test_email = settings.is_test_email(request.email) if is_test_email: # Validate test email password if not settings.validate_test_email_password(request.email, request.password): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid test email password', ) logger.info(f'Test email registration: {request.email}') # Проверить что email не занят existing = await db.execute(select(User).where(User.email == request.email)) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='This email is already registered', ) # Хешировать пароль password_hash = hash_password(request.password) # Создать пользователя user = await create_user_by_email( db=db, email=request.email, password_hash=password_hash, first_name=request.first_name, language=request.language, ) # Для тестового email - автоматически верифицировать if is_test_email: user.email_verified = True user.email_verified_at = datetime.utcnow() await db.commit() logger.info(f'Test email auto-verified: {request.email}, user_id={user.id}') else: # Сгенерировать токен верификации verification_token = generate_verification_token() verification_expires = get_verification_expires_at() user.email_verification_token = verification_token user.email_verification_expires = verification_expires await db.commit() # Отправить email верификации if settings.is_cabinet_email_verification_enabled() and email_service.is_configured(): cabinet_url = getattr(settings, 'CABINET_URL', 'https://example.com/cabinet') verification_url = f'{cabinet_url}/verify-email?token={verification_token}' await asyncio.to_thread( email_service.send_verification_email, to_email=request.email, verification_token=verification_token, verification_url=verification_url, username=user.first_name or 'User', ) # Создать токены и вернуть ответ response = _create_auth_response(user) await _store_refresh_token(db, user.id, response.refresh_token) return response @router.post('/email/verify') async def verify_email( request: EmailVerifyRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Verify email with token.""" # Find user with this token result = await db.execute(select(User).where(User.email_verification_token == request.token)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid verification token', ) if is_token_expired(user.email_verification_expires): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Verification token has expired', ) # Mark email as verified user.email_verified = True user.email_verified_at = datetime.utcnow() user.email_verification_token = None user.email_verification_expires = None await db.commit() return {'message': 'Email verified successfully'} @router.post('/email/resend') async def resend_verification( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Resend verification email.""" if not user.email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='No email address to verify', ) if user.email_verified: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Email is already verified', ) # Generate new token verification_token = generate_verification_token() verification_expires = get_verification_expires_at() user.email_verification_token = verification_token user.email_verification_expires = verification_expires await db.commit() # Send verification email asynchronously (smtplib is blocking) if settings.is_cabinet_email_verification_enabled() and email_service.is_configured(): verification_url = 'https://example.com/cabinet/verify-email' await asyncio.to_thread( email_service.send_verification_email, to_email=user.email, verification_token=verification_token, verification_url=verification_url, username=user.first_name, ) elif not settings.is_cabinet_email_verification_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Email verification is disabled', ) elif not email_service.is_configured(): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Email service is not configured', ) return {'message': 'Verification email sent'} @router.post('/email/login', response_model=AuthResponse) async def login_email( request: EmailLoginRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Login with email and password. Test email accounts (configured via TEST_EMAIL) bypass email verification. """ # Check if this is a test email login is_test_email = settings.is_test_email(request.email) # Find user by email result = await db.execute(select(User).where(User.email == request.email)) user = result.scalar_one_or_none() if not user: # For test email - auto-create user if not exists if is_test_email and settings.validate_test_email_password(request.email, request.password): logger.info(f'Test email login - creating new user: {request.email}') password_hash = hash_password(request.password) user = await create_user_by_email( db=db, email=request.email, password_hash=password_hash, first_name='Test User', language='ru', ) user.email_verified = True user.email_verified_at = datetime.utcnow() await db.commit() else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid email or password', ) if not user.password_hash: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Password login not configured for this account', ) if not verify_password(request.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid email or password', ) # Test email bypasses verification check if not user.email_verified and not is_test_email: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail='Please verify your email first', ) if user.status != 'active': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail='User account is not active', ) user.cabinet_last_login = datetime.utcnow() await db.commit() response = _create_auth_response(user) await _store_refresh_token(db, user.id, response.refresh_token) return response @router.post('/refresh', response_model=TokenResponse) async def refresh_token( request: RefreshTokenRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Refresh access token using refresh token.""" payload = get_token_payload(request.refresh_token, expected_type='refresh') if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid or expired refresh token', ) try: user_id = int(payload.get('sub')) except (TypeError, ValueError): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid token payload', ) # Verify token exists in database and is not revoked token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest() result = await db.execute( select(CabinetRefreshToken).where( CabinetRefreshToken.token_hash == token_hash, CabinetRefreshToken.revoked_at.is_(None), ) ) token_record = result.scalar_one_or_none() if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Refresh token not found or revoked', ) if not token_record.is_valid: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Refresh token is no longer valid', ) user = await get_user_by_id(db, user_id) if not user or user.status != 'active': raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='User not found or inactive', ) access_token = create_access_token(user.id, user.telegram_id) expires_in = settings.get_cabinet_access_token_expire_minutes() * 60 return TokenResponse( access_token=access_token, refresh_token=request.refresh_token, token_type='bearer', expires_in=expires_in, ) @router.post('/logout') async def logout( request: RefreshTokenRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Logout and revoke refresh token.""" token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest() result = await db.execute( select(CabinetRefreshToken).where( CabinetRefreshToken.token_hash == token_hash, ) ) token_record = result.scalar_one_or_none() if token_record: token_record.revoked_at = datetime.utcnow() await db.commit() return {'message': 'Logged out successfully'} @router.post('/password/forgot') async def forgot_password( request: PasswordForgotRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Request password reset.""" result = await db.execute(select(User).where(User.email == request.email)) user = result.scalar_one_or_none() # Always return success to prevent email enumeration if not user or not user.email_verified: return {'message': 'If the email exists, a password reset link has been sent'} # Generate reset token reset_token = generate_password_reset_token() reset_expires = get_password_reset_expires_at() user.password_reset_token = reset_token user.password_reset_expires = reset_expires await db.commit() # Send reset email asynchronously (smtplib is blocking) if email_service.is_configured(): reset_url = 'https://example.com/cabinet/reset-password' await asyncio.to_thread( email_service.send_password_reset_email, to_email=user.email, reset_token=reset_token, reset_url=reset_url, username=user.first_name, ) return {'message': 'If the email exists, a password reset link has been sent'} @router.post('/password/reset') async def reset_password( request: PasswordResetRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Reset password with token.""" result = await db.execute(select(User).where(User.password_reset_token == request.token)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid reset token', ) if is_token_expired(user.password_reset_expires): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Reset token has expired', ) # Update password user.password_hash = hash_password(request.password) user.password_reset_token = None user.password_reset_expires = None await db.commit() return {'message': 'Password reset successfully'} @router.get('/me', response_model=UserResponse) async def get_current_user( user: User = Depends(get_current_cabinet_user), ): """Get current authenticated user info.""" return _user_to_response(user) @router.get('/me/is-admin') async def check_is_admin( user: User = Depends(get_current_cabinet_user), ): """Check if current user is an admin.""" is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None) return {'is_admin': is_admin}