Compare commits
4 Commits
f426495d47
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f6ffe68e6a | |||
| 41d0fe8aa7 | |||
| 9c2190737a | |||
| bd1faffbb0 |
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
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
|
||||
@@ -17,3 +17,6 @@ class IUnitOfWork(Protocol):
|
||||
@property
|
||||
def session_repository(self) -> ISessionRepository: ...
|
||||
|
||||
@property
|
||||
def legal_entity_repository(self) -> ILegalEntityRepository: ...
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
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_legal_entity_repository import ILegalEntityRepository
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
@@ -1,6 +1,7 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ILogger, ICache
|
||||
from src.application.domain.entities import UserEntity
|
||||
from src.application.domain.enums.account_type import AccountType
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
@@ -13,5 +14,7 @@ class GetMeCommand:
|
||||
@transactional
|
||||
async def __call__(self, user_id: str) -> UserEntity:
|
||||
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}')
|
||||
return user
|
||||
24
src/application/domain/entities/legal_entity.py
Normal file
24
src/application/domain/entities/legal_entity.py
Normal 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
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
from src.application.domain.entities.legal_entity import LegalEntityEntity
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserEntity:
|
||||
@@ -28,3 +30,6 @@ class UserEntity:
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
kyc_verified_at: datetime | None = None
|
||||
|
||||
account_type: str = 'individual'
|
||||
legal_entity: LegalEntityEntity | None = None
|
||||
|
||||
6
src/application/domain/enums/account_type.py
Normal file
6
src/application/domain/enums/account_type.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AccountType(StrEnum):
|
||||
INDIVIDUAL = 'individual'
|
||||
LEGAL_ENTITY = 'legal_entity'
|
||||
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
|
||||
@@ -1,6 +1,7 @@
|
||||
from src.infrastructure.database.models.base import Base
|
||||
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
|
||||
|
||||
__all__ = ['Base', 'UserModel', 'Session']
|
||||
__all__ = ['Base', 'UserModel', 'LegalEntityModel', 'Session']
|
||||
|
||||
|
||||
32
src/infrastructure/database/models/legal_entity.py
Normal file
32
src/infrastructure/database/models/legal_entity.py
Normal 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)
|
||||
@@ -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_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)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from src.infrastructure.database.repositories.user_repository import UserRepository
|
||||
from src.infrastructure.database.repositories.session_repository import SessionRepository
|
||||
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository
|
||||
@@ -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
|
||||
@@ -50,6 +50,7 @@ class UserRepository(IUserRepository):
|
||||
is_deleted=user.is_deleted,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
account_type=user.account_type,
|
||||
)
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> UserEntity:
|
||||
@@ -122,3 +123,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)}')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
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.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._user_repository: IUserRepository = None
|
||||
self._session_repository: ISessionRepository = None
|
||||
self._legal_entity_repository: ILegalEntityRepository = None
|
||||
self._logger: ILogger = logger
|
||||
|
||||
async def __aenter__(self):
|
||||
self._logger.debug('UnitOfWork enter')
|
||||
self._user_repository = None
|
||||
self._session_repository = None
|
||||
self._legal_entity_repository = None
|
||||
self._session = self.session_factory()
|
||||
return self
|
||||
|
||||
@@ -44,3 +46,9 @@ class UnitOfWork(IUnitOfWork):
|
||||
if self._session_repository is None:
|
||||
self._session_repository = SessionRepository(session=self._session, logger=self._logger)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,45 @@ from datetime import date, datetime
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
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):
|
||||
@@ -27,9 +66,16 @@ class MeUserPublicResponse(BaseModel):
|
||||
created_at: datetime | None = Field(None, description='Время создания записи')
|
||||
updated_at: datetime | None = Field(None, description='Время последнего обновления')
|
||||
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
|
||||
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(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
@@ -48,6 +94,8 @@ class MeUserPublicResponse(BaseModel):
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
kyc_verified_at=user.kyc_verified_at,
|
||||
account_type=user.account_type,
|
||||
legal_entity=legal_entity,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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