From 9c2190737a42f25170077abb1c5cf1965b05f2e2 Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Tue, 19 May 2026 15:23:22 +0300 Subject: [PATCH] feat: add reset password --- .../repositories/i_user_repository.py | 4 + src/application/commands/__init__.py | 2 + .../commands/forgot_password_complete.py | 83 +++++++++++ .../commands/forgot_password_start.py | 132 ++++++++++++++++++ src/application/domain/password_policy.py | 21 +++ .../database/repositories/user_repository.py | 18 +++ src/main.py | 2 + src/presentation/dependencies/__init__.py | 2 + src/presentation/dependencies/commands.py | 32 ++++- src/presentation/routing/account_settings.py | 2 +- src/presentation/routing/password.py | 81 +++++++++++ src/presentation/schemas/__init__.py | 6 +- src/presentation/schemas/password.py | 51 ++++++- 13 files changed, 430 insertions(+), 6 deletions(-) create mode 100644 src/application/commands/forgot_password_complete.py create mode 100644 src/application/commands/forgot_password_start.py create mode 100644 src/application/domain/password_policy.py create mode 100644 src/presentation/routing/password.py diff --git a/src/application/abstractions/repositories/i_user_repository.py b/src/application/abstractions/repositories/i_user_repository.py index 43eb3d4..f4599e2 100644 --- a/src/application/abstractions/repositories/i_user_repository.py +++ b/src/application/abstractions/repositories/i_user_repository.py @@ -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 diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 0b859e1..02f85ef 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -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 diff --git a/src/application/commands/forgot_password_complete.py b/src/application/commands/forgot_password_complete.py new file mode 100644 index 0000000..e06f49f --- /dev/null +++ b/src/application/commands/forgot_password_complete.py @@ -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 diff --git a/src/application/commands/forgot_password_start.py b/src/application/commands/forgot_password_start.py new file mode 100644 index 0000000..da7f86d --- /dev/null +++ b/src/application/commands/forgot_password_start.py @@ -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) diff --git a/src/application/domain/password_policy.py b/src/application/domain/password_policy.py new file mode 100644 index 0000000..169072f --- /dev/null +++ b/src/application/domain/password_policy.py @@ -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 diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py index cbcbdb0..b28543b 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -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)}') diff --git a/src/main.py b/src/main.py index 6cf14ed..4f2ce6d 100644 --- a/src/main.py +++ b/src/main.py @@ -23,6 +23,7 @@ from src.presentation.handlers import ( ) from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware from src.presentation.routing import me_router +from src.presentation.routing.password import password_router security = HTTPBasic() @@ -90,6 +91,7 @@ app.add_exception_handler(ApplicationException, application_exception_handler) app.add_exception_handler(Exception, unhandled_exception_handler) app.include_router(me_router) +app.include_router(password_router) # app.include_router(me_devices_router) # app.include_router(me_deals_router) diff --git a/src/presentation/dependencies/__init__.py b/src/presentation/dependencies/__init__.py index f8015be..2553803 100644 --- a/src/presentation/dependencies/__init__.py +++ b/src/presentation/dependencies/__init__.py @@ -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, diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index f37c215..dac2aa0 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -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), diff --git a/src/presentation/routing/account_settings.py b/src/presentation/routing/account_settings.py index a1be2fa..36518d6 100644 --- a/src/presentation/routing/account_settings.py +++ b/src/presentation/routing/account_settings.py @@ -191,7 +191,7 @@ async def change_password_start( summary='Подтвердить смену пароля', description=( 'Принимает код из письма, новый пароль и его подтверждение. ' - 'Новый пароль должен отличаться от текущего (минимум 8 символов).' + 'Новый пароль должен отличаться от текущего и соответствовать политике сложности (минимум 12 символов).' ), responses=_PASSWORD_ERROR_RESPONSES, ) diff --git a/src/presentation/routing/password.py b/src/presentation/routing/password.py new file mode 100644 index 0000000..a40cb4d --- /dev/null +++ b/src/presentation/routing/password.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import ORJSONResponse +from starlette import status +from src.application.commands import ForgotPasswordStartCommand, ForgotPasswordCompleteCommand +from src.presentation.decorators import rate_limit, email_rl_key +from src.presentation.dependencies import ( + get_forgot_password_start_command, + get_forgot_password_complete_command, +) +from src.presentation.schemas import ForgotPasswordStartRequest, ForgotPasswordCompleteRequest +from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload + + +password_router = APIRouter(prefix='/password', tags=['Password']) + + +_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_503_SERVICE_UNAVAILABLE: { + 'description': 'Временная ошибка отправки кода или сохранения в кеш.', + 'model': ApiErrorPayload, + }, +} + + +@password_router.post( + path='/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}) + + +@password_router.post( + path='/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}) diff --git a/src/presentation/schemas/__init__.py b/src/presentation/schemas/__init__.py index dd89bf1..3428adf 100644 --- a/src/presentation/schemas/__init__.py +++ b/src/presentation/schemas/__init__.py @@ -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 \ No newline at end of file diff --git a/src/presentation/schemas/password.py b/src/presentation/schemas/password.py index 5e431dc..6da729c 100644 --- a/src/presentation/schemas/password.py +++ b/src/presentation/schemas/password.py @@ -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)