4 Commits

Author SHA1 Message Date
f6ffe68e6a feat: update for b2b 2026-06-02 23:46:57 +03:00
41d0fe8aa7 feat: change router 2026-05-19 15:26:50 +03:00
9c2190737a feat: add reset password 2026-05-19 15:23:22 +03:00
bd1faffbb0 feat: add change password event 2026-05-19 08:58:12 +03:00
25 changed files with 717 additions and 40 deletions

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
from src.application.abstractions.repositories import IUserRepository, ISessionRepository from src.application.abstractions.repositories import IUserRepository, ISessionRepository, ILegalEntityRepository
@runtime_checkable @runtime_checkable
@@ -17,3 +17,6 @@ class IUnitOfWork(Protocol):
@property @property
def session_repository(self) -> ISessionRepository: ... def session_repository(self) -> ISessionRepository: ...
@property
def legal_entity_repository(self) -> ILegalEntityRepository: ...

View File

@@ -1,2 +1,3 @@
from src.application.abstractions.repositories.i_user_repository import IUserRepository from src.application.abstractions.repositories.i_user_repository import IUserRepository
from src.application.abstractions.repositories.i_session_repository import ISessionRepository from src.application.abstractions.repositories.i_session_repository import ISessionRepository
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository

View File

@@ -0,0 +1,9 @@
from abc import ABC, abstractmethod
from src.application.domain.entities.legal_entity import LegalEntityEntity
class ILegalEntityRepository(ABC):
@abstractmethod
async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None:
raise NotImplementedError

View File

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

View File

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

View File

@@ -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

View File

@@ -0,0 +1,132 @@
import secrets
from datetime import datetime, timezone
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
from src.application.domain.exceptions import 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)

View File

@@ -1,6 +1,7 @@
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger, ICache from src.application.contracts import ILogger, ICache
from src.application.domain.entities import UserEntity from src.application.domain.entities import UserEntity
from src.application.domain.enums.account_type import AccountType
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -13,5 +14,7 @@ class GetMeCommand:
@transactional @transactional
async def __call__(self, user_id: str) -> UserEntity: async def __call__(self, user_id: str) -> UserEntity:
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id) user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
if user.account_type == AccountType.LEGAL_ENTITY.value:
user.legal_entity = await self._unit_of_work.legal_entity_repository.get_by_user_id(user_id)
self._logger.info(f'User ID: {user.id}') self._logger.info(f'User ID: {user.id}')
return user return user

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
@dataclass(slots=True)
class LegalEntityEntity:
id: str
user_id: str
name: str
inn: str
status: str
short_name: str | None = None
ogrn: str | None = None
kpp: str | None = None
legal_address: str | None = None
actual_address: str | None = None
bank_details: dict[str, Any] | None = None
contact_person: str | None = None
contact_phone: str | None = None
kyc_verified: bool = True
kyc_verified_at: datetime | None = None

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime
from src.application.domain.entities.legal_entity import LegalEntityEntity
@dataclass(slots=True) @dataclass(slots=True)
class UserEntity: class UserEntity:
@@ -28,3 +30,6 @@ class UserEntity:
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
kyc_verified_at: datetime | None = None kyc_verified_at: datetime | None = None
account_type: str = 'individual'
legal_entity: LegalEntityEntity | None = None

View File

@@ -0,0 +1,6 @@
from enum import StrEnum
class AccountType(StrEnum):
INDIVIDUAL = 'individual'
LEGAL_ENTITY = 'legal_entity'

View File

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

View File

