feat: add reset password
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -191,7 +191,7 @@ async def change_password_start(
|
||||
summary='Подтвердить смену пароля',
|
||||
description=(
|
||||
'Принимает код из письма, новый пароль и его подтверждение. '
|
||||
'Новый пароль должен отличаться от текущего (минимум 8 символов).'
|
||||
'Новый пароль должен отличаться от текущего и соответствовать политике сложности (минимум 12 символов).'
|
||||
),
|
||||
responses=_PASSWORD_ERROR_RESPONSES,
|
||||
)
|
||||
|
||||
81
src/presentation/routing/password.py
Normal file
81
src/presentation/routing/password.py
Normal 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})
|
||||
@@ -2,5 +2,9 @@ from src.presentation.schemas.avatar import SetAvatarRequest
|
||||
from src.presentation.schemas.phone import SetPhoneRequest
|
||||
from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest
|
||||
from src.presentation.schemas.encrypted_mnemonic import EncryptedMnemonicConfirmRequest
|
||||
from src.presentation.schemas.password import ChangePasswordConfirmRequest
|
||||
from src.presentation.schemas.password import (
|
||||
ChangePasswordConfirmRequest,
|
||||
ForgotPasswordStartRequest,
|
||||
ForgotPasswordCompleteRequest,
|
||||
)
|
||||
from src.presentation.schemas.email import ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest
|
||||
@@ -1,6 +1,53 @@
|
||||
import re
|
||||
from typing import Self
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
from src.application.domain.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class ForgotPasswordStartRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
|
||||
class ForgotPasswordCompleteRequest(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def passwords_match(self) -> Self:
|
||||
if self.new_password != self.confirm_password:
|
||||
raise ValueError('Passwords do not match')
|
||||
return self
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
class ChangePasswordConfirmRequest(BaseModel):
|
||||
@@ -25,6 +72,4 @@ class ChangePasswordConfirmRequest(BaseModel):
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError('Password must be at least 8 characters')
|
||||
return v
|
||||
return validate_password_strength(v)
|
||||
|
||||
Reference in New Issue
Block a user