10 Commits

Author SHA1 Message Date
35b968d288 feat: add new custom 500 exceptions 2026-06-01 15:07:44 +03:00
a1b41e8317 feat: add 500 csrf exception 2026-05-29 14:34:02 +03:00
48e917eece refactor: change exceptions to more specific 2026-05-28 18:23:44 +03:00
b9e980db94 refactor: change import paths 2026-05-28 16:56:07 +03:00
fb7856260f refactor: divided exceptions into files 2026-05-28 14:13:43 +03:00
41d0fe8aa7 feat: change router 2026-05-19 15:26:50 +03:00
9c2190737a feat: add reset password 2026-05-19 15:23:22 +03:00
bd1faffbb0 feat: add change password event 2026-05-19 08:58:12 +03:00
f426495d47 feat: upodate uow 2026-05-17 15:36:53 +03:00
4d3683dc01 feat: upodate uow 2026-05-17 15:34:43 +03:00
42 changed files with 775 additions and 203 deletions

View File

@@ -38,6 +38,10 @@ class IUserRepository(ABC):
async def email_exists(self, email: str) -> bool: async def email_exists(self, email: str) -> bool:
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def get_user_by_email(self, email: str) -> UserEntity | None:
raise NotImplementedError
@abstractmethod @abstractmethod
async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity: async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity:
raise NotImplementedError raise NotImplementedError

View File

@@ -8,6 +8,8 @@ from src.application.commands.update_bank_details_start import UpdateBankDetails
from src.application.commands.update_bank_details_complete import UpdateBankDetailsCompleteCommand from src.application.commands.update_bank_details_complete import UpdateBankDetailsCompleteCommand
from src.application.commands.change_password_start import ChangePasswordStartCommand from src.application.commands.change_password_start import ChangePasswordStartCommand
from src.application.commands.change_password_complete import ChangePasswordCompleteCommand from src.application.commands.change_password_complete import ChangePasswordCompleteCommand
from src.application.commands.forgot_password_start import ForgotPasswordStartCommand
from src.application.commands.forgot_password_complete import ForgotPasswordCompleteCommand
from src.application.commands.change_email_start import ChangeEmailStartCommand from src.application.commands.change_email_start import ChangeEmailStartCommand
from src.application.commands.change_email_confirm_old import ChangeEmailConfirmOldCommand from src.application.commands.change_email_confirm_old import ChangeEmailConfirmOldCommand
from src.application.commands.change_email_complete import ChangeEmailCompleteCommand from src.application.commands.change_email_complete import ChangeEmailCompleteCommand

View File