@@ -1,6 +1,7 @@
from src.infrastructure.database.models.base import Base from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.user import UserModel from src.infrastructure.database.models.user import UserModel
from src.infrastructure.database.models.legal_entity import LegalEntityModel
from src.infrastructure.database.models.sessions import Session from src.infrastructure.database.models.sessions import Session
__all__ = ['Base', 'UserModel', 'Session'] __all__ = ['Base', 'UserModel', 'LegalEntityModel', 'Session']

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class LegalEntityModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'legal_entities'
user_id: Mapped[str] = mapped_column(String(26), ForeignKey('users.id', ondelete='RESTRICT'), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(512), nullable=False)
short_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
inn: Mapped[str] = mapped_column(String(12), nullable=False, index=True)
ogrn: Mapped[str | None] = mapped_column(String(15), nullable=True)
kpp: Mapped[str | None] = mapped_column(String(9), nullable=True)
legal_address: Mapped[str | None] = mapped_column(Text, nullable=True)
actual_address: Mapped[str | None] = mapped_column(Text, nullable=True)
bank_details: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
contact_person: Mapped[str | None] = mapped_column(String(256), nullable=True)
contact_phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='active', default='active')
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True)
kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(26), nullable=True)

View File

@@ -27,3 +27,7 @@ class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False) kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False)
kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True) kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)
account_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default='individual', default='individual')
provisioned_by: Mapped[str | None] = mapped_column(String(26), nullable=True)
provisioned_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -1,2 +1,3 @@
from src.infrastructure.database.repositories.user_repository import UserRepository from src.infrastructure.database.repositories.user_repository import UserRepository
from src.infrastructure.database.repositories.session_repository import SessionRepository from src.infrastructure.database.repositories.session_repository import SessionRepository
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository
from src.application.contracts import ILogger
from src.application.domain.entities.legal_entity import LegalEntityEntity
from src.application.domain.exceptions import InternalException
from src.infrastructure.database.models.legal_entity import LegalEntityModel
class LegalEntityRepository(ILegalEntityRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
@staticmethod
def _to_entity(model: LegalEntityModel) -> LegalEntityEntity:
return LegalEntityEntity(
id=model.id,
user_id=model.user_id,
name=model.name,
inn=model.inn,
status=model.status,
short_name=model.short_name,
ogrn=model.ogrn,
kpp=model.kpp,
legal_address=model.legal_address,
actual_address=model.actual_address,
bank_details=model.bank_details,
contact_person=model.contact_person,
contact_phone=model.contact_phone,
kyc_verified=model.kyc_verified,
kyc_verified_at=model.kyc_verified_at,
)
async def get_by_user_id(self, user_id: str) -> LegalEntityEntity | None:
try:
stmt = select(LegalEntityModel).where(LegalEntityModel.user_id == user_id)
result = await self._session.execute(stmt)
model = result.scalar_one_or_none()
if model is None:
return None
return self._to_entity(model)
except SQLAlchemyError as exc:
self._logger.exception(str(exc))
raise InternalException(message=f'Database error: {exc}') from exc

View File

@@ -50,6 +50,7 @@ class UserRepository(IUserRepository):
is_deleted=user.is_deleted, is_deleted=user.is_deleted,
created_at=user.created_at, created_at=user.created_at,
updated_at=user.updated_at, updated_at=user.updated_at,
account_type=user.account_type,
) )
async def get_user_by_id(self, user_id: str) -> UserEntity: async def get_user_by_id(self, user_id: str) -> UserEntity:
@@ -122,3 +123,21 @@ class UserRepository(IUserRepository):
except SQLAlchemyError as exception: except SQLAlchemyError as exception:
self._logger.exception(str(exception)) self._logger.exception(str(exception))
raise InternalException(message=f'Database error: {str(exception)}') raise 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)}')

View File

