feat: add change password

This commit is contained in:
2026-06-08 11:55:51 +03:00
parent 6d7df5836a
commit e208792738
9 changed files with 120 additions and 1 deletions

View File

@@ -25,3 +25,11 @@ class IUserRepository(ABC):
@abstractmethod
async def exists_by_email(self, email: str) -> bool:
raise NotImplementedError
@abstractmethod
async def get_password_hash(self, user_id: str) -> str:
raise NotImplementedError
@abstractmethod
async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
raise NotImplementedError

View File

@@ -5,6 +5,7 @@ from src.application.commands.get_admin_me import GetAdminMeCommand
from src.application.commands.create_organization import CreateOrganizationCommand
from src.application.commands.create_organization_wallets import CreateOrganizationWalletsCommand
from src.application.commands.upload_organization_document import UploadOrganizationDocumentCommand
from src.application.commands.set_password import SetPasswordCommand
from src.application.commands.organization_commands import (
ListOrganizationsCommand,
GetOrganizationCommand,
@@ -38,4 +39,5 @@ __all__ = [
'SetPurchaseRequestQuoteCommand',
'ListOrganizationDocumentsCommand',
'GetOrganizationDocumentCommand',
'SetPasswordCommand',
]

View File

@@ -0,0 +1,31 @@
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IHashService, ILogger
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.decorators import transactional
class SetPasswordCommand:
def __init__(
self,
unit_of_work: IUnitOfWork,
hash_service: IHashService,
logger: ILogger
):
self._unit_of_work = unit_of_work
self._hash_service = hash_service
self._logger = logger
@transactional
async def __call__(self, email: str, password: str) -> bool:
try:
user = await self._unit_of_work.user_repository.get_user_by_email(email)
password_hash = self._hash_service.hash_password(password)
await self._unit_of_work.user_repository.set_password(
user_id=user.id,
password_hash=password_hash,
)
self._logger.info(f'Set password for user {user.id}')
return True
except ApplicationException:
raise

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

@@ -11,7 +11,7 @@ from src.application.abstractions.repositories import IUserRepository
from src.application.contracts import ILogger
from src.application.domain.entities import UserEntity
from src.application.domain.enums.account_type import AccountType
from src.application.domain.exceptions import ApplicationException
from src.application.domain.exceptions import InternalServerException, ApplicationException
from src.infrastructure.database.models import UserModel
@@ -74,6 +74,23 @@ class UserRepository(IUserRepository):
self._logger.exception(str(exc))
raise ApplicationException(status_code=500, message='Database error')
async def _update_field(self, user_id: str, **fields: object) -> UserEntity:
try:
user = await self._get_active_user(user_id)
for key, value in fields.items():
setattr(user, key, value)
await self._session.flush()
await self._session.refresh(user)
return self._to_entity(user)
except ApplicationException:
raise
except SQLAlchemyError as exception:
self._logger.exception(str(exception))
raise InternalServerException(message=f'Database error: {str(exception)}')
async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
return await self._update_field(user_id, password_hash=password_hash)
async def get_user_by_email(self, email: str) -> UserEntity:
try:
stmt = select(UserModel).where(UserModel.email == email, UserModel.is_deleted.is_(False))

View File

@@ -20,6 +20,7 @@ from src.application.commands import (
UpdateOrganizationCommand,
UpdatePurchaseRequestStatusCommand,
UploadOrganizationDocumentCommand,
SetPasswordCommand,
)
from src.application.contracts import IHashService, IJwtService, ILogger
from src.infrastructure.config import settings
@@ -159,3 +160,10 @@ def get_set_purchase_request_quote_command(
logger: ILogger = Depends(get_logger),
) -> SetPurchaseRequestQuoteCommand:
return SetPurchaseRequestQuoteCommand(uow, logger)
def get_set_password_command(
uow: IUnitOfWork = Depends(get_unit_of_work),
hash_service: IHashService = Depends(get_hash_service),
logger: ILogger = Depends(get_logger),
) -> SetPasswordCommand:
return SetPasswordCommand(uow, hash_service, logger)

View File

@@ -5,6 +5,7 @@ from src.presentation.routing.documents import documents_router
from src.presentation.routing.jwt import jwt_router
from src.presentation.routing.organizations import organizations_router
from src.presentation.routing.purchase_requests import purchase_requests_router
from src.presentation.routing.users import users_router
v1_router = APIRouter(prefix='/v1')
v1_router.include_router(auth_router)
@@ -12,3 +13,4 @@ v1_router.include_router(jwt_router)
v1_router.include_router(organizations_router)
v1_router.include_router(documents_router)
v1_router.include_router(purchase_requests_router)
v1_router.include_router(users_router)

View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse
from starlette import status
from src.presentation.dependencies.commands import get_set_password_command
from src.application.commands.set_password import SetPasswordCommand
from presentation.schemas.password import SetPasswordRequest
users_router = APIRouter(prefix='/users', tags=['users'])
@users_router.patch(path='/password', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
async def set_password(
request: Request,
body: SetPasswordRequest,
command: SetPasswordCommand = Depends(get_set_password_command),
):
await command(email=body.email, password=body.password)
return ORJSONResponse(content={'message': 'Password updated successfully'})

View File

@@ -0,0 +1,13 @@
import re
from pydantic import BaseModel, field_validator
from src.application.domain.password_policy import validate_password_strength
class SetPasswordRequest(BaseModel):
email: str
password: str
@field_validator('password')
@classmethod
def validate_password(cls, v: str) -> str:
return validate_password_strength(v)