eb18994b7d
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC) - Migrate 30+ datetime.now() without UTC to datetime.now(UTC) - Convert all 170 DateTime columns to DateTime(timezone=True) - Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety - Remove 70+ .replace(tzinfo=None) workarounds - Fix utcfromtimestamp → fromtimestamp(..., tz=UTC) - Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics) - Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo) - Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics) - Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring) - Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC) - Rename panel_datetime_to_naive_utc → panel_datetime_to_utc - Remove DTZ003 from ruff ignore list
1202 lines
41 KiB
Python
1202 lines
41 KiB
Python
"""Authentication routes for cabinet."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
from datetime import UTC, datetime
|
|
|
|
import structlog
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.database.crud.user import (
|
|
clear_email_change_pending,
|
|
create_user,
|
|
create_user_by_email,
|
|
get_user_by_id,
|
|
get_user_by_referral_code,
|
|
get_user_by_telegram_id,
|
|
is_email_taken,
|
|
set_email_change_pending,
|
|
verify_and_apply_email_change,
|
|
)
|
|
from app.database.models import CabinetRefreshToken, User
|
|
from app.services.disposable_email_service import disposable_email_service
|
|
from app.services.referral_service import process_referral_registration
|
|
from app.utils.timezone import panel_datetime_to_utc
|
|
|
|
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_email_change_code,
|
|
generate_password_reset_token,
|
|
generate_verification_token,
|
|
get_email_change_expires_at,
|
|
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,
|
|
EmailChangeRequest,
|
|
EmailChangeResponse,
|
|
EmailChangeVerifyRequest,
|
|
EmailLoginRequest,
|
|
EmailRegisterRequest,
|
|
EmailRegisterStandaloneRequest,
|
|
EmailVerifyRequest,
|
|
PasswordForgotRequest,
|
|
PasswordResetRequest,
|
|
RefreshTokenRequest,
|
|
RegisterResponse,
|
|
TelegramAuthRequest,
|
|
TelegramWidgetAuthRequest,
|
|
TokenResponse,
|
|
UserResponse,
|
|
)
|
|
from ..services.email_service import email_service
|
|
from ..services.email_template_overrides import get_rendered_override
|
|
|
|
|
|
logger = structlog.get_logger(__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()
|
|
|
|
|
|
async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -> None:
|
|
"""
|
|
Check if user has subscription in RemnaWave panel by email and sync it.
|
|
Called after email verification to import existing subscriptions.
|
|
"""
|
|
if not user.email:
|
|
return
|
|
|
|
try:
|
|
from app.services.remnawave_service import RemnaWaveService
|
|
|
|
service = RemnaWaveService()
|
|
if not service.is_configured:
|
|
return
|
|
|
|
async with service.get_api_client() as api:
|
|
# Try to find user by email in panel
|
|
panel_users = await api.get_user_by_email(user.email)
|
|
|
|
if not panel_users:
|
|
logger.debug('No subscription found in panel for email', email=user.email)
|
|
return
|
|
|
|
# Take first user if multiple found
|
|
panel_user = panel_users[0]
|
|
logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid)
|
|
|
|
# Link user to panel
|
|
user.remnawave_uuid = panel_user.uuid
|
|
|
|
# Create or update subscription
|
|
from app.database.crud.subscription import get_subscription_by_user_id
|
|
from app.database.models import Subscription, SubscriptionStatus
|
|
|
|
existing_sub = await get_subscription_by_user_id(db, user.id)
|
|
|
|
# Parse panel data — panel returns local time with misleading +00:00 offset
|
|
expire_at = panel_datetime_to_utc(panel_user.expire_at)
|
|
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
|
|
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
|
|
|
|
# Extract squad UUIDs from active_internal_squads
|
|
connected_squads = [s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid')]
|
|
|
|
# Device limit from panel
|
|
device_limit = panel_user.hwid_device_limit or 1
|
|
|
|
# Determine status — expire_at is now naive UTC
|
|
current_time = datetime.now(UTC)
|
|
|
|
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
|
|
sub_status = SubscriptionStatus.ACTIVE
|
|
elif expire_at <= current_time:
|
|
sub_status = SubscriptionStatus.EXPIRED
|
|
else:
|
|
sub_status = SubscriptionStatus.DISABLED
|
|
|
|
if existing_sub:
|
|
# Update existing subscription (expire_at already naive UTC)
|
|
existing_sub.end_date = expire_at
|
|
existing_sub.traffic_limit_gb = traffic_limit_gb
|
|
existing_sub.traffic_used_gb = traffic_used_gb
|
|
existing_sub.status = sub_status.value
|
|
existing_sub.remnawave_short_uuid = panel_user.short_uuid
|
|
existing_sub.subscription_url = panel_user.subscription_url
|
|
existing_sub.subscription_crypto_link = panel_user.happ_crypto_link
|
|
existing_sub.connected_squads = connected_squads
|
|
existing_sub.device_limit = device_limit
|
|
existing_sub.is_trial = False # Panel subscription is not trial
|
|
logger.info(
|
|
'Updated subscription for email user squads: devices',
|
|
email=user.email,
|
|
connected_squads=connected_squads,
|
|
device_limit=device_limit,
|
|
)
|
|
else:
|
|
# Create new subscription (expire_at and current_time already naive UTC)
|
|
new_sub = Subscription(
|
|
user_id=user.id,
|
|
start_date=current_time,
|
|
end_date=expire_at,
|
|
traffic_limit_gb=traffic_limit_gb,
|
|
traffic_used_gb=traffic_used_gb,
|
|
status=sub_status.value,
|
|
is_trial=False,
|
|
remnawave_short_uuid=panel_user.short_uuid,
|
|
subscription_url=panel_user.subscription_url,
|
|
subscription_crypto_link=panel_user.happ_crypto_link,
|
|
connected_squads=connected_squads,
|
|
device_limit=device_limit,
|
|
)
|
|
db.add(new_sub)
|
|
logger.info(
|
|
'Created subscription for email user squads: devices',
|
|
email=user.email,
|
|
connected_squads=connected_squads,
|
|
device_limit=device_limit,
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
except Exception as e:
|
|
logger.warning('Failed to sync subscription from panel for', email=user.email, error=e)
|
|
# Don't rollback - it detaches user object and breaks subsequent operations
|
|
# The sync is non-critical, main verification already succeeded
|
|
|
|
|
|
@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('Creating new user from cabinet (initData): telegram_id', 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('User created successfully: id=, telegram_id', user_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('User profile updated from initData', user_id=user.id)
|
|
|
|
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.now(UTC)
|
|
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(
|
|
'Creating new user from cabinet: telegram_id=, username', request_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('User created successfully: id=, telegram_id', user_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.now(UTC)
|
|
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 for disposable email
|
|
if disposable_email_service.is_disposable(request.email):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Disposable email addresses are not allowed',
|
|
)
|
|
|
|
# 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():
|
|
cabinet_url = settings.CABINET_URL
|
|
verification_url = f'{cabinet_url}/verify-email'
|
|
lang = user.language or 'ru'
|
|
full_url = f'{verification_url}?token={verification_token}'
|
|
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
|
|
|
# Check for admin template override
|
|
override = await get_rendered_override(
|
|
'email_verification',
|
|
lang,
|
|
context={
|
|
'username': user.first_name or '',
|
|
'verification_url': full_url,
|
|
'expire_hours': str(expire_hours),
|
|
},
|
|
db=db,
|
|
)
|
|
custom_subject, custom_body = override if override else (None, None)
|
|
|
|
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,
|
|
language=lang,
|
|
custom_subject=custom_subject,
|
|
custom_body_html=custom_body,
|
|
)
|
|
|
|
return {
|
|
'message': 'Verification email sent',
|
|
'email': request.email,
|
|
}
|
|
|
|
|
|
@router.post('/email/register/standalone', response_model=RegisterResponse)
|
|
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.
|
|
|
|
User must verify email before they can login.
|
|
|
|
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('Test email registration', email=request.email)
|
|
|
|
# Check for disposable email
|
|
if disposable_email_service.is_disposable(request.email):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Disposable email addresses are not allowed',
|
|
)
|
|
|
|
# Проверить что 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)
|
|
|
|
# Найти реферера по коду (если указан)
|
|
referrer = None
|
|
if request.referral_code:
|
|
referrer = await get_user_by_referral_code(db, request.referral_code)
|
|
if referrer:
|
|
# Защита от самореферала - нельзя регистрироваться по своему же коду
|
|
if referrer.email and referrer.email.lower() == request.email.lower():
|
|
logger.warning(
|
|
'Self-referral attempt blocked: email=, code',
|
|
email=request.email,
|
|
referral_code=request.referral_code,
|
|
)
|
|
referrer = None
|
|
else:
|
|
logger.info(
|
|
'Found referrer for email registration: referrer_id=, code',
|
|
referrer_id=referrer.id,
|
|
referral_code=request.referral_code,
|
|
)
|
|
|
|
# Создать пользователя
|
|
user = await create_user_by_email(
|
|
db=db,
|
|
email=request.email,
|
|
password_hash=password_hash,
|
|
first_name=request.first_name,
|
|
language=request.language,
|
|
referred_by_id=referrer.id if referrer else None,
|
|
)
|
|
|
|
# Для тестового email - автоматически верифицировать
|
|
if is_test_email:
|
|
user.email_verified = True
|
|
user.email_verified_at = datetime.now(UTC)
|
|
await db.commit()
|
|
logger.info('Test email auto-verified: user_id', email=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 = settings.CABINET_URL
|
|
verification_url = f'{cabinet_url}/verify-email'
|
|
lang = user.language or request.language or 'ru'
|
|
full_url = f'{verification_url}?token={verification_token}'
|
|
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
|
|
|
override = await get_rendered_override(
|
|
'email_verification',
|
|
lang,
|
|
context={
|
|
'username': user.first_name or 'User',
|
|
'verification_url': full_url,
|
|
'expire_hours': str(expire_hours),
|
|
},
|
|
db=db,
|
|
)
|
|
custom_subject, custom_body = override if override else (None, None)
|
|
|
|
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',
|
|
language=lang,
|
|
custom_subject=custom_subject,
|
|
custom_body_html=custom_body,
|
|
)
|
|
|
|
# Обработать реферальную регистрацию (если есть реферер)
|
|
if referrer:
|
|
try:
|
|
await process_referral_registration(db, user.id, referrer.id, bot=None)
|
|
logger.info(
|
|
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
|
|
)
|
|
except Exception as e:
|
|
logger.error('Failed to process referral registration', error=e)
|
|
# Не прерываем регистрацию из-за ошибки реферальной системы
|
|
|
|
# Для тестового email - сразу можно логиниться (уже verified)
|
|
# Для обычного email - требуется верификация
|
|
return RegisterResponse(
|
|
message='Verification email sent. Please check your inbox.',
|
|
email=request.email,
|
|
requires_verification=not is_test_email,
|
|
)
|
|
|
|
|
|
@router.post('/email/verify', response_model=AuthResponse)
|
|
async def verify_email(
|
|
request: EmailVerifyRequest,
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""Verify email with token and return auth tokens."""
|
|
# 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.now(UTC)
|
|
user.email_verification_token = None
|
|
user.email_verification_expires = None
|
|
user.cabinet_last_login = datetime.now(UTC)
|
|
|
|
await db.commit()
|
|
|
|
# Check if user has subscription in RemnaWave panel by email
|
|
await _sync_subscription_from_panel_by_email(db, user)
|
|
|
|
# Return auth tokens so user is logged in after verification
|
|
response = _create_auth_response(user)
|
|
await _store_refresh_token(db, user.id, response.refresh_token)
|
|
|
|
return response
|
|
|
|
|
|
@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():
|
|
cabinet_url = settings.CABINET_URL
|
|
verification_url = f'{cabinet_url}/verify-email'
|
|
lang = user.language or 'ru'
|
|
full_url = f'{verification_url}?token={verification_token}'
|
|
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
|
|
|
override = await get_rendered_override(
|
|
'email_verification',
|
|
lang,
|
|
context={
|
|
'username': user.first_name or '',
|
|
'verification_url': full_url,
|
|
'expire_hours': str(expire_hours),
|
|
},
|
|
db=db,
|
|
)
|
|
custom_subject, custom_body = override if override else (None, None)
|
|
|
|
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,
|
|
language=lang,
|
|
custom_subject=custom_subject,
|
|
custom_body_html=custom_body,
|
|
)
|
|
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('Test email login creating new user', email=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.now(UTC)
|
|
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.now(UTC)
|
|
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.now(UTC)
|
|
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():
|
|
cabinet_url = settings.CABINET_URL
|
|
reset_url = f'{cabinet_url}/reset-password'
|
|
lang = user.language or 'ru'
|
|
full_url = f'{reset_url}?token={reset_token}'
|
|
expire_hours = settings.get_cabinet_password_reset_expire_hours()
|
|
|
|
override = await get_rendered_override(
|
|
'password_reset',
|
|
lang,
|
|
context={'username': user.first_name or '', 'reset_url': full_url, 'expire_hours': str(expire_hours)},
|
|
db=db,
|
|
)
|
|
custom_subject, custom_body = override if override else (None, None)
|
|
|
|
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,
|
|
language=lang,
|
|
custom_subject=custom_subject,
|
|
custom_body_html=custom_body,
|
|
)
|
|
|
|
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}
|
|
|
|
|
|
@router.post('/email/change', response_model=EmailChangeResponse)
|
|
async def request_email_change(
|
|
request: EmailChangeRequest,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""
|
|
Request email change.
|
|
|
|
For verified emails: sends a 6-digit verification code to the new email.
|
|
For unverified emails: replaces the email directly and sends verification to the new address.
|
|
"""
|
|
if not user.email:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='No email address to change',
|
|
)
|
|
|
|
# Check if new email is the same as current
|
|
if request.new_email.lower() == user.email.lower():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='New email is the same as current email',
|
|
)
|
|
|
|
# Check for disposable email
|
|
if disposable_email_service.is_disposable(request.new_email):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Disposable email addresses are not allowed',
|
|
)
|
|
|
|
# Check if new email is already taken
|
|
if await is_email_taken(db, request.new_email, exclude_user_id=user.id):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='This email is already registered',
|
|
)
|
|
|
|
# Unverified email: replace directly and send verification to new address
|
|
if not user.email_verified:
|
|
old_email = user.email
|
|
user.email = request.new_email.lower()
|
|
user.email_verified = False
|
|
|
|
verification_token = generate_verification_token()
|
|
verification_expires = get_verification_expires_at()
|
|
user.email_verification_token = verification_token
|
|
user.email_verification_expires = verification_expires
|
|
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='This email is already registered',
|
|
)
|
|
|
|
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
|
|
cabinet_url = settings.CABINET_URL
|
|
verification_url = f'{cabinet_url}/verify-email'
|
|
lang = user.language or 'ru'
|
|
full_url = f'{verification_url}?token={verification_token}'
|
|
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
|
|
|
override = await get_rendered_override(
|
|
'email_verification',
|
|
lang,
|
|
context={
|
|
'username': user.first_name or '',
|
|
'verification_url': full_url,
|
|
'expire_hours': str(expire_hours),
|
|
},
|
|
db=db,
|
|
)
|
|
custom_subject, custom_body = override if override else (None, None)
|
|
|
|
try:
|
|
await asyncio.to_thread(
|
|
email_service.send_verification_email,
|
|
to_email=request.new_email,
|
|
verification_token=verification_token,
|
|
verification_url=verification_url,
|
|
username=user.first_name,
|
|
language=lang,
|
|
custom_subject=custom_subject,
|
|
custom_body_html=custom_body,
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
'Failed to send verification email to for user',
|
|
new_email=request.new_email,
|
|
user_id=user.id,
|
|
error=e,
|
|
)
|
|
|
|
logger.info(
|
|
'Unverified email replaced for user', user_id=user.id, old_email=old_email, new_email=request.new_email
|
|
)
|
|
|
|
return EmailChangeResponse(
|
|
message='Email replaced, verification sent to new address',
|
|
new_email=request.new_email,
|
|
expires_in_minutes=0,
|
|
)
|
|
|
|
# Verified email: send code to new address for confirmation
|
|
# Generate verification code
|
|
code = generate_email_change_code()
|
|
expires_at = get_email_change_expires_at()
|
|
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
|
|
|
# Save pending email change
|
|
await set_email_change_pending(db, user, request.new_email, code, expires_at)
|
|
|
|
# Send verification email to new address
|
|
if email_service.is_configured():
|
|
lang = user.language or 'ru'
|
|
|
|
# Check for admin template override
|
|
override = await get_rendered_override(
|
|
'email_change_code',
|
|
lang,
|
|
context={
|
|
'username': user.first_name or '',
|
|
'code': code,
|
|
'expire_minutes': str(expire_minutes),
|
|
},
|
|
db=db,
|
|
)
|
|
custom_subject, custom_body = override if override else (None, None)
|
|
|
|
await asyncio.to_thread(
|
|
email_service.send_email_change_code,
|
|
to_email=request.new_email,
|
|
code=code,
|
|
username=user.first_name,
|
|
language=lang,
|
|
custom_subject=custom_subject,
|
|
custom_body_html=custom_body,
|
|
)
|
|
else:
|
|
# Clear pending change if email service is not configured
|
|
await clear_email_change_pending(db, user)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail='Email service is not configured',
|
|
)
|
|
|
|
logger.info('Email change requested for user', user_id=user.id, email=user.email, new_email=request.new_email)
|
|
|
|
return EmailChangeResponse(
|
|
message='Verification code sent to new email',
|
|
new_email=request.new_email,
|
|
expires_in_minutes=expire_minutes,
|
|
)
|
|
|
|
|
|
@router.post('/email/change/verify')
|
|
async def verify_email_change(
|
|
request: EmailChangeVerifyRequest,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""
|
|
Verify email change with code.
|
|
|
|
Completes the email change process if the code is valid.
|
|
"""
|
|
success, message = await verify_and_apply_email_change(db, user, request.code)
|
|
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=message,
|
|
)
|
|
|
|
return {
|
|
'message': message,
|
|
'new_email': user.email,
|
|
}
|
|
|
|
|
|
@router.post('/email/change/cancel')
|
|
async def cancel_email_change(
|
|
user: User = Depends(get_current_cabinet_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
):
|
|
"""
|
|
Cancel pending email change.
|
|
"""
|
|
if not user.email_change_new:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='No pending email change',
|
|
)
|
|
|
|
await clear_email_change_pending(db, user)
|
|
|
|
return {'message': 'Email change cancelled'}
|
|
|
|
|
|
@router.get('/email/change/status')
|
|
async def get_email_change_status(
|
|
user: User = Depends(get_current_cabinet_user),
|
|
):
|
|
"""
|
|
Get pending email change status.
|
|
"""
|
|
if not user.email_change_new:
|
|
return {
|
|
'pending': False,
|
|
'new_email': None,
|
|
'expires_at': None,
|
|
}
|
|
|
|
return {
|
|
'pending': True,
|
|
'new_email': user.email_change_new,
|
|
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
|
|
}
|