diff --git a/src/application/abstractions/repositories/i_user_repository.py b/src/application/abstractions/repositories/i_user_repository.py index c9c8d79..eff4843 100644 --- a/src/application/abstractions/repositories/i_user_repository.py +++ b/src/application/abstractions/repositories/i_user_repository.py @@ -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 diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index a450151..635cf30 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -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', ] diff --git a/src/application/commands/set_password.py b/src/application/commands/set_password.py new file mode 100644 index 0000000..64c6627 --- /dev/null +++ b/src/application/commands/set_password.py @@ -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 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 0d694c2..a82cf37 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -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 @@ -73,6 +73,23 @@ class UserRepository(IUserRepository): except SQLAlchemyError as exc: 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: diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index f5fec47..75546f4 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -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) diff --git a/src/presentation/routing/__init__.py b/src/presentation/routing/__init__.py index b0f0bc2..2dfb85c 100644 --- a/src/presentation/routing/__init__.py +++ b/src/presentation/routing/__init__.py @@ -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) \ No newline at end of file diff --git a/src/presentation/routing/users.py b/src/presentation/routing/users.py new file mode 100644 index 0000000..a291571 --- /dev/null +++ b/src/presentation/routing/users.py @@ -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'}) \ No newline at end of file diff --git a/src/presentation/schemas/password.py b/src/presentation/schemas/password.py new file mode 100644 index 0000000..5f001ec --- /dev/null +++ b/src/presentation/schemas/password.py @@ -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)