feat: add reset password

This commit is contained in:
2026-05-19 15:23:22 +03:00
parent bd1faffbb0
commit 9c2190737a
13 changed files with 430 additions and 6 deletions

View File

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

View File

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

View File

@@ -191,7 +191,7 @@ async def change_password_start(
summary='Подтвердить смену пароля',
description=(
'Принимает код из письма, новый пароль и его подтверждение. '
'Новый пароль должен отличаться от текущего (минимум 8 символов).'
'Новый пароль должен отличаться от текущего и соответствовать политике сложности (минимум 12 символов).'
),
responses=_PASSWORD_ERROR_RESPONSES,
)

View File

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

View File

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

View File

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