from datetime import timedelta, datetime, timezone from ulid import ULID from src.application.abstractions import IUnitOfWork from src.application.contracts import IHashService, IJwtService, ILogger, ICache from src.application.domain.dto import UserLoginDto from src.application.domain.entities import UserEntity from src.application.domain.exceptions import ApplicationException from src.infrastructure.config import settings from src.infrastructure.database.decorators import transactional class UserLoginCompleteCommand: def __init__( self, unit_of_work: IUnitOfWork, hash_service: IHashService, jwt_service: IJwtService, cache: ICache, logger: ILogger, ): self._unit_of_work = unit_of_work self._hash_service = hash_service self._jwt_service = jwt_service self._cache = cache self._logger = logger @transactional async def __call__( self, *, email: str, password: str, code: str, device_id: str, user_agent: str | None, ip: str | None, ) -> UserLoginDto: email = (email or '').strip().lower() code = (code or '').strip() code_key = f'login:code:{code}' email_key = f'login:email:{email}' cached_email = await self._cache.get(code_key) if not cached_email: self._logger.info(f'Login failed: code not found (email={email})') raise ApplicationException(400, 'Invalid or expired code') if cached_email != email: self._logger.info(f'Login failed: code-email mismatch (email={email}, cached_email={cached_email})') raise ApplicationException(400, 'Invalid or expired code') code_hash = await self._cache.get(email_key) if not code_hash: self._logger.info(f'Login failed: email key missing (email={email})') raise ApplicationException(400, 'Invalid or expired code') ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) if not ok: self._logger.info(f'Login failed: code hash mismatch (email={email})') raise ApplicationException(400, 'Invalid or expired code') now = datetime.now(timezone.utc) user: UserEntity = await self._unit_of_work.user_repository.get_user_by_email(email=email) ok = await self._hash_service.verify(plain_value=password, hashed_value=user.password_hash) if not ok: self._logger.warning(f'{user.id} login failed: invalid credentials') raise ApplicationException(status_code=401, message='Invalid credentials') try: await self._cache.delete(code_key) await self._cache.delete(email_key) except Exception as e: self._logger.warning(f'Login cleanup failed (email={email}): {e}') sid = str(ULID()) jti = str(ULID()) refresh_jti_hash = await self._hash_service.hash(value=jti) refresh_expires_at = now + timedelta(seconds=int(settings.JWT_REFRESH_TTL_SECONDS)) await self._unit_of_work.session_repository.upsert_by_device( user_id=user.id, device_id=device_id, sid=sid, refresh_jti_hash=refresh_jti_hash, refresh_expires_at=refresh_expires_at, user_agent=user_agent, ip=ip, now=now, ) access_token = await self._jwt_service.create_access_token(user_id=user.id, sid=sid) refresh_token = await self._jwt_service.create_refresh_token(user_id=user.id, sid=sid, refresh_jti=jti) return UserLoginDto( id=user.id, email=user.email, first_name=user.first_name, middle_name=user.middle_name, last_name=user.last_name, birth_date=user.birth_date, crypto_wallet=user.crypto_wallet, phone=user.phone, passport_data=user.passport_data, inn=user.inn, kyc_verified=user.kyc_verified, kyc_verified_at=user.kyc_verified_at, created_at=user.created_at, updated_at=user.updated_at, access_token=access_token, refresh_token=refresh_token, )