Compare commits
10 Commits
61e5a380e9
...
feature/3
| Author | SHA1 | Date | |
|---|---|---|---|
| 35b968d288 | |||
| a1b41e8317 | |||
| 48e917eece | |||
| b9e980db94 | |||
| fb7856260f | |||
| 41d0fe8aa7 | |||
| 9c2190737a | |||
| bd1faffbb0 | |||
| f426495d47 | |||
| 4d3683dc01 |
@@ -38,6 +38,10 @@ class IUserRepository(ABC):
|
||||
async def email_exists(self, email: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_by_email(self, email: str) -> UserEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -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.change_password_start import ChangePasswordStartCommand
|
||||
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_confirm_old import ChangeEmailConfirmOldCommand
|
||||
from src.application.commands.change_email_complete import ChangeEmailCompleteCommand
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
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
|
||||
|
||||
|
||||
@@ -30,16 +30,16 @@ class ChangeEmailCompleteCommand:
|
||||
cached_user_id = await self._cache.get(new_code_key)
|
||||
if not cached_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:
|
||||
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)
|
||||
if not raw_value:
|
||||
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(':')
|
||||
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)
|
||||
if not ok:
|
||||
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)
|
||||
await self._cache.set_user(user_id, user)
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 ApplicationException
|
||||
from src.application.domain.exceptions import BadRequestException, ConflictException, ServiceUnavailableException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
@@ -41,32 +41,32 @@ class ChangeEmailConfirmOldCommand:
|
||||
cached_user_id = await self._cache.get(old_code_key)
|
||||
if not cached_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:
|
||||
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)
|
||||
if not code_hash:
|
||||
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)
|
||||
if not ok:
|
||||
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)
|
||||
|
||||
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})')
|
||||
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)
|
||||
if email_taken:
|
||||
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:
|
||||
await self._cache.delete(old_code_key)
|
||||
@@ -94,7 +94,7 @@ class ChangeEmailConfirmOldCommand:
|
||||
if not saved:
|
||||
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}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
raise ServiceUnavailableException(message='Temporary error. Please try again.')
|
||||
|
||||
message_id = str(ULID())
|
||||
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'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
|
||||
|
||||
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.')
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 ApplicationException
|
||||
from src.application.domain.exceptions import NotFoundException, TooManyRequestsException, ServiceUnavailableException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
@@ -38,7 +38,7 @@ class ChangeEmailStartCommand:
|
||||
|
||||
if not user.email:
|
||||
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()
|
||||
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)
|
||||
if not locked:
|
||||
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:
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
@@ -56,7 +56,7 @@ class ChangeEmailStartCommand:
|
||||
existing = await self._cache.get(user_key)
|
||||
if existing:
|
||||
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):
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
@@ -72,7 +72,7 @@ class ChangeEmailStartCommand:
|
||||
if not saved:
|
||||
await self._cache.delete(code_key)
|
||||
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())
|
||||
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'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
|
||||
|
||||
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:
|
||||
await self._cache.delete(lock_key)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
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
|
||||
|
||||
|
||||
@@ -36,33 +36,33 @@ class ChangePasswordCompleteCommand:
|
||||
|
||||
if new_password != confirm_password:
|
||||
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)
|
||||
if not cached_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:
|
||||
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)
|
||||
if not code_hash:
|
||||
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)
|
||||
if not ok:
|
||||
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)
|
||||
|
||||
is_same = await self._hash_service.verify(hashed_value=current_password_hash, plain_value=new_password)
|
||||
if is_same:
|
||||
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)
|
||||
user = await self._unit_of_work.user_repository.set_password(
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 ApplicationException
|
||||
from src.application.domain.exceptions import NotFoundException, TooManyRequestsException, ServiceUnavailableException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
@@ -38,7 +38,7 @@ class ChangePasswordStartCommand:
|
||||
|
||||
if not user.email:
|
||||
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()
|
||||
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)
|
||||
if not locked:
|
||||
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:
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
@@ -56,7 +56,7 @@ class ChangePasswordStartCommand:
|
||||
existing = await self._cache.get(user_key)
|
||||
if existing:
|
||||
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):
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
@@ -72,7 +72,7 @@ class ChangePasswordStartCommand:
|
||||
if not saved:
|
||||
await self._cache.delete(code_key)
|
||||
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())
|
||||
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'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
|
||||
|
||||
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:
|
||||
await self._cache.delete(lock_key)
|
||||
|
||||
@@ -17,25 +17,40 @@ class DeleteAvatarCommand:
|
||||
|
||||
@transactional
|
||||
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:
|
||||
prior = await self._load_user(user_id)
|
||||
link = prior.avatar_link
|
||||
self._logger.info(f'DeleteAvatar start user_id={user_id} had_link={bool(link)}')
|
||||
if 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:
|
||||
self._logger.info(f'DeleteAvatar S3 delete start user_id={user_id} key={key}')
|
||||
try:
|
||||
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:
|
||||
code = exc.response.get('Error', {}).get('Code', '')
|
||||
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)
|
||||
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)
|
||||
self._logger.debug(f'DeleteAvatar cache updated user_id={user_id}')
|
||||
self._logger.info(f'Avatar removed user_id={user_id}')
|
||||
return user
|
||||
|
||||
@transactional
|
||||
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)
|
||||
|
||||
83
src/application/commands/forgot_password_complete.py
Normal file
83
src/application/commands/forgot_password_complete.py
Normal 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
|
||||
132
src/application/commands/forgot_password_start.py
Normal file
132
src/application/commands/forgot_password_start.py
Normal 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)
|
||||
@@ -25,11 +25,16 @@ class SetAvatarCommand:
|
||||
|
||||
@transactional
|
||||
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]:
|
||||
prior = await self._load_user(user_id)
|
||||
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:
|
||||
webp_bytes = image_bytes_to_webp(image_bytes)
|
||||
except UnidentifiedImageError as exc:
|
||||
@@ -38,6 +43,8 @@ class SetAvatarCommand:
|
||||
self._logger.exception(str(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('.', '_')
|
||||
name_id = str(ULID())
|
||||
ts = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||
@@ -45,28 +52,49 @@ class SetAvatarCommand:
|
||||
fname = f'{name_id}_{pid}_{ts}.webp'
|
||||
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:
|
||||
url = await self._s3.upload_bytes(key=object_key, body=webp_bytes, content_type='image/webp')
|
||||
except ClientError as exc:
|
||||
self._logger.exception(str(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)
|
||||
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)
|
||||
self._logger.debug(f'SetAvatar cache updated user_id={user_id}')
|
||||
|
||||
if 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:
|
||||
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:
|
||||
code = exc.response.get('Error', {}).get('Code', '')
|
||||
if code not in ('NoSuchKey', '404'):
|
||||
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}')
|
||||
return user, len(webp_bytes)
|
||||
|
||||
@transactional
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ILogger, ICache
|
||||
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
|
||||
|
||||
|
||||
@@ -31,26 +31,26 @@ class SetEncryptedMnemonicCompleteCommand:
|
||||
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
|
||||
if user.encrypted_mnemonic is not None:
|
||||
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)
|
||||
if not cached_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:
|
||||
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)
|
||||
if not code_hash:
|
||||
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)
|
||||
if not ok:
|
||||
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_id=user_id,
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 ApplicationException
|
||||
from src.application.domain.exceptions import ConflictException, NotFoundException, TooManyRequestsException, ServiceUnavailableException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
@@ -38,11 +38,11 @@ class SetEncryptedMnemonicStartCommand:
|
||||
|
||||
if user.encrypted_mnemonic is not None:
|
||||
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:
|
||||
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()
|
||||
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)
|
||||
if not locked:
|
||||
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:
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
@@ -60,7 +60,7 @@ class SetEncryptedMnemonicStartCommand:
|
||||
existing = await self._cache.get(user_key)
|
||||
if existing:
|
||||
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):
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
@@ -76,7 +76,7 @@ class SetEncryptedMnemonicStartCommand:
|
||||
if not saved:
|
||||
await self._cache.delete(code_key)
|
||||
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())
|
||||
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'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
|
||||
|
||||
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:
|
||||
await self._cache.delete(lock_key)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ILogger, ICache
|
||||
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
|
||||
|
||||
|
||||
@@ -39,21 +39,21 @@ class UpdateBankDetailsCompleteCommand:
|
||||
cached_user_id = await self._cache.get(code_key)
|
||||
if not cached_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:
|
||||
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)
|
||||
if not code_hash:
|
||||
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)
|
||||
if not ok:
|
||||
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 = {}
|
||||
if passport_data is not None:
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 ApplicationException
|
||||
from src.application.domain.exceptions import NotFoundException, TooManyRequestsException, ServiceUnavailableException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
@@ -38,7 +38,7 @@ class UpdateBankDetailsStartCommand:
|
||||
|
||||
if not user.email:
|
||||
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()
|
||||
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)
|
||||
if not locked:
|
||||
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:
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
@@ -56,7 +56,7 @@ class UpdateBankDetailsStartCommand:
|
||||
existing = await self._cache.get(user_key)
|
||||
if existing:
|
||||
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):
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
@@ -72,7 +72,7 @@ class UpdateBankDetailsStartCommand:
|
||||
if not saved:
|
||||
await self._cache.delete(code_key)
|
||||
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())
|
||||
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'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
|
||||
|
||||
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:
|
||||
await self._cache.delete(lock_key)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from src.application.domain.exceptions.application_exceptions import (
|
||||
ApplicationException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
InternalException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
TooManyRequestsException,
|
||||
UnauthorizedException,
|
||||
)
|
||||
from src.application.domain.exceptions.application_exceptions import ApplicationException
|
||||
from src.application.domain.exceptions.bad_request_exception import BadRequestException
|
||||
from src.application.domain.exceptions.unauthorized_exception import UnauthorizedException
|
||||
from src.application.domain.exceptions.forbidden_exception import ForbiddenException
|
||||
from src.application.domain.exceptions.not_found_exception import NotFoundException
|
||||
from src.application.domain.exceptions.conflict_exception import ConflictException
|
||||
from src.application.domain.exceptions.internal_exception import InternalException
|
||||
from src.application.domain.exceptions.service_unavailable_exception import ServiceUnavailableException
|
||||
from src.application.domain.exceptions.too_many_requests_exception import TooManyRequestsException
|
||||
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
|
||||
@@ -17,43 +17,3 @@ class ApplicationException(Exception):
|
||||
|
||||
def __str__(self) -> str:
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
8
src/application/domain/exceptions/conflict_exception.py
Normal file
8
src/application/domain/exceptions/conflict_exception.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
8
src/application/domain/exceptions/forbidden_exception.py
Normal file
8
src/application/domain/exceptions/forbidden_exception.py
Normal 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)
|
||||
8
src/application/domain/exceptions/internal_exception.py
Normal file
8
src/application/domain/exceptions/internal_exception.py
Normal 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)
|
||||
8
src/application/domain/exceptions/jwt_error_exception.py
Normal file
8
src/application/domain/exceptions/jwt_error_exception.py
Normal 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)
|
||||
8
src/application/domain/exceptions/not_found_exception.py
Normal file
8
src/application/domain/exceptions/not_found_exception.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
21
src/application/domain/password_policy.py
Normal file
21
src/application/domain/password_policy.py
Normal 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
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
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.domain.entities import UserEntity
|
||||
from src.infrastructure.database.models import UserModel
|
||||
@@ -60,7 +60,7 @@ class UserRepository(IUserRepository):
|
||||
raise
|
||||
except SQLAlchemyError as 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:
|
||||
try:
|
||||
@@ -74,7 +74,7 @@ class UserRepository(IUserRepository):
|
||||
raise
|
||||
except SQLAlchemyError as 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:
|
||||
return await self._update_field(user_id, phone=phone)
|
||||
@@ -100,7 +100,7 @@ class UserRepository(IUserRepository):
|
||||
raise
|
||||
except SQLAlchemyError as 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:
|
||||
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
|
||||
except SQLAlchemyError as 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)}')
|
||||
|
||||
@@ -15,19 +15,23 @@ class UnitOfWork(IUnitOfWork):
|
||||
self._logger: ILogger = logger
|
||||
|
||||
async def __aenter__(self):
|
||||
self._logger.debug('UnitOfWork enter')
|
||||
self._user_repository = None
|
||||
self._session_repository = None
|
||||
self._session = self.session_factory()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
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()
|
||||
self._logger.error(f'Rollback: str{exc_val})')
|
||||
self._logger.debug(f'UnitOfWork session rollback done exc_type={exc_type.__name__}')
|
||||
else:
|
||||
await self._session.flush()
|
||||
await self._session.commit()
|
||||
self._logger.debug('Commit')
|
||||
self._logger.debug('UnitOfWork commit')
|
||||
await self._session.close()
|
||||
self._logger.debug('UnitOfWork exit session closed')
|
||||
|
||||
@property
|
||||
def user_repository(self) -> IUserRepository:
|
||||
|
||||
@@ -3,7 +3,7 @@ import secrets
|
||||
from typing import Any, Optional, Mapping
|
||||
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
|
||||
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
|
||||
|
||||
|
||||
@@ -42,21 +42,12 @@ class CsrfService(ICsrfService):
|
||||
try:
|
||||
data = self._serializer.loads(token, max_age=self.TTL_SECONDS)
|
||||
except SignatureExpired:
|
||||
raise ApplicationException(
|
||||
status_code=403,
|
||||
message='CSRF token expired',
|
||||
)
|
||||
raise ForbiddenException(message='CSRF token expired')
|
||||
except BadSignature:
|
||||
raise ApplicationException(
|
||||
status_code=403,
|
||||
message='CSRF token invalid',
|
||||
)
|
||||
raise ForbiddenException(message='CSRF token invalid')
|
||||
|
||||
if expected_subject is not None and data.get('sub') != expected_subject:
|
||||
raise ApplicationException(
|
||||
status_code=403,
|
||||
message='CSRF token subject mismatch',
|
||||
)
|
||||
raise ForbiddenException(message='CSRF token subject mismatch')
|
||||
|
||||
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:
|
||||
if not cookie_token or not header_token:
|
||||
raise ApplicationException(
|
||||
status_code=403,
|
||||
message='CSRF token missing',
|
||||
)
|
||||
raise ForbiddenException(message='CSRF token missing')
|
||||
|
||||
if not secrets.compare_digest(cookie_token, header_token):
|
||||
raise ApplicationException(
|
||||
status_code=403,
|
||||
message='CSRF token mismatch',
|
||||
)
|
||||
raise ForbiddenException(message='CSRF token mismatch')
|
||||
|
||||
self.verify(cookie_token, expected_subject=expected_subject)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
from jose import jwt, ExpiredSignatureError, JWTError
|
||||
from src.application.contracts import ILogger, IJwtService
|
||||
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.vault import JwtKeyStore
|
||||
|
||||
@@ -17,7 +17,7 @@ class JwtService(IJwtService):
|
||||
|
||||
if payload.get('type') != 'access':
|
||||
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:
|
||||
return AccessTokenPayload(
|
||||
@@ -32,7 +32,7 @@ class JwtService(IJwtService):
|
||||
)
|
||||
except KeyError as 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:
|
||||
kid: str | None = None
|
||||
@@ -42,12 +42,12 @@ class JwtService(IJwtService):
|
||||
kid = header.get('kid')
|
||||
if not kid:
|
||||
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')
|
||||
if received_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))
|
||||
|
||||
@@ -58,7 +58,7 @@ class JwtService(IJwtService):
|
||||
|
||||
if not public_pem:
|
||||
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 = {
|
||||
'verify_signature': True,
|
||||
@@ -85,25 +85,25 @@ class JwtService(IJwtService):
|
||||
|
||||
if 'sid' not in payload:
|
||||
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:
|
||||
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
|
||||
|
||||
except ExpiredSignatureError as 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:
|
||||
raise
|
||||
|
||||
except JWTError as 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:
|
||||
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')
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ class JwtKeyStore:
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'JwtKeyStore':
|
||||
if cls._instance is None:
|
||||
raise ApplicationException(status_code=500, message='JwtKeyStore not initialized')
|
||||
raise JwtErrorException(message='JwtKeyStore not initialized')
|
||||
return cls._instance
|
||||
|
||||
def _read_keyset_sync(self) -> JwtPublicKeySet:
|
||||
|
||||
@@ -3,7 +3,7 @@ import inspect
|
||||
from functools import wraps
|
||||
from typing import Callable, Awaitable, Any, Optional, Annotated
|
||||
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
|
||||
|
||||
|
||||
@@ -39,10 +39,7 @@ def csrf_protect(
|
||||
break
|
||||
|
||||
if request is None:
|
||||
raise ApplicationException(
|
||||
status_code=500,
|
||||
message='Request is required for CSRF protection',
|
||||
)
|
||||
raise CsrfErrorException(message='Request is required for CSRF protection')
|
||||
|
||||
csrf = CsrfService()
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtim
|
||||
from fastapi import Request
|
||||
from redis.asyncio.client import Redis
|
||||
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.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]
|
||||
except Exception as 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
|
||||
method = request.method
|
||||
@@ -153,13 +157,12 @@ def rate_limit(
|
||||
logger.warning(f'RateLimit fail-open activated key={redis_key}')
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
raise ApplicationException(503, 'Rate limiter unavailable')
|
||||
raise ServiceUnavailableException(message='Rate limiter unavailable')
|
||||
|
||||
if count > limit:
|
||||
retry_after = max(ttl, 0)
|
||||
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
|
||||
raise ApplicationException(
|
||||
status_code=429,
|
||||
raise TooManyRequestsException(
|
||||
message='Too Many Requests',
|
||||
headers={'Retry-After': str(retry_after)},
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ from src.presentation.dependencies.commands import (
|
||||
get_update_bank_details_complete_command,
|
||||
get_change_password_start_command,
|
||||
get_change_password_complete_command,
|
||||
get_forgot_password_start_command,
|
||||
get_forgot_password_complete_command,
|
||||
get_change_email_start_command,
|
||||
get_change_email_confirm_old_command,
|
||||
get_change_email_complete_command,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import Depends
|
||||
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.presentation.dependencies.cache import get_cache
|
||||
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(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
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.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 (
|
||||
get_delete_avatar_command,
|
||||
get_set_avatar_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.me_public import MeUserPublicResponse, SetAvatarPublicResponse
|
||||
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]] = {
|
||||
status.HTTP_401_UNAUTHORIZED: {
|
||||
'description': 'Не передан или неверен access token.',
|
||||
@@ -125,6 +199,99 @@ async def delete_avatar(
|
||||
user = await command(user_id=auth.user_id)
|
||||
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)
|
||||
# async def encrypted_mnemonic_start(
|
||||
@@ -183,32 +350,6 @@ async def delete_avatar(
|
||||
# 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)
|
||||
# async def bank_details_start(
|
||||
# request: Request,
|
||||
|
||||
@@ -2,5 +2,9 @@ from src.presentation.schemas.avatar import SetAvatarRequest
|
||||
from src.presentation.schemas.phone import SetPhoneRequest
|
||||
from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest
|
||||
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
|
||||
@@ -1,6 +1,53 @@
|
||||
import re
|
||||
from typing import Self
|
||||
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):
|
||||
@@ -25,6 +72,4 @@ class ChangePasswordConfirmRequest(BaseModel):
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError('Password must be at least 8 characters')
|
||||
return v
|
||||
return validate_password_strength(v)
|
||||
|
||||
Reference in New Issue
Block a user