@@ -1,6 +1,6 @@
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ILogger, ICache from src.application.contracts import IHashService, ILogger, ICache
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import BadRequestException
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -30,16 +30,16 @@ class ChangeEmailCompleteCommand:
cached_user_id = await self._cache.get(new_code_key) cached_user_id = await self._cache.get(new_code_key)
if not cached_user_id: if not cached_user_id:
self._logger.info(f'Change email complete failed: code not found (user_id={user_id})') self._logger.info(f'Change email complete failed: code not found (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
if cached_user_id != user_id: if cached_user_id != user_id:
self._logger.info(f'Change email complete failed: code-user mismatch (user_id={user_id})') self._logger.info(f'Change email complete failed: code-user mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
raw_value = await self._cache.get(new_user_key) raw_value = await self._cache.get(new_user_key)
if not raw_value: if not raw_value:
self._logger.info(f'Change email complete failed: user key missing (user_id={user_id})') self._logger.info(f'Change email complete failed: user key missing (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
separator_idx = raw_value.index(':') separator_idx = raw_value.index(':')
code_hash = raw_value[:separator_idx] code_hash = raw_value[:separator_idx]
@@ -48,7 +48,7 @@ class ChangeEmailCompleteCommand:
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
if not ok: if not ok:
self._logger.info(f'Change email complete failed: code hash mismatch (user_id={user_id})') self._logger.info(f'Change email complete failed: code hash mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
user = await self._unit_of_work.user_repository.set_email(user_id=user_id, email=new_email) user = await self._unit_of_work.user_repository.set_email(user_id=user_id, email=new_email)
await self._cache.set_user(user_id, user) await self._cache.set_user(user_id, user)

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from ulid import ULID from ulid import ULID
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import BadRequestException, ConflictException, ServiceUnavailableException
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.infrastructure.context_vars import trace_id_var from src.infrastructure.context_vars import trace_id_var
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -41,32 +41,32 @@ class ChangeEmailConfirmOldCommand:
cached_user_id = await self._cache.get(old_code_key) cached_user_id = await self._cache.get(old_code_key)
if not cached_user_id: if not cached_user_id:
self._logger.info(f'Change email confirm-old failed: code not found (user_id={user_id})') self._logger.info(f'Change email confirm-old failed: code not found (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
if cached_user_id != user_id: if cached_user_id != user_id:
self._logger.info(f'Change email confirm-old failed: code-user mismatch (user_id={user_id})') self._logger.info(f'Change email confirm-old failed: code-user mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
code_hash = await self._cache.get(old_user_key) code_hash = await self._cache.get(old_user_key)
if not code_hash: if not code_hash:
self._logger.info(f'Change email confirm-old failed: user key missing (user_id={user_id})') self._logger.info(f'Change email confirm-old failed: user key missing (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
if not ok: if not ok:
self._logger.info(f'Change email confirm-old failed: code hash mismatch (user_id={user_id})') self._logger.info(f'Change email confirm-old failed: code hash mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
if user.email and user.email.lower() == new_email.lower(): if user.email and user.email.lower() == new_email.lower():
self._logger.info(f'Change email confirm-old failed: new email same as current (user_id={user_id})') self._logger.info(f'Change email confirm-old failed: new email same as current (user_id={user_id})')
raise ApplicationException(400, 'New email must differ from the current one') raise BadRequestException(message='New email must differ from the current one')
email_taken = await self._unit_of_work.user_repository.email_exists(email=new_email) email_taken = await self._unit_of_work.user_repository.email_exists(email=new_email)
if email_taken: if email_taken:
self._logger.info(f'Change email confirm-old failed: new email already taken (user_id={user_id})') self._logger.info(f'Change email confirm-old failed: new email already taken (user_id={user_id})')
raise ApplicationException(409, 'Email already in use') raise ConflictException(message='Email already in use')
try: try:
await self._cache.delete(old_code_key) await self._cache.delete(old_code_key)
@@ -94,7 +94,7 @@ class ChangeEmailConfirmOldCommand:
if not saved: if not saved:
await self._cache.delete(new_code_key) await self._cache.delete(new_code_key)
self._logger.error(f'Change email confirm-old failed: cannot save new code hash for user_id={user_id}') self._logger.error(f'Change email confirm-old failed: cannot save new code hash for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
message_id = str(ULID()) message_id = str(ULID())
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
@@ -137,9 +137,9 @@ class ChangeEmailConfirmOldCommand:
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
self._logger.error(f'Failed to publish change email new code for user_id={user_id}: {str(exception)}') self._logger.error(f'Failed to publish change email new code for user_id={user_id}: {str(exception)}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
return True return True
self._logger.error(f'Change email confirm-old failed: code space exhausted for user_id={user_id}') self._logger.error(f'Change email confirm-old failed: code space exhausted for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from ulid import ULID from ulid import ULID
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import NotFoundException, TooManyRequestsException, ServiceUnavailableException
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.infrastructure.context_vars import trace_id_var from src.infrastructure.context_vars import trace_id_var
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -38,7 +38,7 @@ class ChangeEmailStartCommand:
if not user.email: if not user.email:
self._logger.warning(f'User {user_id} does not have an email address') self._logger.warning(f'User {user_id} does not have an email address')
raise ApplicationException(404, f'User {user_id} does not have an email address') raise NotFoundException(message=f'User {user_id} does not have an email address')
trace_id = trace_id_var.get() trace_id = trace_id_var.get()
if not trace_id or trace_id == 'N/A': if not trace_id or trace_id == 'N/A':
@@ -48,7 +48,7 @@ class ChangeEmailStartCommand:
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
if not locked: if not locked:
self._logger.info(f'Change email throttled by lock (user_id={user_id})') self._logger.info(f'Change email throttled by lock (user_id={user_id})')
raise ApplicationException(429, 'Too many requests. Please wait.') raise TooManyRequestsException(message='Too many requests. Please wait.')
try: try:
user_key = f'{USER_PREFIX}{user_id}' user_key = f'{USER_PREFIX}{user_id}'
@@ -56,7 +56,7 @@ class ChangeEmailStartCommand:
existing = await self._cache.get(user_key) existing = await self._cache.get(user_key)
if existing: if existing:
self._logger.info(f'Change email denied: code already exists for user_id={user_id}') self._logger.info(f'Change email denied: code already exists for user_id={user_id}')
raise ApplicationException(429, 'Code already sent. Please wait before retrying.') raise TooManyRequestsException(message='Code already sent. Please wait before retrying.')
for _ in range(MAX_ATTEMPTS): for _ in range(MAX_ATTEMPTS):
code = f'{secrets.randbelow(1_000_000):06d}' code = f'{secrets.randbelow(1_000_000):06d}'
@@ -72,7 +72,7 @@ class ChangeEmailStartCommand:
if not saved: if not saved:
await self._cache.delete(code_key) await self._cache.delete(code_key)
self._logger.error(f'Change email failed: cannot save code hash for user_id={user_id}') self._logger.error(f'Change email failed: cannot save code hash for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
message_id = str(ULID()) message_id = str(ULID())
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
@@ -115,12 +115,12 @@ class ChangeEmailStartCommand:
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
self._logger.error(f'Failed to publish change email old code for user_id={user_id}: {str(exception)}') self._logger.error(f'Failed to publish change email old code for user_id={user_id}: {str(exception)}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
return True return True
self._logger.error(f'Change email failed: code space exhausted for user_id={user_id}') self._logger.error(f'Change email failed: code space exhausted for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
finally: finally:
await self._cache.delete(lock_key) await self._cache.delete(lock_key)

View File

@@ -1,6 +1,6 @@
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ILogger, ICache from src.application.contracts import IHashService, ILogger, ICache
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import BadRequestException
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -36,33 +36,33 @@ class ChangePasswordCompleteCommand:
if new_password != confirm_password: if new_password != confirm_password:
self._logger.info(f'Change password failed: passwords do not match (user_id={user_id})') self._logger.info(f'Change password failed: passwords do not match (user_id={user_id})')
raise ApplicationException(400, 'Passwords do not match') raise BadRequestException(message='Passwords do not match')
cached_user_id = await self._cache.get(code_key) cached_user_id = await self._cache.get(code_key)
if not cached_user_id: if not cached_user_id:
self._logger.info(f'Change password failed: code not found (user_id={user_id})') self._logger.info(f'Change password failed: code not found (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
if cached_user_id != user_id: if cached_user_id != user_id:
self._logger.info(f'Change password failed: code-user mismatch (user_id={user_id})') self._logger.info(f'Change password failed: code-user mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
code_hash = await self._cache.get(user_key) code_hash = await self._cache.get(user_key)
if not code_hash: if not code_hash:
self._logger.info(f'Change password failed: user key missing (user_id={user_id})') self._logger.info(f'Change password failed: user key missing (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
if not ok: if not ok:
self._logger.info(f'Change password failed: code hash mismatch (user_id={user_id})') self._logger.info(f'Change password failed: code hash mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
current_password_hash = await self._unit_of_work.user_repository.get_password_hash(user_id=user_id) current_password_hash = await self._unit_of_work.user_repository.get_password_hash(user_id=user_id)
is_same = await self._hash_service.verify(hashed_value=current_password_hash, plain_value=new_password) is_same = await self._hash_service.verify(hashed_value=current_password_hash, plain_value=new_password)
if is_same: if is_same:
self._logger.info(f'Change password failed: new password same as current (user_id={user_id})') self._logger.info(f'Change password failed: new password same as current (user_id={user_id})')
raise ApplicationException(400, 'New password must differ from the current one') raise BadRequestException(message='New password must differ from the current one')
new_password_hash = await self._hash_service.hash(new_password) new_password_hash = await self._hash_service.hash(new_password)
user = await self._unit_of_work.user_repository.set_password( user = await self._unit_of_work.user_repository.set_password(

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from ulid import ULID from ulid import ULID
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import NotFoundException, TooManyRequestsException, ServiceUnavailableException
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.infrastructure.context_vars import trace_id_var from src.infrastructure.context_vars import trace_id_var
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -38,7 +38,7 @@ class ChangePasswordStartCommand:
if not user.email: if not user.email:
self._logger.warning(f'User {user_id} does not have an email address') self._logger.warning(f'User {user_id} does not have an email address')
raise ApplicationException(404, f'User {user_id} does not have an email address') raise NotFoundException(message=f'User {user_id} does not have an email address')
trace_id = trace_id_var.get() trace_id = trace_id_var.get()
if not trace_id or trace_id == 'N/A': if not trace_id or trace_id == 'N/A':
@@ -48,7 +48,7 @@ class ChangePasswordStartCommand:
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
if not locked: if not locked:
self._logger.info(f'Change password throttled by lock (user_id={user_id})') self._logger.info(f'Change password throttled by lock (user_id={user_id})')
raise ApplicationException(429, 'Too many requests. Please wait.') raise TooManyRequestsException(message='Too many requests. Please wait.')
try: try:
user_key = f'{USER_PREFIX}{user_id}' user_key = f'{USER_PREFIX}{user_id}'
@@ -56,7 +56,7 @@ class ChangePasswordStartCommand:
existing = await self._cache.get(user_key) existing = await self._cache.get(user_key)
if existing: if existing:
self._logger.info(f'Change password denied: code already exists for user_id={user_id}') self._logger.info(f'Change password denied: code already exists for user_id={user_id}')
raise ApplicationException(429, 'Code already sent. Please wait before retrying.') raise TooManyRequestsException(message='Code already sent. Please wait before retrying.')
for _ in range(MAX_ATTEMPTS): for _ in range(MAX_ATTEMPTS):
code = f'{secrets.randbelow(1_000_000):06d}' code = f'{secrets.randbelow(1_000_000):06d}'
@@ -72,7 +72,7 @@ class ChangePasswordStartCommand:
if not saved: if not saved:
await self._cache.delete(code_key) await self._cache.delete(code_key)
self._logger.error(f'Change password failed: cannot save code hash for user_id={user_id}') self._logger.error(f'Change password failed: cannot save code hash for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
message_id = str(ULID()) message_id = str(ULID())
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
@@ -115,12 +115,12 @@ class ChangePasswordStartCommand:
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
self._logger.error(f'Failed to publish change password email for user_id={user_id}: {str(exception)}') self._logger.error(f'Failed to publish change password email for user_id={user_id}: {str(exception)}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
return True return True
self._logger.error(f'Change password failed: code space exhausted for user_id={user_id}') self._logger.error(f'Change password failed: code space exhausted for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
finally: finally:
await self._cache.delete(lock_key) await self._cache.delete(lock_key)

View File

@@ -17,25 +17,40 @@ class DeleteAvatarCommand:
@transactional @transactional
async def _load_user(self, user_id: str) -> UserEntity: async def _load_user(self, user_id: str) -> UserEntity:
return await self._unit_of_work.user_repository.get_user_by_id(user_id) user = await self._unit_of_work.user_repository.get_user_by_id(user_id)
self._logger.debug(f'DeleteAvatar _load_user user_id={user_id} has_avatar_link={bool(user.avatar_link)}')
return user
async def __call__(self, user_id: str) -> UserEntity: async def __call__(self, user_id: str) -> UserEntity:
prior = await self._load_user(user_id) prior = await self._load_user(user_id)
link = prior.avatar_link link = prior.avatar_link
self._logger.info(f'DeleteAvatar start user_id={user_id} had_link={bool(link)}')
if link: if link:
key = self._s3.object_key_from_public_url(link) key = self._s3.object_key_from_public_url(link)
self._logger.debug(f'DeleteAvatar parsed_object_key user_id={user_id} has_key={bool(key)}')
if not key:
self._logger.warning(
f'DeleteAvatar could not parse avatar URL for S3 user_id={user_id} link_len={len(link)}'
)
if key: if key:
self._logger.info(f'DeleteAvatar S3 delete start user_id={user_id} key={key}')
try: try:
await self._s3.delete_object(key=key) await self._s3.delete_object(key=key)
self._logger.info(f'DeleteAvatar S3 delete done user_id={user_id} key={key}')
except ClientError as exc: except ClientError as exc:
code = exc.response.get('Error', {}).get('Code', '') code = exc.response.get('Error', {}).get('Code', '')
if code not in ('NoSuchKey', '404'): if code not in ('NoSuchKey', '404'):
self._logger.warning(f'S3 delete avatar failed user_id={user_id} code={code}: {exc}') self._logger.warning(f'DeleteAvatar S3 delete failed user_id={user_id} code={code}: {exc}')
else:
self._logger.debug(f'DeleteAvatar S3 object already absent user_id={user_id} code={code}')
user = await self._clear_avatar_link(user_id) user = await self._clear_avatar_link(user_id)
self._logger.debug(f'DeleteAvatar DB cleared user_id={user_id} entity_has_link={bool(user.avatar_link)}')
await self._cache.set_user(user_id, user) await self._cache.set_user(user_id, user)
self._logger.debug(f'DeleteAvatar cache updated user_id={user_id}')
self._logger.info(f'Avatar removed user_id={user_id}') self._logger.info(f'Avatar removed user_id={user_id}')
return user return user
@transactional @transactional
async def _clear_avatar_link(self, user_id: str) -> UserEntity: async def _clear_avatar_link(self, user_id: str) -> UserEntity:
self._logger.debug(f'DeleteAvatar DB transaction set_avatar_link user_id={user_id} link=None')
return await self._unit_of_work.user_repository.set_avatar_link(user_id, None) return await self._unit_of_work.user_repository.set_avatar_link(user_id, None)

View File

@@ -0,0 +1,83 @@
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ILogger, ICache
from src.application.domain.exceptions import BadRequestException
from src.infrastructure.database.decorators import transactional
class ForgotPasswordCompleteCommand:
def __init__(
self,
unit_of_work: IUnitOfWork,
hash_service: IHashService,
cache: ICache,
logger: ILogger,
):
self._unit_of_work = unit_of_work
self._hash_service = hash_service
self._cache = cache
self._logger = logger
@staticmethod
def _normalize_email(email: str) -> str:
return email.strip().lower()
@transactional
async def __call__(
self,
*,
email: str,
code: str,
new_password: str,
confirm_password: str,
) -> bool:
code = (code or '').strip()
normalized = self._normalize_email(email)
EMAIL_PREFIX = 'forgot_password:email:'
CODE_PREFIX = 'forgot_password:code:'
if new_password != confirm_password:
self._logger.info('Forgot password failed: passwords do not match')
raise BadRequestException(message='Passwords do not match')
code_key = f'{CODE_PREFIX}{code}'
cached_email = await self._cache.get(code_key)
if not cached_email:
self._logger.info('Forgot password failed: code not found')
raise BadRequestException(message='Invalid or expired code')
if cached_email != normalized:
self._logger.info('Forgot password failed: code-email mismatch')
raise BadRequestException(message='Invalid or expired code')
email_key = f'{EMAIL_PREFIX}{normalized}'
code_hash = await self._cache.get(email_key)
if not code_hash:
self._logger.info('Forgot password failed: email key missing')
raise BadRequestException(message='Invalid or expired code')
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
if not ok:
self._logger.info('Forgot password failed: code hash mismatch')
raise BadRequestException(message='Invalid or expired code')
user = await self._unit_of_work.user_repository.get_user_by_email(normalized)
if user is None:
self._logger.info('Forgot password failed: user not found after valid code')
raise BadRequestException(message='Invalid or expired code')
new_password_hash = await self._hash_service.hash(new_password)
user = await self._unit_of_work.user_repository.set_password(
user_id=user.id,
password_hash=new_password_hash,
)
await self._cache.set_user(user.id, user)
try:
await self._cache.delete(code_key)
await self._cache.delete(email_key)
except Exception as e:
self._logger.warning(f'Forgot password cleanup failed (user_id={user.id}): {e}')
self._logger.info(f'Password reset via forgot flow for user_id={user.id}')
return True

View File

@@ -0,0 +1,132 @@
import secrets
from datetime import datetime, timezone
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
from src.application.domain.exceptions import TooManyRequestsException, ServiceUnavailableException
from src.infrastructure.config import settings
from src.infrastructure.context_vars import trace_id_var
from src.infrastructure.database.decorators import transactional
class ForgotPasswordStartCommand:
def __init__(
self,
hash_service: IHashService,
cache: ICache,
unit_of_work: IUnitOfWork,
logger: ILogger,
messanger: IQueueMessanger,
):
self._hash_service = hash_service
self._unit_of_work = unit_of_work
self._cache = cache
self._logger = logger
self._messanger = messanger
@staticmethod
def _normalize_email(email: str) -> str:
return email.strip().lower()
@transactional
async def __call__(self, email: str) -> bool:
TTL = 300
LOCK_TTL = 30
MAX_ATTEMPTS = 20
EMAIL_PREFIX = 'forgot_password:email:'
CODE_PREFIX = 'forgot_password:code:'
LOCK_PREFIX = 'forgot_password:lock:'
normalized = self._normalize_email(email)
user = await self._unit_of_work.user_repository.get_user_by_email(normalized)
if user is None:
self._logger.info(f'Forgot password start: no user for email hash lookup')
return True
trace_id = trace_id_var.get()
if not trace_id or trace_id == 'N/A':
trace_id = None
lock_key = f'{LOCK_PREFIX}{normalized}'
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
if not locked:
self._logger.info(f'Forgot password throttled by lock (user_id={user.id})')
raise TooManyRequestsException(message='Too many requests. Please wait.')
try:
email_key = f'{EMAIL_PREFIX}{normalized}'
existing = await self._cache.get(email_key)
if existing:
self._logger.info(f'Forgot password denied: code already exists for user_id={user.id}')
raise TooManyRequestsException(message='Code already sent. Please wait before retrying.')
for _ in range(MAX_ATTEMPTS):
code = f'{secrets.randbelow(1_000_000):06d}'
code_key = f'{CODE_PREFIX}{code}'
code_hash = await self._hash_service.hash(code)
reserved = await self._cache.set_nx(code_key, normalized, ttl=TTL)
if not reserved:
continue
saved = await self._cache.set(email_key, code_hash, ttl=TTL)
if not saved:
await self._cache.delete(code_key)
self._logger.error(f'Forgot password failed: cannot save code hash for user_id={user.id}')
raise ServiceUnavailableException(message='Temporary error. Please try again.')
message_id = str(ULID())
now = datetime.now(timezone.utc).isoformat()
metadata = {
'trace_id': trace_id,
'source': 'user-service',
'timestamp': now,
'message_id': message_id,
}
payload = {
'email': normalized,
'code': code,
'ttl_seconds': TTL,
}
message = {
'event': 'forgot_password',
'payload': payload,
'metadata': metadata,
}
self._logger.info(f'Forgot password code created for user_id={user.id}')
try:
await self._messanger.publish_to_queue(
queue=settings.RABBIT_EMAIL_CODE_QUEUE,
message=message,
persist=True,
correlation_id=trace_id,
message_id=message_id,
headers={'trace_id': trace_id} if trace_id else None,
)
except Exception as exception:
try:
await self._cache.delete(email_key)
await self._cache.delete(code_key)
except Exception as rollback_err:
self._logger.error(
f'Publish failed and rollback cache failed for user_id={user.id}: {str(rollback_err)}'
)
self._logger.error(f'Failed to publish forgot password email for user_id={user.id}: {str(exception)}')
raise ServiceUnavailableException(message='Temporary error. Please try again.')
return True
self._logger.error(f'Forgot password failed: code space exhausted for user_id={user.id}')
raise ServiceUnavailableException(message='Temporary error. Please try again.')
finally:
await self._cache.delete(lock_key)

View File

@@ -25,11 +25,16 @@ class SetAvatarCommand:
@transactional @transactional
async def _load_user(self, user_id: str) -> UserEntity: async def _load_user(self, user_id: str) -> UserEntity:
return await self._unit_of_work.user_repository.get_user_by_id(user_id) user = await self._unit_of_work.user_repository.get_user_by_id(user_id)
self._logger.debug(f'Avatar _load_user user_id={user_id} has_avatar_link={bool(user.avatar_link)}')
return user
async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]: async def __call__(self, user_id: str, image_bytes: bytes) -> tuple[UserEntity, int]:
prior = await self._load_user(user_id) prior = await self._load_user(user_id)
old_link = prior.avatar_link old_link = prior.avatar_link
self._logger.info(
f'SetAvatar start user_id={user_id} input_bytes={len(image_bytes)} had_previous_link={bool(old_link)}'
)
try: try:
webp_bytes = image_bytes_to_webp(image_bytes) webp_bytes = image_bytes_to_webp(image_bytes)
except UnidentifiedImageError as exc: except UnidentifiedImageError as exc:
@@ -38,6 +43,8 @@ class SetAvatarCommand:
self._logger.exception(str(exc)) self._logger.exception(str(exc))
raise BadRequestException(message='Could not process image') from exc raise BadRequestException(message='Could not process image') from exc
self._logger.debug(f'SetAvatar webp_ready bytes={len(webp_bytes)}')
pid = user_id.replace('/', '').replace('.', '_') pid = user_id.replace('/', '').replace('.', '_')
name_id = str(ULID()) name_id = str(ULID())
ts = int(datetime.now(timezone.utc).timestamp() * 1000) ts = int(datetime.now(timezone.utc).timestamp() * 1000)
@@ -45,28 +52,49 @@ class SetAvatarCommand:
fname = f'{name_id}_{pid}_{ts}.webp' fname = f'{name_id}_{pid}_{ts}.webp'
object_key = f'{prefix}/{fname}' if prefix else fname object_key = f'{prefix}/{fname}' if prefix else fname
self._logger.info(f'SetAvatar S3 upload start user_id={user_id} key={object_key} webp_bytes={len(webp_bytes)}')
try: try:
url = await self._s3.upload_bytes(key=object_key, body=webp_bytes, content_type='image/webp') url = await self._s3.upload_bytes(key=object_key, body=webp_bytes, content_type='image/webp')
except ClientError as exc: except ClientError as exc:
self._logger.exception(str(exc)) self._logger.exception(str(exc))
raise ServiceUnavailableException(message='S3 upload failed') from exc raise ServiceUnavailableException(message='S3 upload failed') from exc
self._logger.info(f'SetAvatar S3 upload done user_id={user_id} key={object_key} public_url_len={len(url)}')
user = await self._save_avatar_link(user_id, url) user = await self._save_avatar_link(user_id, url)
self._logger.info(
f'SetAvatar DB updated user_id={user_id} key={object_key} '
f'entity_avatar_link_len={len(user.avatar_link or "")}'
)
await self._cache.set_user(user_id, user) await self._cache.set_user(user_id, user)
self._logger.debug(f'SetAvatar cache updated user_id={user_id}')
if old_link: if old_link:
old_key = self._s3.object_key_from_public_url(old_link) old_key = self._s3.object_key_from_public_url(old_link)
if old_key and old_key != object_key: if not old_key:
self._logger.warning(
f'SetAvatar could not parse old avatar URL for S3 delete user_id={user_id} '
f'old_link_len={len(old_link)}'
)
elif old_key == object_key:
self._logger.debug(f'SetAvatar skip delete same object key user_id={user_id} key={object_key}')
else:
self._logger.info(f'SetAvatar S3 delete old object user_id={user_id} old_key={old_key}')
try: try:
await self._s3.delete_object(key=old_key) await self._s3.delete_object(key=old_key)
self._logger.info(f'SetAvatar S3 old object removed user_id={user_id} old_key={old_key}')
except ClientError as exc: except ClientError as exc:
code = exc.response.get('Error', {}).get('Code', '') code = exc.response.get('Error', {}).get('Code', '')
if code not in ('NoSuchKey', '404'): if code not in ('NoSuchKey', '404'):
self._logger.warning(f'S3 delete old avatar failed user_id={user_id} code={code}: {exc}') self._logger.warning(f'S3 delete old avatar failed user_id={user_id} code={code}: {exc}')
else:
self._logger.debug(f'SetAvatar old object already gone user_id={user_id} code={code}')
self._logger.info(f'Avatar set for user_id={user_id} key={object_key}') self._logger.info(f'Avatar set for user_id={user_id} key={object_key}')
return user, len(webp_bytes) return user, len(webp_bytes)
@transactional @transactional
async def _save_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity: async def _save_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
self._logger.debug(f'SetAvatar DB transaction set_avatar_link user_id={user_id} link_len={len(avatar_link)}')
return await self._unit_of_work.user_repository.set_avatar_link(user_id, avatar_link) return await self._unit_of_work.user_repository.set_avatar_link(user_id, avatar_link)

View File

@@ -1,7 +1,7 @@
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ILogger, ICache from src.application.contracts import IHashService, ILogger, ICache
from src.application.domain.entities import UserEntity from src.application.domain.entities import UserEntity
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import BadRequestException, ConflictException
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -31,26 +31,26 @@ class SetEncryptedMnemonicCompleteCommand:
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
if user.encrypted_mnemonic is not None: if user.encrypted_mnemonic is not None:
self._logger.info(f'Encrypted mnemonic already set for user_id={user_id}') self._logger.info(f'Encrypted mnemonic already set for user_id={user_id}')
raise ApplicationException(409, 'Encrypted mnemonic already set and cannot be changed') raise ConflictException(message='Encrypted mnemonic already set and cannot be changed')
cached_user_id = await self._cache.get(code_key) cached_user_id = await self._cache.get(code_key)
if not cached_user_id: if not cached_user_id:
self._logger.info(f'Encrypted mnemonic set failed: code not found (user_id={user_id})') self._logger.info(f'Encrypted mnemonic set failed: code not found (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
if cached_user_id != user_id: if cached_user_id != user_id:
self._logger.info(f'Encrypted mnemonic set failed: code-user mismatch (user_id={user_id})') self._logger.info(f'Encrypted mnemonic set failed: code-user mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
code_hash = await self._cache.get(user_key) code_hash = await self._cache.get(user_key)
if not code_hash: if not code_hash:
self._logger.info(f'Encrypted mnemonic set failed: user key missing (user_id={user_id})') self._logger.info(f'Encrypted mnemonic set failed: user key missing (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
if not ok: if not ok:
self._logger.info(f'Encrypted mnemonic set failed: code hash mismatch (user_id={user_id})') self._logger.info(f'Encrypted mnemonic set failed: code hash mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
user = await self._unit_of_work.user_repository.set_encrypted_mnemonic( user = await self._unit_of_work.user_repository.set_encrypted_mnemonic(
user_id=user_id, user_id=user_id,

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from ulid import ULID from ulid import ULID
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ConflictException, NotFoundException, TooManyRequestsException, ServiceUnavailableException
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.infrastructure.context_vars import trace_id_var from src.infrastructure.context_vars import trace_id_var
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -38,11 +38,11 @@ class SetEncryptedMnemonicStartCommand:
if user.encrypted_mnemonic is not None: if user.encrypted_mnemonic is not None:
self._logger.info(f'Encrypted mnemonic already set for user_id={user_id}') self._logger.info(f'Encrypted mnemonic already set for user_id={user_id}')
raise ApplicationException(409, 'Encrypted mnemonic already set and cannot be changed') raise ConflictException(message='Encrypted mnemonic already set and cannot be changed')
if not user.email: if not user.email:
self._logger.warning(f'User {user_id} does not have an email address') self._logger.warning(f'User {user_id} does not have an email address')
raise ApplicationException(404, f'User {user_id} does not have an email address') raise NotFoundException(message=f'User {user_id} does not have an email address')
trace_id = trace_id_var.get() trace_id = trace_id_var.get()
if not trace_id or trace_id == 'N/A': if not trace_id or trace_id == 'N/A':
@@ -52,7 +52,7 @@ class SetEncryptedMnemonicStartCommand:
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
if not locked: if not locked:
self._logger.info(f'Encrypted mnemonic set throttled by lock (user_id={user_id})') self._logger.info(f'Encrypted mnemonic set throttled by lock (user_id={user_id})')
raise ApplicationException(429, 'Too many requests. Please wait.') raise TooManyRequestsException(message='Too many requests. Please wait.')
try: try:
user_key = f'{USER_PREFIX}{user_id}' user_key = f'{USER_PREFIX}{user_id}'
@@ -60,7 +60,7 @@ class SetEncryptedMnemonicStartCommand:
existing = await self._cache.get(user_key) existing = await self._cache.get(user_key)
if existing: if existing:
self._logger.info(f'Encrypted mnemonic set denied: code already exists for user_id={user_id}') self._logger.info(f'Encrypted mnemonic set denied: code already exists for user_id={user_id}')
raise ApplicationException(429, 'Code already sent. Please wait before retrying.') raise TooManyRequestsException(message='Code already sent. Please wait before retrying.')
for _ in range(MAX_ATTEMPTS): for _ in range(MAX_ATTEMPTS):
code = f'{secrets.randbelow(1_000_000):06d}' code = f'{secrets.randbelow(1_000_000):06d}'
@@ -76,7 +76,7 @@ class SetEncryptedMnemonicStartCommand:
if not saved: if not saved:
await self._cache.delete(code_key) await self._cache.delete(code_key)
self._logger.error(f'Encrypted mnemonic set failed: cannot save code hash for user_id={user_id}') self._logger.error(f'Encrypted mnemonic set failed: cannot save code hash for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
message_id = str(ULID()) message_id = str(ULID())
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
@@ -119,12 +119,12 @@ class SetEncryptedMnemonicStartCommand:
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
self._logger.error(f'Failed to publish encrypted mnemonic set email for user_id={user_id}: {str(exception)}') self._logger.error(f'Failed to publish encrypted mnemonic set email for user_id={user_id}: {str(exception)}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
return True return True
self._logger.error(f'Encrypted mnemonic set failed: code space exhausted for user_id={user_id}') self._logger.error(f'Encrypted mnemonic set failed: code space exhausted for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
finally: finally:
await self._cache.delete(lock_key) await self._cache.delete(lock_key)

View File

@@ -1,7 +1,7 @@
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ILogger, ICache from src.application.contracts import IHashService, ILogger, ICache
from src.application.domain.entities import UserEntity from src.application.domain.entities import UserEntity
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import BadRequestException
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -39,21 +39,21 @@ class UpdateBankDetailsCompleteCommand:
cached_user_id = await self._cache.get(code_key) cached_user_id = await self._cache.get(code_key)
if not cached_user_id: if not cached_user_id:
self._logger.info(f'Bank details update failed: code not found (user_id={user_id})') self._logger.info(f'Bank details update failed: code not found (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
if cached_user_id != user_id: if cached_user_id != user_id:
self._logger.info(f'Bank details update failed: code-user mismatch (user_id={user_id})') self._logger.info(f'Bank details update failed: code-user mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
code_hash = await self._cache.get(user_key) code_hash = await self._cache.get(user_key)
if not code_hash: if not code_hash:
self._logger.info(f'Bank details update failed: user key missing (user_id={user_id})') self._logger.info(f'Bank details update failed: user key missing (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code) ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
if not ok: if not ok:
self._logger.info(f'Bank details update failed: code hash mismatch (user_id={user_id})') self._logger.info(f'Bank details update failed: code hash mismatch (user_id={user_id})')
raise ApplicationException(400, 'Invalid or expired code') raise BadRequestException(message='Invalid or expired code')
fields = {} fields = {}
if passport_data is not None: if passport_data is not None:

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from ulid import ULID from ulid import ULID
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import NotFoundException, TooManyRequestsException, ServiceUnavailableException
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.infrastructure.context_vars import trace_id_var from src.infrastructure.context_vars import trace_id_var
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -38,7 +38,7 @@ class UpdateBankDetailsStartCommand:
if not user.email: if not user.email:
self._logger.warning(f'User {user_id} does not have an email address') self._logger.warning(f'User {user_id} does not have an email address')
raise ApplicationException(status_code=404, message=f'User {user_id} does not have an email address') raise NotFoundException(message=f'User {user_id} does not have an email address')
trace_id = trace_id_var.get() trace_id = trace_id_var.get()
if not trace_id or trace_id == 'N/A': if not trace_id or trace_id == 'N/A':
@@ -48,7 +48,7 @@ class UpdateBankDetailsStartCommand:
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL) locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
if not locked: if not locked:
self._logger.info(f'Bank details update throttled by lock (user_id={user_id})') self._logger.info(f'Bank details update throttled by lock (user_id={user_id})')
raise ApplicationException(429, 'Too many requests. Please wait.') raise TooManyRequestsException(message='Too many requests. Please wait.')
try: try:
user_key = f'{USER_PREFIX}{user_id}' user_key = f'{USER_PREFIX}{user_id}'
@@ -56,7 +56,7 @@ class UpdateBankDetailsStartCommand:
existing = await self._cache.get(user_key) existing = await self._cache.get(user_key)
if existing: if existing:
self._logger.info(f'Bank details update denied: code already exists for user_id={user_id}') self._logger.info(f'Bank details update denied: code already exists for user_id={user_id}')
raise ApplicationException(429, 'Code already sent. Please wait before retrying.') raise TooManyRequestsException(message='Code already sent. Please wait before retrying.')
for _ in range(MAX_ATTEMPTS): for _ in range(MAX_ATTEMPTS):
code = f'{secrets.randbelow(1_000_000):06d}' code = f'{secrets.randbelow(1_000_000):06d}'
@@ -72,7 +72,7 @@ class UpdateBankDetailsStartCommand:
if not saved: if not saved:
await self._cache.delete(code_key) await self._cache.delete(code_key)
self._logger.error(f'Bank details update failed: cannot save code hash for user_id={user_id}') self._logger.error(f'Bank details update failed: cannot save code hash for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
message_id = str(ULID()) message_id = str(ULID())
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
@@ -115,12 +115,12 @@ class UpdateBankDetailsStartCommand:
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}') self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
self._logger.error(f'Failed to publish bank details update email for user_id={user_id}: {str(exception)}') self._logger.error(f'Failed to publish bank details update email for user_id={user_id}: {str(exception)}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
return True return True
self._logger.error(f'Bank details update failed: code space exhausted for user_id={user_id}') self._logger.error(f'Bank details update failed: code space exhausted for user_id={user_id}')
raise ApplicationException(503, 'Temporary error. Please try again.') raise ServiceUnavailableException(message='Temporary error. Please try again.')
finally: finally:
await self._cache.delete(lock_key) await self._cache.delete(lock_key)

View File

@@ -1,11 +1,13 @@
from src.application.domain.exceptions.application_exceptions import ( from src.application.domain.exceptions.application_exceptions import ApplicationException
ApplicationException, from src.application.domain.exceptions.bad_request_exception import BadRequestException
BadRequestException, from src.application.domain.exceptions.unauthorized_exception import UnauthorizedException
ConflictException, from src.application.domain.exceptions.forbidden_exception import ForbiddenException
ForbiddenException, from src.application.domain.exceptions.not_found_exception import NotFoundException
InternalException, from src.application.domain.exceptions.conflict_exception import ConflictException
NotFoundException, from src.application.domain.exceptions.internal_exception import InternalException
ServiceUnavailableException, from src.application.domain.exceptions.service_unavailable_exception import ServiceUnavailableException
TooManyRequestsException, from src.application.domain.exceptions.too_many_requests_exception import TooManyRequestsException
UnauthorizedException, from src.application.domain.exceptions.csrf_error_exception import CsrfErrorException
) from src.application.domain.exceptions.jwt_error_exception import JwtErrorException
from src.application.domain.exceptions.data_base_error_exception import DataBaseErrorException
from src.application.domain.exceptions.rate_limit_error_exception import RateLimitErrorException

View File

@@ -17,43 +17,3 @@ class ApplicationException(Exception):
def __str__(self) -> str: def __str__(self) -> str:
return f'{self.status_code}: {self.message}' return f'{self.status_code}: {self.message}'
class BadRequestException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(400, message, headers)
class UnauthorizedException(ApplicationException):
def __init__(self, message: str = 'Unauthorized', headers: Mapping[str, str] | None = None):
super().__init__(401, message, headers)
class ForbiddenException(ApplicationException):
def __init__(self, message: str = 'Forbidden', headers: Mapping[str, str] | None = None):
super().__init__(403, message, headers)
class NotFoundException(ApplicationException):
def __init__(self, message: str = 'Not found', headers: Mapping[str, str] | None = None):
super().__init__(404, message, headers)
class ConflictException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(409, message, headers)
class TooManyRequestsException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(429, message, headers)
class ServiceUnavailableException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(503, message, headers)
class InternalException(ApplicationException):
def __init__(self, message: str = 'Internal Server Error', headers: Mapping[str, str] | None = None):
super().__init__(500, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class BadRequestException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(400, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class ConflictException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(409, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class CsrfErrorException(ApplicationException):
def __init__(self, message: str = 'CSRF context is invalid', headers: Mapping[str, str] | None = None):
super().__init__(500, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class DataBaseErrorException(ApplicationException):
def __init__(self, message: str = 'Database error occurred', headers: Mapping[str, str] | None = None):
super().__init__(500, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class ForbiddenException(ApplicationException):
def __init__(self, message: str = 'Forbidden', headers: Mapping[str, str] | None = None):
super().__init__(403, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class InternalException(ApplicationException):
def __init__(self, message: str = 'Internal Server Error', headers: Mapping[str, str] | None = None):
super().__init__(500, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class JwtErrorException(ApplicationException):
def __init__(self, message: str = 'JWT error occurred', headers: Mapping[str, str] | None = None):
super().__init__(500, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class NotFoundException(ApplicationException):
def __init__(self, message: str = 'Not found', headers: Mapping[str, str] | None = None):
super().__init__(404, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class RateLimitErrorException(ApplicationException):
def __init__(self, message: str = 'Rate limit error occurred', headers: Mapping[str, str] | None = None):
super().__init__(500, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class ServiceUnavailableException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(503, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class TooManyRequestsException(ApplicationException):
def __init__(self, message: str, headers: Mapping[str, str] | None = None):
super().__init__(429, message, headers)

View File

@@ -0,0 +1,8 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
from typing import Mapping
class UnauthorizedException(ApplicationException):
def __init__(self, message: str = 'Unauthorized', headers: Mapping[str, str] | None = None):
super().__init__(401, message, headers)

View File

@@ -0,0 +1,21 @@
import re
SPECIAL_CHARS = '!@#$%^&*()_+-=.,:;?/[]{}<>'
def validate_password_strength(password: str) -> str:
if re.search(r'\s', password):
raise ValueError('Password must not contain whitespace')
if len(password) < 12:
raise ValueError('Password must be at least 12 characters')
if not re.search(r'[a-z]', password):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'[A-Z]', password):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'\d', password):
raise ValueError('Password must contain at least one digit')
if not any(c in SPECIAL_CHARS for c in password):
raise ValueError(
'Password must contain at least one special character from: !@#$%^&*()_+-=.,:;?/[]{}<>'
)
return password

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from src.application.contracts import ILogger from src.application.contracts import ILogger
from src.application.domain.exceptions import ApplicationException, BadRequestException, InternalException, NotFoundException from src.application.domain.exceptions import ApplicationException, BadRequestException, DataBaseErrorException, NotFoundException
from src.application.abstractions.repositories import IUserRepository from src.application.abstractions.repositories import IUserRepository
from src.application.domain.entities import UserEntity from src.application.domain.entities import UserEntity
from src.infrastructure.database.models import UserModel from src.infrastructure.database.models import UserModel
@@ -60,7 +60,7 @@ class UserRepository(IUserRepository):
raise raise
except SQLAlchemyError as exception: except SQLAlchemyError as exception:
self._logger.exception(str(exception)) self._logger.exception(str(exception))
raise InternalException(message=f'Database error: {str(exception)}') raise DataBaseErrorException(message=f'Database error: {str(exception)}')
async def _update_field(self, user_id: str, **fields: object) -> UserEntity: async def _update_field(self, user_id: str, **fields: object) -> UserEntity:
try: try:
@@ -74,7 +74,7 @@ class UserRepository(IUserRepository):
raise raise
except SQLAlchemyError as exception: except SQLAlchemyError as exception:
self._logger.exception(str(exception)) self._logger.exception(str(exception))
raise InternalException(message=f'Database error: {str(exception)}') raise DataBaseErrorException(message=f'Database error: {str(exception)}')
async def set_phone(self, user_id: str, phone: str) -> UserEntity: async def set_phone(self, user_id: str, phone: str) -> UserEntity:
return await self._update_field(user_id, phone=phone) return await self._update_field(user_id, phone=phone)
@@ -100,7 +100,7 @@ class UserRepository(IUserRepository):
raise raise
except SQLAlchemyError as exception: except SQLAlchemyError as exception:
self._logger.exception(str(exception)) self._logger.exception(str(exception))
raise InternalException(message=f'Database error: {str(exception)}') raise DataBaseErrorException(message=f'Database error: {str(exception)}')
async def set_password(self, user_id: str, password_hash: str) -> UserEntity: async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
return await self._update_field(user_id, password_hash=password_hash) return await self._update_field(user_id, password_hash=password_hash)
@@ -121,4 +121,22 @@ class UserRepository(IUserRepository):
return result.scalar_one_or_none() is not None return result.scalar_one_or_none() is not None
except SQLAlchemyError as exception: except SQLAlchemyError as exception:
self._logger.exception(str(exception)) self._logger.exception(str(exception))
raise InternalException(message=f'Database error: {str(exception)}') raise DataBaseErrorException(message=f'Database error: {str(exception)}')
async def get_user_by_email(self, email: str) -> UserEntity | None:
try:
stmt = (
select(UserModel)
.where(
UserModel.email == email,
UserModel.is_deleted.is_(False),
)
)
result = await self._session.execute(stmt)
user: UserModel | None = result.scalar_one_or_none()
if user is None:
return None
return self._to_entity(user)
except SQLAlchemyError as exception:
self._logger.exception(str(exception))
raise DataBaseErrorException(message=f'Database error: {str(exception)}')

View File

@@ -15,19 +15,23 @@ class UnitOfWork(IUnitOfWork):
self._logger: ILogger = logger self._logger: ILogger = logger
async def __aenter__(self): async def __aenter__(self):
self._logger.debug('UnitOfWork enter')
self._user_repository = None
self._session_repository = None
self._session = self.session_factory() self._session = self.session_factory()
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
if exc_type: if exc_type:
self._logger.error(str(exc_val)) self._logger.error(f'UnitOfWork rollback_on_error exc_type={exc_type.__name__} exc_val={exc_val!r}')
await self._session.rollback() await self._session.rollback()
self._logger.error(f'Rollback: str{exc_val})') self._logger.debug(f'UnitOfWork session rollback done exc_type={exc_type.__name__}')
else: else:
await self._session.flush() await self._session.flush()
await self._session.commit() await self._session.commit()
self._logger.debug('Commit') self._logger.debug('UnitOfWork commit')
await self._session.close() await self._session.close()
self._logger.debug('UnitOfWork exit session closed')
@property @property
def user_repository(self) -> IUserRepository: def user_repository(self) -> IUserRepository:

View File

@@ -3,7 +3,7 @@ import secrets
from typing import Any, Optional, Mapping from typing import Any, Optional, Mapping
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
from src.application.contracts import ICsrfService from src.application.contracts import ICsrfService
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ForbiddenException
from src.infrastructure.config.settings import settings from src.infrastructure.config.settings import settings
@@ -42,21 +42,12 @@ class CsrfService(ICsrfService):
try: try:
data = self._serializer.loads(token, max_age=self.TTL_SECONDS) data = self._serializer.loads(token, max_age=self.TTL_SECONDS)
except SignatureExpired: except SignatureExpired:
raise ApplicationException( raise ForbiddenException(message='CSRF token expired')
status_code=403,
message='CSRF token expired',
)
except BadSignature: except BadSignature:
raise ApplicationException( raise ForbiddenException(message='CSRF token invalid')
status_code=403,
message='CSRF token invalid',
)
if expected_subject is not None and data.get('sub') != expected_subject: if expected_subject is not None and data.get('sub') != expected_subject:
raise ApplicationException( raise ForbiddenException(message='CSRF token subject mismatch')
status_code=403,
message='CSRF token subject mismatch',
)
return data return data
@@ -67,15 +58,9 @@ class CsrfService(ICsrfService):
def verify_pair(self, cookie_token: Optional[str], header_token: Optional[str], expected_subject: Optional[str] = None) -> None: def verify_pair(self, cookie_token: Optional[str], header_token: Optional[str], expected_subject: Optional[str] = None) -> None:
if not cookie_token or not header_token: if not cookie_token or not header_token:
raise ApplicationException( raise ForbiddenException(message='CSRF token missing')
status_code=403,
message='CSRF token missing',
)
if not secrets.compare_digest(cookie_token, header_token): if not secrets.compare_digest(cookie_token, header_token):
raise ApplicationException( raise ForbiddenException(message='CSRF token mismatch')
status_code=403,
message='CSRF token mismatch',
)
self.verify(cookie_token, expected_subject=expected_subject) self.verify(cookie_token, expected_subject=expected_subject)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from jose import jwt, ExpiredSignatureError, JWTError from jose import jwt, ExpiredSignatureError, JWTError
from src.application.contracts import ILogger, IJwtService from src.application.contracts import ILogger, IJwtService
from src.application.domain.dto import AccessTokenPayload from src.application.domain.dto import AccessTokenPayload
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException, UnauthorizedException, JwtErrorException
from src.infrastructure.config.settings import settings from src.infrastructure.config.settings import settings
from src.infrastructure.vault import JwtKeyStore from src.infrastructure.vault import JwtKeyStore
@@ -17,7 +17,7 @@ class JwtService(IJwtService):
if payload.get('type') != 'access': if payload.get('type') != 'access':
self._logger.warning(f'Access token invalid type received_type={payload.get('type')}') self._logger.warning(f'Access token invalid type received_type={payload.get('type')}')
raise ApplicationException(status_code=401, message='Invalid token type') raise UnauthorizedException(message='Invalid token type')
try: try:
return AccessTokenPayload( return AccessTokenPayload(
@@ -32,7 +32,7 @@ class JwtService(IJwtService):
) )
except KeyError as exception: except KeyError as exception:
self._logger.warning(f'Access token missing claim error={str(exception)}') self._logger.warning(f'Access token missing claim error={str(exception)}')
raise ApplicationException(status_code=401, message=f'Missing token claim: {exception}') raise UnauthorizedException(message=f'Missing token claim: {exception}')
async def _decode_and_verify(self, token: str) -> dict: async def _decode_and_verify(self, token: str) -> dict:
kid: str | None = None kid: str | None = None
@@ -42,12 +42,12 @@ class JwtService(IJwtService):
kid = header.get('kid') kid = header.get('kid')
if not kid: if not kid:
self._logger.warning(f'JWT header missing kid header={header}') self._logger.warning(f'JWT header missing kid header={header}')
raise ApplicationException(status_code=401, message='Missing token header: kid') raise UnauthorizedException(message='Missing token header: kid')
received_alg = header.get('alg') received_alg = header.get('alg')
if received_alg != settings.JWT_ALGORITHM: if received_alg != settings.JWT_ALGORITHM:
self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_alg={settings.JWT_ALGORITHM}') self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_alg={settings.JWT_ALGORITHM}')
raise ApplicationException(status_code=401, message='Invalid token algorithm') raise UnauthorizedException(message='Invalid token algorithm')
public_pem = await self._key_store.get_public_key_for_kid(str(kid)) public_pem = await self._key_store.get_public_key_for_kid(str(kid))
@@ -58,7 +58,7 @@ class JwtService(IJwtService):
if not public_pem: if not public_pem:
self._logger.warning(f'JWT unknown kid kid={kid}') self._logger.warning(f'JWT unknown kid kid={kid}')
raise ApplicationException(status_code=401, message='Unknown token kid') raise UnauthorizedException(message='Unknown token kid')
options = { options = {
'verify_signature': True, 'verify_signature': True,
@@ -85,25 +85,25 @@ class JwtService(IJwtService):
if 'sid' not in payload: if 'sid' not in payload:
self._logger.warning(f'JWT missing sid claim kid={kid}') self._logger.warning(f'JWT missing sid claim kid={kid}')
raise ApplicationException(status_code=401, message='Missing token claim: sid') raise UnauthorizedException(message='Missing token claim: sid')
if 'type' not in payload: if 'type' not in payload:
self._logger.warning(f'JWT missing type claim kid={kid}') self._logger.warning(f'JWT missing type claim kid={kid}')
raise ApplicationException(status_code=401, message='Missing token claim: type') raise UnauthorizedException(message='Missing token claim: type')
return payload return payload
except ExpiredSignatureError as exception: except ExpiredSignatureError as exception:
self._logger.info(f'JWT expired kid={kid} error={str(exception)}') self._logger.info(f'JWT expired kid={kid} error={str(exception)}')
raise ApplicationException(status_code=401, message='Token expired') raise UnauthorizedException(message='Token expired')
except ApplicationException: except ApplicationException:
raise raise
except JWTError as exception: except JWTError as exception:
self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}') self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}')
raise ApplicationException(status_code=401, message='Invalid token') raise UnauthorizedException(message='Invalid token')
except Exception as exception: except Exception as exception:
self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}') self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}')
raise ApplicationException(status_code=500, message='JWT decode failed') raise JwtErrorException(message='JWT decode failed')

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import JwtErrorException
from src.infrastructure.vault.client import VaultClient from src.infrastructure.vault.client import VaultClient
@@ -52,7 +52,7 @@ class JwtKeyStore:
@classmethod @classmethod
def get_instance(cls) -> 'JwtKeyStore': def get_instance(cls) -> 'JwtKeyStore':
if cls._instance is None: if cls._instance is None:
raise ApplicationException(status_code=500, message='JwtKeyStore not initialized') raise JwtErrorException(message='JwtKeyStore not initialized')
return cls._instance return cls._instance
def _read_keyset_sync(self) -> JwtPublicKeySet: def _read_keyset_sync(self) -> JwtPublicKeySet:

View File

@@ -3,7 +3,7 @@ import inspect
from functools import wraps from functools import wraps
from typing import Callable, Awaitable, Any, Optional, Annotated from typing import Callable, Awaitable, Any, Optional, Annotated
from fastapi import Request, Header from fastapi import Request, Header
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import CsrfErrorException
from src.infrastructure.security import CsrfService from src.infrastructure.security import CsrfService
@@ -39,10 +39,7 @@ def csrf_protect(
break break
if request is None: if request is None:
raise ApplicationException( raise CsrfErrorException(message='Request is required for CSRF protection')
status_code=500,
message='Request is required for CSRF protection',
)
csrf = CsrfService() csrf = CsrfService()

View File

@@ -6,7 +6,11 @@ from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtim
from fastapi import Request from fastapi import Request
from redis.asyncio.client import Redis from redis.asyncio.client import Redis
from src.application.contracts import ILogger from src.application.contracts import ILogger
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import (
RateLimitErrorException,
ServiceUnavailableException,
TooManyRequestsException,
)
from src.infrastructure.logger import get_logger from src.infrastructure.logger import get_logger
from src.presentation.dependencies import get_redis from src.presentation.dependencies import get_redis
@@ -124,7 +128,7 @@ def rate_limit(
ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type] ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type]
except Exception as e: except Exception as e:
logger.error(f'RateLimit key_builder failed error={str(e)}') logger.error(f'RateLimit key_builder failed error={str(e)}')
raise ApplicationException(500, 'Rate limiter key_builder failed') raise RateLimitErrorException(message='Rate limiter key_builder failed')
route = request.url.path route = request.url.path
method = request.method method = request.method
@@ -153,13 +157,12 @@ def rate_limit(
logger.warning(f'RateLimit fail-open activated key={redis_key}') logger.warning(f'RateLimit fail-open activated key={redis_key}')
return await func(*args, **kwargs) return await func(*args, **kwargs)
raise ApplicationException(503, 'Rate limiter unavailable') raise ServiceUnavailableException(message='Rate limiter unavailable')
if count > limit: if count > limit:
retry_after = max(ttl, 0) retry_after = max(ttl, 0)
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}') logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
raise ApplicationException( raise TooManyRequestsException(
status_code=429,
message='Too Many Requests', message='Too Many Requests',
headers={'Retry-After': str(retry_after)}, headers={'Retry-After': str(retry_after)},
) )

View File

@@ -9,6 +9,8 @@ from src.presentation.dependencies.commands import (
get_update_bank_details_complete_command, get_update_bank_details_complete_command,
get_change_password_start_command, get_change_password_start_command,
get_change_password_complete_command, get_change_password_complete_command,
get_forgot_password_start_command,
get_forgot_password_complete_command,
get_change_email_start_command, get_change_email_start_command,
get_change_email_confirm_old_command, get_change_email_confirm_old_command,
get_change_email_complete_command, get_change_email_complete_command,

View File

@@ -1,6 +1,6 @@
from fastapi import Depends from fastapi import Depends
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ForgotPasswordStartCommand, ForgotPasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService, IS3 from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService, IS3
from src.presentation.dependencies.cache import get_cache from src.presentation.dependencies.cache import get_cache
from src.presentation.dependencies.logger import get_logger from src.presentation.dependencies.logger import get_logger
@@ -104,6 +104,36 @@ def get_change_password_complete_command(
) )
def get_forgot_password_start_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
messanger: IQueueMessanger = Depends(get_rabbit),
hash_service: IHashService = Depends(get_hash_service),
) -> ForgotPasswordStartCommand:
return ForgotPasswordStartCommand(
logger=logger,
unit_of_work=unit_of_work,
cache=cache,
messanger=messanger,
hash_service=hash_service,
)
def get_forgot_password_complete_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
hash_service: IHashService = Depends(get_hash_service),
) -> ForgotPasswordCompleteCommand:
return ForgotPasswordCompleteCommand(
logger=logger,
unit_of_work=unit_of_work,
cache=cache,
hash_service=hash_service,
)
def get_change_email_start_command( def get_change_email_start_command(
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work), unit_of_work: IUnitOfWork = Depends(get_unit_of_work),

View File

@@ -1,15 +1,33 @@
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse from fastapi.responses import ORJSONResponse
from starlette import status from starlette import status
from src.application.commands import SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand from src.application.commands import (
SetPhoneCommand,
SetAvatarCommand,
DeleteAvatarCommand,
ChangePasswordStartCommand,
ChangePasswordCompleteCommand,
ForgotPasswordStartCommand,
ForgotPasswordCompleteCommand,
)
from src.application.domain.dto import AuthContext from src.application.domain.dto import AuthContext
from src.presentation.decorators import require_access_token, csrf_protect from src.presentation.decorators import require_access_token, csrf_protect, rate_limit, email_rl_key
from src.presentation.dependencies import ( from src.presentation.dependencies import (
get_delete_avatar_command, get_delete_avatar_command,
get_set_avatar_command, get_set_avatar_command,
get_set_phone_command, get_set_phone_command,
get_change_password_start_command,
get_change_password_complete_command,
get_forgot_password_start_command,
get_forgot_password_complete_command,
)
from src.presentation.schemas import (
SetAvatarRequest,
SetPhoneRequest,
ChangePasswordConfirmRequest,
ForgotPasswordStartRequest,
ForgotPasswordCompleteRequest,
) )
from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse
from src.presentation.serializers import me_user_public from src.presentation.serializers import me_user_public
@@ -46,6 +64,62 @@ _SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
} }
_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = {
status.HTTP_400_BAD_REQUEST: {
'description': 'Неверный или просроченный код, пароли не совпадают или совпадают с текущим.',
'model': ApiErrorPayload,
},
status.HTTP_401_UNAUTHORIZED: {
'description': 'Не передан или неверен access token.',
'model': ApiErrorPayload,
},
status.HTTP_404_NOT_FOUND: {
'description': 'Учётная запись не найдена или у пользователя нет email.',
'model': ApiErrorPayload,
},
status.HTTP_422_UNPROCESSABLE_ENTITY: {
'description': 'Тело запроса не соответствует схеме (код, длина пароля).',
'model': ApiValidationErrorsPayload,
},
status.HTTP_429_TOO_MANY_REQUESTS: {
'description': 'Код уже отправлен или слишком частые запросы.',
'model': ApiErrorPayload,
},
status.HTTP_500_INTERNAL_SERVER_ERROR: {
'description': 'Внутренняя ошибка сервера.',
'model': ApiErrorPayload,
},
status.HTTP_503_SERVICE_UNAVAILABLE: {
'description': 'Временная ошибка отправки кода или сохранения в кеш.',
'model': ApiErrorPayload,
},
}
_FORGOT_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = {
status.HTTP_400_BAD_REQUEST: {
'description': 'Неверный или просроченный код, пароли не совпадают.',
'model': ApiErrorPayload,
},
status.HTTP_422_UNPROCESSABLE_ENTITY: {
'description': 'Тело запроса не соответствует схеме.',
'model': ApiValidationErrorsPayload,
},
status.HTTP_429_TOO_MANY_REQUESTS: {
'description': 'Код уже отправлен или слишком частые запросы.',
'model': ApiErrorPayload,
},
status.HTTP_500_INTERNAL_SERVER_ERROR: {
'description': 'Внутренняя ошибка сервера.',
'model': ApiErrorPayload,
},
status.HTTP_503_SERVICE_UNAVAILABLE: {
'description': 'Временная ошибка отправки кода или сохранения в кеш.',
'model': ApiErrorPayload,
},
}
_DELETE_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = { _DELETE_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = {
status.HTTP_401_UNAUTHORIZED: { status.HTTP_401_UNAUTHORIZED: {
'description': 'Не передан или неверен access token.', 'description': 'Не передан или неверен access token.',
@@ -125,6 +199,99 @@ async def delete_avatar(
user = await command(user_id=auth.user_id) user = await command(user_id=auth.user_id)
return me_user_public(user) return me_user_public(user)
@account_settings_router.post(
path='/password/start',
response_class=ORJSONResponse,
status_code=status.HTTP_200_OK,
summary='Запросить код для смены пароля',
description='Отправляет шестизначный код на email текущего пользователя. Повторный запрос возможен после истечения TTL.',
responses=_PASSWORD_ERROR_RESPONSES,
)
@csrf_protect()
async def change_password_start(
request: Request,
auth: AuthContext = Depends(require_access_token),
command: ChangePasswordStartCommand = Depends(get_change_password_start_command),
):
result = await command(user_id=auth.user_id)
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
@account_settings_router.post(
path='/password/complete',
response_class=ORJSONResponse,
status_code=status.HTTP_200_OK,
summary='Подтвердить смену пароля',
description=(
'Принимает код из письма, новый пароль и его подтверждение. '
'Новый пароль должен отличаться от текущего и соответствовать политике сложности (минимум 12 символов).'
),
responses=_PASSWORD_ERROR_RESPONSES,
)
@csrf_protect()
async def change_password_complete(
request: Request,
body: ChangePasswordConfirmRequest,
auth: AuthContext = Depends(require_access_token),
command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command),
):
result = await command(
user_id=auth.user_id,
code=body.code,
new_password=body.new_password,
confirm_password=body.confirm_password,
)
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
@account_settings_router.post(
path='/password/forgot/start',
response_class=ORJSONResponse,
status_code=status.HTTP_200_OK,
summary='Запросить код для восстановления пароля',
description=(
'Принимает email. Если учётная запись существует, отправляет шестизначный код. '
'Ответ всегда успешный при валидном email (без раскрытия наличия аккаунта).'
),
responses=_FORGOT_PASSWORD_ERROR_RESPONSES,
)
@rate_limit(limit=5, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key)
async def forgot_password_start(
request: Request,
body: ForgotPasswordStartRequest,
command: ForgotPasswordStartCommand = Depends(get_forgot_password_start_command),
):
result = await command(email=body.email)
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
@account_settings_router.post(
path='/password/forgot/complete',
response_class=ORJSONResponse,
status_code=status.HTTP_200_OK,
summary='Установить новый пароль по коду из письма',
description=(
'Принимает email, код из письма, новый пароль и подтверждение. '
'Пароль: минимум 12 символов, строчная и заглавная буква, цифра, спецсимвол, без пробелов.'
),
responses=_FORGOT_PASSWORD_ERROR_RESPONSES,
)
@rate_limit(limit=10, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key)
async def forgot_password_complete(
request: Request,
body: ForgotPasswordCompleteRequest,
command: ForgotPasswordCompleteCommand = Depends(get_forgot_password_complete_command),
):
result = await command(
email=body.email,
code=body.code,
new_password=body.new_password,
confirm_password=body.confirm_password,
)
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
# #
# @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) # @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def encrypted_mnemonic_start( # async def encrypted_mnemonic_start(
@@ -183,32 +350,6 @@ async def delete_avatar(
# return {'success': result} # return {'success': result}
# #
# #
# @account_settings_router.post(path='/password/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def change_password_start(
# request: Request,
# auth: AuthContext = Depends(require_access_token),
# command: ChangePasswordStartCommand = Depends(get_change_password_start_command),
# ):
# result = await command(user_id=auth.user_id)
# return {'success': result}
#
#
# @account_settings_router.post(path='/password/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def change_password_complete(
# request: Request,
# body: ChangePasswordConfirmRequest,
# auth: AuthContext = Depends(require_access_token),
# command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command),
# ):
# result = await command(
# user_id=auth.user_id,
# code=body.code,
# new_password=body.new_password,
# confirm_password=body.confirm_password,
# )
# return {'success': result}
#
#
# @account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) # @account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
# async def bank_details_start( # async def bank_details_start(
# request: Request, # request: Request,

View File

@@ -2,5 +2,9 @@ from src.presentation.schemas.avatar import SetAvatarRequest
from src.presentation.schemas.phone import SetPhoneRequest from src.presentation.schemas.phone import SetPhoneRequest
from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest
from src.presentation.schemas.encrypted_mnemonic import EncryptedMnemonicConfirmRequest from src.presentation.schemas.encrypted_mnemonic import EncryptedMnemonicConfirmRequest
from src.presentation.schemas.password import ChangePasswordConfirmRequest from src.presentation.schemas.password import (
ChangePasswordConfirmRequest,
ForgotPasswordStartRequest,
ForgotPasswordCompleteRequest,
)
from src.presentation.schemas.email import ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest from src.presentation.schemas.email import ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest

View File

@@ -1,6 +1,53 @@
import re import re
from typing import Self from typing import Self
from pydantic import BaseModel, field_validator, model_validator from pydantic import BaseModel, field_validator, model_validator
from src.application.domain.password_policy import validate_password_strength
class ForgotPasswordStartRequest(BaseModel):
email: str
@field_validator('email')
@classmethod
def validate_email(cls, v: str) -> str:
v = v.strip().lower()
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
raise ValueError('Invalid email address')
return v
class ForgotPasswordCompleteRequest(BaseModel):
email: str
code: str
new_password: str
confirm_password: str
@field_validator('email')
@classmethod
def validate_email(cls, v: str) -> str:
v = v.strip().lower()
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
raise ValueError('Invalid email address')
return v
@model_validator(mode='after')
def passwords_match(self) -> Self:
if self.new_password != self.confirm_password:
raise ValueError('Passwords do not match')
return self
@field_validator('code')
@classmethod
def validate_code(cls, v: str) -> str:
v = v.strip()
if not re.match(r'^\d{6}$', v):
raise ValueError('Code must be exactly 6 digits')
return v
@field_validator('new_password')
@classmethod
def validate_new_password(cls, v: str) -> str:
return validate_password_strength(v)
class ChangePasswordConfirmRequest(BaseModel): class ChangePasswordConfirmRequest(BaseModel):
@@ -25,6 +72,4 @@ class ChangePasswordConfirmRequest(BaseModel):
@field_validator('new_password') @field_validator('new_password')
@classmethod @classmethod
def validate_new_password(cls, v: str) -> str: def validate_new_password(cls, v: str) -> str:
if len(v) < 8: return validate_password_strength(v)
raise ValueError('Password must be at least 8 characters')
return v