@@ -1,8 +1,8 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.abstractions.repositories import IUserRepository, ISessionRepository from src.application.abstractions.repositories import IUserRepository, ISessionRepository, ILegalEntityRepository
from src.application.contracts import ILogger from src.application.contracts import ILogger
from src.infrastructure.database.repositories import UserRepository, SessionRepository from src.infrastructure.database.repositories import UserRepository, SessionRepository, LegalEntityRepository
@@ -12,12 +12,14 @@ class UnitOfWork(IUnitOfWork):
self._session: AsyncSession = None self._session: AsyncSession = None
self._user_repository: IUserRepository = None self._user_repository: IUserRepository = None
self._session_repository: ISessionRepository = None self._session_repository: ISessionRepository = None
self._legal_entity_repository: ILegalEntityRepository = None
self._logger: ILogger = logger self._logger: ILogger = logger
async def __aenter__(self): async def __aenter__(self):
self._logger.debug('UnitOfWork enter') self._logger.debug('UnitOfWork enter')
self._user_repository = None self._user_repository = None
self._session_repository = None self._session_repository = None
self._legal_entity_repository = None
self._session = self.session_factory() self._session = self.session_factory()
return self return self
@@ -44,3 +46,9 @@ class UnitOfWork(IUnitOfWork):
if self._session_repository is None: if self._session_repository is None:
self._session_repository = SessionRepository(session=self._session, logger=self._logger) self._session_repository = SessionRepository(session=self._session, logger=self._logger)
return self._session_repository return self._session_repository
@property
def legal_entity_repository(self) -> ILegalEntityRepository:
if self._legal_entity_repository is None:
self._legal_entity_repository = LegalEntityRepository(session=self._session, logger=self._logger)
return self._legal_entity_repository

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,45 @@ from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from src.application.domain.entities import UserEntity from src.application.domain.entities import UserEntity
from src.application.domain.entities.legal_entity import LegalEntityEntity
class LegalEntityPublicResponse(BaseModel):
model_config = ConfigDict(from_attributes=False)
id: str
name: str
inn: str
status: str
short_name: str | None = None
ogrn: str | None = None
kpp: str | None = None
legal_address: str | None = None
actual_address: str | None = None
bank_details: dict | None = None
contact_person: str | None = None
contact_phone: str | None = None
kyc_verified: bool | None = None
kyc_verified_at: datetime | None = None
@classmethod
def from_entity(cls, entity: LegalEntityEntity) -> LegalEntityPublicResponse:
return cls(
id=entity.id,
name=entity.name,
inn=entity.inn,
status=entity.status,
short_name=entity.short_name,
ogrn=entity.ogrn,
kpp=entity.kpp,
legal_address=entity.legal_address,
actual_address=entity.actual_address,
bank_details=entity.bank_details,
contact_person=entity.contact_person,
contact_phone=entity.contact_phone,
kyc_verified=entity.kyc_verified,
kyc_verified_at=entity.kyc_verified_at,
)
class MeUserPublicResponse(BaseModel): class MeUserPublicResponse(BaseModel):
@@ -27,9 +66,16 @@ class MeUserPublicResponse(BaseModel):
created_at: datetime | None = Field(None, description='Время создания записи') created_at: datetime | None = Field(None, description='Время создания записи')
updated_at: datetime | None = Field(None, description='Время последнего обновления') updated_at: datetime | None = Field(None, description='Время последнего обновления')
kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC') kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC')
account_type: str | None = Field(None, description='individual | legal_entity')
legal_entity: LegalEntityPublicResponse | None = Field(None, description='Профиль юрлица')
@classmethod @classmethod
def from_user(cls, user: UserEntity) -> MeUserPublicResponse: def from_user(cls, user: UserEntity) -> MeUserPublicResponse:
legal_entity = (
LegalEntityPublicResponse.from_entity(user.legal_entity)
if user.legal_entity is not None
else None
)
return cls( return cls(
id=user.id, id=user.id,
email=user.email, email=user.email,
@@ -48,6 +94,8 @@ class MeUserPublicResponse(BaseModel):
created_at=user.created_at, created_at=user.created_at,
updated_at=user.updated_at, updated_at=user.updated_at,
kyc_verified_at=user.kyc_verified_at, kyc_verified_at=user.kyc_verified_at,
account_type=user.account_type,
legal_entity=legal_entity,
) )

View File

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