Compare commits
13 Commits
aa7d33b099
...
feature/1
| Author | SHA1 | Date | |
|---|---|---|---|
| d794d3f9c6 | |||
| d2e8eb9e4d | |||
| b6ffdd9553 | |||
| 41d0fe8aa7 | |||
| 9c2190737a | |||
| bd1faffbb0 | |||
| f426495d47 | |||
| 4d3683dc01 | |||
| 61e5a380e9 | |||
| d3b5e0c107 | |||
| 6f6d10567e | |||
| e4369df0d8 | |||
| 21b3bd3aad |
@@ -39,5 +39,9 @@ class IUserRepository(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
|
||||
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
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from src.application.commands.get_me import GetMeCommand
|
||||
from src.application.commands.set_phone import SetPhoneCommand
|
||||
from src.application.commands.set_avatar import SetAvatarCommand
|
||||
from src.application.commands.delete_avatar import DeleteAvatarCommand
|
||||
from src.application.commands.set_encrypted_mnemonic_start import SetEncryptedMnemonicStartCommand
|
||||
from src.application.commands.set_encrypted_mnemonic_complete import SetEncryptedMnemonicCompleteCommand
|
||||
from src.application.commands.update_bank_details_start import UpdateBankDetailsStartCommand
|
||||
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
|
||||
|
||||
56
src/application/commands/delete_avatar.py
Normal file
56
src/application/commands/delete_avatar.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ICache, ILogger, IS3
|
||||
from src.application.domain.entities import UserEntity
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class DeleteAvatarCommand:
|
||||
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache, s3: IS3):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._cache = cache
|
||||
self._s3 = s3
|
||||
|
||||
@transactional
|
||||
async def _load_user(self, user_id: str) -> UserEntity:
|
||||
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'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 ApplicationException
|
||||
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 ApplicationException(400, '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 ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
if cached_email != normalized:
|
||||
self._logger.info('Forgot password failed: code-email mismatch')
|
||||
raise ApplicationException(400, '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 ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
|
||||
if not ok:
|
||||
self._logger.info('Forgot password failed: code hash mismatch')
|
||||
raise ApplicationException(400, '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 ApplicationException(400, '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 ApplicationException
|
||||
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 ApplicationException(429, '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 ApplicationException(429, '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 ApplicationException(503, '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 ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
return True
|
||||
|
||||
self._logger.error(f'Forgot password failed: code space exhausted for user_id={user.id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
finally:
|
||||
await self._cache.delete(lock_key)
|
||||
@@ -6,6 +6,7 @@ from PIL import UnidentifiedImageError
|
||||
from ulid import ULID
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ICache, ILogger, IS3
|
||||
from src.application.domain.entities import UserEntity
|
||||
@@ -22,7 +23,18 @@ class SetAvatarCommand:
|
||||
self._cache = cache
|
||||
self._s3 = s3
|
||||
|
||||
@transactional
|
||||
async def _load_user(self, user_id: str) -> UserEntity:
|
||||
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:
|
||||
@@ -31,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)
|
||||
@@ -38,17 +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 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)
|
||||
|
||||
@@ -7,3 +7,11 @@ from typing import Protocol, runtime_checkable
|
||||
class IS3(Protocol):
|
||||
async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str:
|
||||
...
|
||||
|
||||
|
||||
async def delete_object(self, *, key: str) -> None:
|
||||
...
|
||||
|
||||
|
||||
def object_key_from_public_url(self, url: str) -> str | None:
|
||||
...
|
||||
|
||||
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
|
||||
@@ -96,7 +96,7 @@ class Settings(BaseSettings):
|
||||
S3_SECRET_ACCESS_KEY: str = ''
|
||||
S3_ENDPOINT_URL: str = ''
|
||||
S3_PUBLIC_BASE_URL: str = ''
|
||||
S3_REGRU_PUBLIC_WEBSITE_HOST: bool = True
|
||||
S3_REGRU_PUBLIC_WEBSITE_HOST: bool = False
|
||||
S3_AVATAR_KEY_PREFIX: str = 'avatars'
|
||||
|
||||
LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO'
|
||||
@@ -116,7 +116,7 @@ class Settings(BaseSettings):
|
||||
object.__setattr__(self, 'S3_ENDPOINT_URL', '')
|
||||
object.__setattr__(self, 'S3_PUBLIC_BASE_URL', '')
|
||||
object.__setattr__(self, 'S3_REGION', 'us-east-1')
|
||||
object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', True)
|
||||
object.__setattr__(self, 'S3_REGRU_PUBLIC_WEBSITE_HOST', False)
|
||||
object.__setattr__(self, 'S3_AVATAR_KEY_PREFIX', 'avatars')
|
||||
|
||||
@staticmethod
|
||||
@@ -247,12 +247,8 @@ class Settings(BaseSettings):
|
||||
|
||||
s3_rel_path = self.VAULT_S3_SECRET_PATH.strip()
|
||||
if s3_rel_path:
|
||||
try:
|
||||
s3_secret_data = client.read_secret(s3_rel_path)
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
f'Vault S3 secret not readable at mount {self.VAULT_MOUNT_POINT}/{s3_rel_path}: {exc!r}'
|
||||
) from exc
|
||||
s3_secret_data = client.read_secret_optional(s3_rel_path)
|
||||
if s3_secret_data:
|
||||
self._apply_s3_from_vault_secret(s3_secret_data)
|
||||
|
||||
if not self.DATABASE_URL:
|
||||
|
||||
@@ -89,7 +89,7 @@ class UserRepository(IUserRepository):
|
||||
async def set_encrypted_mnemonic(self, user_id: str, encrypted_mnemonic: str) -> UserEntity:
|
||||
return await self._update_field(user_id, encrypted_mnemonic=encrypted_mnemonic)
|
||||
|
||||
async def set_avatar_link(self, user_id: str, avatar_link: str) -> UserEntity:
|
||||
async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity:
|
||||
return await self._update_field(user_id, avatar_link=avatar_link)
|
||||
|
||||
async def get_password_hash(self, user_id: str) -> str:
|
||||
@@ -122,3 +122,21 @@ class UserRepository(IUserRepository):
|
||||
except SQLAlchemyError as exception:
|
||||
self._logger.exception(str(exception))
|
||||
raise InternalException(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 InternalException(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:
|
||||
|
||||
@@ -24,6 +24,52 @@ class S3Service:
|
||||
self._endpoint_url = endpoint_url.strip().rstrip('/') if endpoint_url and endpoint_url.strip() else None
|
||||
self._use_reg_ru_website_public_host = use_reg_ru_website_public_host
|
||||
|
||||
@staticmethod
|
||||
def _url_prefix_variants(prefix: str) -> list[str]:
|
||||
p = prefix.rstrip('/') + '/'
|
||||
out = [p]
|
||||
if p.startswith('https://'):
|
||||
out.append('http://' + p[8:])
|
||||
elif p.startswith('http://'):
|
||||
out.append('https://' + p[7:])
|
||||
return out
|
||||
|
||||
def _public_url_prefixes(self) -> list[str]:
|
||||
acc: list[str] = []
|
||||
pb = self._public_base_url
|
||||
if pb:
|
||||
acc.extend(self._url_prefix_variants(pb))
|
||||
ep = self._endpoint_url
|
||||
if ep:
|
||||
base = f'{ep.rstrip("/")}/{self._bucket}'
|
||||
acc.extend(self._url_prefix_variants(base))
|
||||
if ep and self._use_reg_ru_website_public_host and 's3.regru.cloud' in ep.lower():
|
||||
wh = f'https://{self._bucket}.website.regru.cloud'
|
||||
acc.extend(self._url_prefix_variants(wh))
|
||||
if not ep:
|
||||
if self._region == 'us-east-1':
|
||||
h = f'https://{self._bucket}.s3.amazonaws.com'
|
||||
else:
|
||||
h = f'https://{self._bucket}.s3.{self._region}.amazonaws.com'
|
||||
acc.extend(self._url_prefix_variants(h))
|
||||
seen: set[str] = set()
|
||||
uniq: list[str] = []
|
||||
for x in sorted(acc, key=len, reverse=True):
|
||||
if x not in seen:
|
||||
seen.add(x)
|
||||
uniq.append(x)
|
||||
return uniq
|
||||
|
||||
def object_key_from_public_url(self, url: str) -> str | None:
|
||||
u = (url or '').strip()
|
||||
if not u:
|
||||
return None
|
||||
for p in self._public_url_prefixes():
|
||||
if u.startswith(p):
|
||||
k = u[len(p):].split('?', 1)[0].split('#', 1)[0]
|
||||
return k if k else None
|
||||
return None
|
||||
|
||||
def _object_url(self, key: str) -> str:
|
||||
if self._public_base_url:
|
||||
return f'{self._public_base_url}/{key}'
|
||||
@@ -62,3 +108,18 @@ class S3Service:
|
||||
ContentType=content_type,
|
||||
)
|
||||
return self._object_url(key)
|
||||
|
||||
async def delete_object(self, *, key: str) -> None:
|
||||
session = get_session()
|
||||
kw: dict[str, object] = {'region_name': self._region}
|
||||
aid = self._access_key_id
|
||||
sk = self._secret_access_key
|
||||
ep = self._endpoint_url
|
||||
if aid:
|
||||
kw['aws_access_key_id'] = aid
|
||||
if sk:
|
||||
kw['aws_secret_access_key'] = sk
|
||||
if ep:
|
||||
kw['endpoint_url'] = ep
|
||||
async with session.create_client('s3', **kw) as client:
|
||||
await client.delete_object(Bucket=self._bucket, Key=key)
|
||||
|
||||
@@ -2,12 +2,15 @@ from src.presentation.dependencies.commands import (
|
||||
get_get_me_command,
|
||||
get_set_phone_command,
|
||||
get_set_avatar_command,
|
||||
get_delete_avatar_command,
|
||||
get_set_encrypted_mnemonic_start_command,
|
||||
get_set_encrypted_mnemonic_complete_command,
|
||||
get_update_bank_details_start_command,
|
||||
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, 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
|
||||
@@ -35,6 +35,15 @@ def get_set_avatar_command(
|
||||
return SetAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3)
|
||||
|
||||
|
||||
def get_delete_avatar_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
s3: IS3 = Depends(get_s3_storage),
|
||||
) -> DeleteAvatarCommand:
|
||||
return DeleteAvatarCommand(unit_of_work=unit_of_work, logger=logger, cache=cache, s3=s3)
|
||||
|
||||
|
||||
def get_set_encrypted_mnemonic_start_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
@@ -95,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),
|
||||
|
||||
@@ -10,7 +10,6 @@ from src.presentation.dependencies.logger import get_logger
|
||||
from src.presentation.decorators import csrf_protect
|
||||
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse
|
||||
from src.presentation.serializers import me_user_public
|
||||
|
||||
account_router = APIRouter()
|
||||
|
||||
@@ -37,6 +36,10 @@ account_router = APIRouter()
|
||||
'description': 'Ошибка проверки CSRF (нет пары cookie/заголовка, несовпадение или просрочен токен).',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
'description': 'Учётная запись не найдена.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||||
'description': 'Ошибка валидации входных данных (например, заголовков).',
|
||||
'model': ApiValidationErrorsPayload,
|
||||
@@ -52,4 +55,22 @@ async def me(
|
||||
) -> MeUserPublicResponse:
|
||||
user = await command(user_id=auth.user_id)
|
||||
logger.info(f'Get user: {user.id}')
|
||||
return me_user_public(user)
|
||||
return MeUserPublicResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
first_name=user.first_name,
|
||||
middle_name=user.middle_name,
|
||||
last_name=user.last_name,
|
||||
birth_date=user.birth_date,
|
||||
encrypted_mnemonic=user.encrypted_mnemonic,
|
||||
phone=user.phone,
|
||||
passport_data=user.passport_data,
|
||||
inn=user.inn,
|
||||
erc20=user.erc20,
|
||||
avatar_link=user.avatar_link,
|
||||
kyc_verified=user.kyc_verified,
|
||||
is_deleted=user.is_deleted,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
kyc_verified_at=user.kyc_verified_at
|
||||
)
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
from src.application.commands import SetPhoneCommand, SetAvatarCommand
|
||||
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
|
||||
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 SetAvatarPublicResponse
|
||||
from src.presentation.serializers import me_user_public
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse
|
||||
|
||||
|
||||
account_settings_router = APIRouter(prefix='/settings')
|
||||
@@ -45,7 +63,84 @@ _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.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
'description': 'Учётная запись не найдена.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||||
'description': 'Внутренняя ошибка сервера; клиенту отдаётся обобщённое сообщение.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||||
'description': 'S3 не сконфигурирован или временная недоступность удаления объекта.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@account_settings_router.patch(path='/phone', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||
@csrf_protect()
|
||||
async def set_phone(
|
||||
request: Request,
|
||||
body: SetPhoneRequest,
|
||||
@@ -64,12 +159,14 @@ async def set_phone(
|
||||
summary='Обновить аватар',
|
||||
description=(
|
||||
'Принимает фото в Base64, сохраняет как WebP в объектном хранилище и записывает публичный URL в профиль. '
|
||||
'После успешной записи удаляется предыдущий объект в S3 (если ссылку удаётся сопоставить с ключом).'
|
||||
),
|
||||
response_description=(
|
||||
'Профиль пользователя в том же формате, что и GET /me, плюс размер сохранённого WebP.'
|
||||
),
|
||||
responses=_SET_AVATAR_ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def set_avatar(
|
||||
request: Request,
|
||||
body: SetAvatarRequest,
|
||||
@@ -77,9 +174,159 @@ async def set_avatar(
|
||||
command: SetAvatarCommand = Depends(get_set_avatar_command),
|
||||
) -> SetAvatarPublicResponse:
|
||||
user, webp_size = await command(user_id=auth.user_id, image_bytes=body.decoded_bytes)
|
||||
pub = me_user_public(user)
|
||||
pub = MeUserPublicResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
first_name=user.first_name,
|
||||
middle_name=user.middle_name,
|
||||
last_name=user.last_name,
|
||||
birth_date=user.birth_date,
|
||||
encrypted_mnemonic=user.encrypted_mnemonic,
|
||||
phone=user.phone,
|
||||
passport_data=user.passport_data,
|
||||
inn=user.inn,
|
||||
erc20=user.erc20,
|
||||
avatar_link=user.avatar_link,
|
||||
kyc_verified=user.kyc_verified,
|
||||
is_deleted=user.is_deleted,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
kyc_verified_at=user.kyc_verified_at
|
||||
)
|
||||
return SetAvatarPublicResponse(**pub.model_dump(), webp_size_bytes=webp_size)
|
||||
|
||||
|
||||
@account_settings_router.delete(
|
||||
path='/avatar',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=MeUserPublicResponse,
|
||||
summary='Удалить аватар',
|
||||
description=(
|
||||
'Удаляет файл в объектном хранилище при известном URL и обнуляет avatar_link в профиле.'
|
||||
),
|
||||
responses=_DELETE_AVATAR_ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def delete_avatar(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: DeleteAvatarCommand = Depends(get_delete_avatar_command),
|
||||
) -> MeUserPublicResponse:
|
||||
user = await command(user_id=auth.user_id)
|
||||
return MeUserPublicResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
first_name=user.first_name,
|
||||
middle_name=user.middle_name,
|
||||
last_name=user.last_name,
|
||||
birth_date=user.birth_date,
|
||||
encrypted_mnemonic=user.encrypted_mnemonic,
|
||||
phone=user.phone,
|
||||
passport_data=user.passport_data,
|
||||
inn=user.inn,
|
||||
erc20=user.erc20,
|
||||
avatar_link=user.avatar_link,
|
||||
kyc_verified=user.kyc_verified,
|
||||
is_deleted=user.is_deleted,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
kyc_verified_at=user.kyc_verified_at
|
||||
)
|
||||
|
||||
|
||||
@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(
|
||||
@@ -138,32 +385,6 @@ async def set_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)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from src.presentation.serializers.me_user import me_user_payload, me_user_public
|
||||
@@ -1,13 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
from src.presentation.schemas.me_public import MeUserPublicResponse
|
||||
|
||||
|
||||
def me_user_public(user: UserEntity) -> MeUserPublicResponse:
|
||||
return MeUserPublicResponse.from_user(user)
|
||||
|
||||
|
||||
def me_user_payload(user: UserEntity) -> dict:
|
||||
return me_user_public(user).model_dump(mode='json')
|
||||
Reference in New Issue
Block a user