diff --git a/sapphire/common/broker/models/email.py b/sapphire/common/broker/models/email.py index 11936d51..5f7e87dd 100644 --- a/sapphire/common/broker/models/email.py +++ b/sapphire/common/broker/models/email.py @@ -1,7 +1,7 @@ import enum -import uuid +from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr class EmailType(str, enum.Enum): @@ -11,8 +11,10 @@ class EmailType(str, enum.Enum): PARTICIPANT_DECLINED = "participant_declined" PARTICIPANT_LEFT = "participant_left" OWNER_EXCLUDED = "owner_excluded" + RESET_PASSWORD = "reset_password" class Email(BaseModel): type: EmailType - to: list[uuid.UUID] + to: list[EmailStr] + data: dict[str, Any] = {} diff --git a/sapphire/common/types.py b/sapphire/common/types.py new file mode 100644 index 00000000..ee31b84c --- /dev/null +++ b/sapphire/common/types.py @@ -0,0 +1,3 @@ +from pydantic import constr + +Password = constr(pattern=r"^[\w\(\)\[\]\{\}\^\$\+\*@#%!&]{8,}$") diff --git a/sapphire/email/sender/service.py b/sapphire/email/sender/service.py index 1edfdbd7..b88a1848 100644 --- a/sapphire/email/sender/service.py +++ b/sapphire/email/sender/service.py @@ -1,10 +1,10 @@ import asyncio -import uuid from typing import Any, Iterable import aiosmtplib import backoff from facet import ServiceMixin +from pydantic import EmailStr from .settings import Settings from .templates import Template @@ -35,16 +35,10 @@ def __init__( def templates(self) -> dict[str, Template]: return self._templates - async def _get_recipient_email(self, recipient: uuid.UUID) -> str: - # Issue: Write a function to get email from the users service using user_id - - return "email@example.com" - - async def send(self, template: Template, data: dict[str, Any], recipients: Iterable[uuid.UUID]): + async def send(self, template: Template, data: dict[str, Any], recipients: Iterable[EmailStr]): coroutines = [] for recipient in recipients: - recipient_email = await self._get_recipient_email(recipient) - message = template.render(recipient=recipient_email, sender=self._sender, data=data) + message = template.render(recipient=recipient, sender=self._sender, data=data) coroutine = backoff.on_exception(backoff.expo, Exception, max_tries=3)( self._client.send_message, )(message) diff --git a/sapphire/projects/broker/service.py b/sapphire/projects/broker/service.py index 2407b1fb..bdac2db2 100644 --- a/sapphire/projects/broker/service.py +++ b/sapphire/projects/broker/service.py @@ -1,7 +1,7 @@ import asyncio import uuid -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr from sapphire.common.broker.models.email import Email, EmailType from sapphire.common.broker.models.messenger import CreateChat @@ -26,7 +26,7 @@ async def send_participant_requested( ) -> None: """RECIPIENTS: ONLY OWNER""" await self._send_email( - recipients=[project.owner_id], + recipients=[owner_email], email_type=EmailType.PARTICIPANT_REQUESTED, ) @@ -50,7 +50,7 @@ async def send_participant_joined( ) -> None: """RECIPIENTS: PARTICIPANTS""" await self._send_email( - recipients=[p.user_id for p in project.joined_participants], + recipients=[p.user.email for p in project.joined_participants], email_type=EmailType.PARTICIPANT_JOINED, ) @@ -74,7 +74,7 @@ async def send_participant_declined( ) -> None: """RECIPIENTS: ONLY OWNER""" await self._send_email( - recipients=[project.owner_id], email_type=EmailType.PARTICIPANT_DECLINED + recipients=[owner_email], email_type=EmailType.PARTICIPANT_DECLINED ) await self._send_notification_to_recipients( @@ -97,7 +97,7 @@ async def send_owner_declined( ) -> None: """RECIPIENTS: ONLY PARTICIPANT""" await self._send_email( - recipients=[participant.user_id], email_type=EmailType.OWNER_DECLINED + recipients=[participant.user.email], email_type=EmailType.OWNER_DECLINED ) await self._send_notification_to_recipients( @@ -120,7 +120,7 @@ async def send_participant_left( ) -> None: """RECIPIENTS: PROJECT OWNER AND PARTICIPANTS""" await self._send_email( - recipients=[project.owner_id] + [p.user_id for p in project.joined_participants], + recipients=[owner_email] + [p.user.email for p in project.joined_participants], email_type=EmailType.PARTICIPANT_LEFT, ) @@ -144,7 +144,7 @@ async def send_owner_excluded( ) -> None: """RECIPIENTS: PROJECT OWNER AND PARTICIPANTS""" await self._send_email( - recipients=[project.owner_id] + [p.user_id for p in project.joined_participants], + recipients=[owner_email] + [p.user.email for p in project.joined_participants], email_type=EmailType.OWNER_EXCLUDED, ) @@ -176,7 +176,7 @@ async def _send_notification_to_recipients(self, await asyncio.gather(*send_tasks) async def _send_email( - self, recipients: list[uuid.UUID], email_type: EmailType, topic: str = "email" + self, recipients: list[EmailStr], email_type: EmailType, topic: str = "email" ): await self.send(topic=topic, message=Email(to=recipients, type=email_type)) diff --git a/sapphire/users/api/rest/auth/handlers.py b/sapphire/users/api/rest/auth/handlers.py index 1e6745af..3078b75b 100644 --- a/sapphire/users/api/rest/auth/handlers.py +++ b/sapphire/users/api/rest/auth/handlers.py @@ -1,12 +1,17 @@ import fastapi -from sapphire.common.api.exceptions import HTTPNotAuthenticated +from sapphire.common.api.exceptions import HTTPForbidden, HTTPNotAuthenticated, HTTPNotFound from sapphire.common.jwt.dependencies.rest import get_jwt_data from sapphire.common.jwt.methods import JWTMethods from sapphire.common.jwt.models import JWTData -from sapphire.users import database +from sapphire.users import broker, cache, database -from .schemas import AuthorizeRequest, AuthorizeResponse +from .schemas import ( + AuthorizeRequest, + AuthorizeResponse, + ChangePasswordRequest, + ResetPasswordRequest, +) from .utils import generate_authorize_response @@ -67,3 +72,47 @@ async def sign_in( raise HTTPNotAuthenticated() return generate_authorize_response(jwt_methods=jwt_methods, response=response, user=user) + + +async def reset_password( + request: fastapi.Request, + reset_data: ResetPasswordRequest +): + broker_service: broker.Service = request.app.service.broker + database_service: database.Service = request.app.service.database + cache_service: cache.Service = request.app.service.cache + + async with database_service.transaction() as session: + user = await database_service.get_user( + session=session, + email=reset_data.email + ) + if not user: + raise HTTPNotFound() + + secret_code = await cache_service.change_password_set_secret_code(email=reset_data.email) + await broker_service.send_email_code(email=reset_data.email, code=secret_code) + + +async def change_password( + request: fastapi.Request, + change_password_data: ChangePasswordRequest +): + database_service: database.Service = request.app.service.database + cache_service: cache.Service = request.app.service.cache + + if not cache_service.reset_password_validate_code( + secret_code=change_password_data.secret_code, + email=change_password_data.email + ): + raise HTTPForbidden() + + async with database_service.transaction() as session: + user = await database_service.get_user(session=session, email=change_password_data.email) + if not user: + raise HTTPNotFound() + await database_service.update_user( + session=session, + user=user, + password=change_password_data.new_password + ) diff --git a/sapphire/users/api/rest/auth/oauth2/habr.py b/sapphire/users/api/rest/auth/oauth2/habr.py index 53cf1b28..506d35c0 100644 --- a/sapphire/users/api/rest/auth/oauth2/habr.py +++ b/sapphire/users/api/rest/auth/oauth2/habr.py @@ -23,7 +23,7 @@ async def authorize( cache_service: cache.Service = request.app.service.cache - state = await cache_service.set_state() + state = await cache_service.oauth_set_state() if redirect_url is None: redirect_url = oauth2_habr_callback_url diff --git a/sapphire/users/api/rest/auth/router.py b/sapphire/users/api/rest/auth/router.py index 7dcce4c4..bbada402 100644 --- a/sapphire/users/api/rest/auth/router.py +++ b/sapphire/users/api/rest/auth/router.py @@ -9,3 +9,5 @@ router.add_api_route(methods=["POST"], path="/signup", endpoint=handlers.sign_up) router.add_api_route(methods=["POST"], path="/signin", endpoint=handlers.sign_in) router.include_router(oauth2.router, prefix="/oauth2") +router.add_api_route(methods=["POST"], path="/change-password", endpoint=handlers.change_password) +router.add_api_route(methods=["POST"], path="/reset-password", endpoint=handlers.reset_password) diff --git a/sapphire/users/api/rest/auth/schemas.py b/sapphire/users/api/rest/auth/schemas.py index 1c66d362..12edee3a 100644 --- a/sapphire/users/api/rest/auth/schemas.py +++ b/sapphire/users/api/rest/auth/schemas.py @@ -1,14 +1,25 @@ -from pydantic import BaseModel, EmailStr, constr +from pydantic import BaseModel, EmailStr +from sapphire.common.types import Password from sapphire.users.api.rest.schemas import UserResponse class AuthorizeRequest(BaseModel): email: EmailStr - password: constr(pattern=r"^[\w\(\)\[\]\{\}\^\$\+\*@#%!&]{8,}$") + password: Password class AuthorizeResponse(BaseModel): user: UserResponse access_token: str refresh_token: str + + +class ResetPasswordRequest(BaseModel): + email: EmailStr + + +class ChangePasswordRequest(BaseModel): + code: str + email: EmailStr + new_password: Password diff --git a/sapphire/users/broker/__init__.py b/sapphire/users/broker/__init__.py new file mode 100644 index 00000000..365287ab --- /dev/null +++ b/sapphire/users/broker/__init__.py @@ -0,0 +1,2 @@ +from .service import Service, get_service +from .settings import Settings diff --git a/sapphire/users/broker/service.py b/sapphire/users/broker/service.py new file mode 100644 index 00000000..c69db35b --- /dev/null +++ b/sapphire/users/broker/service.py @@ -0,0 +1,27 @@ +import asyncio + +from pydantic import EmailStr + +from sapphire.common.broker.models.email import Email, EmailType +from sapphire.common.broker.service import BaseBrokerProducerService + +from .settings import Settings + + +class Service(BaseBrokerProducerService): + async def send_email_code(self, email: EmailStr, code: str, topic: str = "email"): + await self.send( + topic=topic, + message=Email( + to=[email], + type=EmailType.RESET_PASSWORD, + sending_data={"code": code} + ) + ) + + +def get_service( + loop: asyncio.AbstractEventLoop, + settings: Settings, +) -> Service: + return Service(loop=loop, servers=settings.servers) diff --git a/sapphire/users/broker/settings.py b/sapphire/users/broker/settings.py new file mode 100644 index 00000000..9ff89feb --- /dev/null +++ b/sapphire/users/broker/settings.py @@ -0,0 +1,5 @@ +from sapphire.common.broker.settings import BaseBrokerProducerSettings + + +class Settings(BaseBrokerProducerSettings): + servers: list[str] = ["localhost:9091"] diff --git a/sapphire/users/cache/service.py b/sapphire/users/cache/service.py index 5ad8d2c3..c7e3833c 100644 --- a/sapphire/users/cache/service.py +++ b/sapphire/users/cache/service.py @@ -1,4 +1,7 @@ import secrets +import uuid + +from pydantic import EmailStr from sapphire.common.cache.service import BaseCacheService @@ -6,13 +9,13 @@ class Service(BaseCacheService): - async def set_state(self) -> str: - state = secrets.token_hex(32) + async def oauth_set_state(self) -> str: + state = str(uuid.uuid4()) key = f"users:auth:oauth2:habr:state:{state}" - await self.redis.set(key, state, ex=120) + await self.redis.set(key, state, ex=Settings.oauth_storage_time) return state - async def validate_state(self, state: str) -> bool: + async def oauth_validate_state(self, state: str) -> bool: key = f"users:auth:oauth2:habr:state:{state}" value = await self.redis.get(key) if value is not None: @@ -20,6 +23,20 @@ async def validate_state(self, state: str) -> bool: return True return False + async def change_password_set_secret_code(self, email: EmailStr) -> str: + secret_code = str(secrets.token_urlsafe(12)) # generate sixteen-digit secret code + key = f"users:auth:change_password:secret_code:{email}" + await self.redis.set(key, secret_code, ex=Settings.code_storage_time) + return secret_code + + async def reset_password_validate_code(self, secret_code: str, email: EmailStr) -> bool: + key = f"users:auth:change_password:secret_code:{email}" + value = await self.redis.get(key) + if value == secret_code: + await self.redis.delete(key) + return True + return False + def get_service(settings: Settings) -> Service: return Service(url=str(settings.url)) diff --git a/sapphire/users/cache/settings.py b/sapphire/users/cache/settings.py index fa7103a5..db9f087d 100644 --- a/sapphire/users/cache/settings.py +++ b/sapphire/users/cache/settings.py @@ -2,4 +2,5 @@ class Settings(BaseCacheSettings): - pass + oauth_storage_time: int = 120 + code_storage_time: int = 43200 diff --git a/sapphire/users/database/service.py b/sapphire/users/database/service.py index 77ad929c..3347ca79 100644 --- a/sapphire/users/database/service.py +++ b/sapphire/users/database/service.py @@ -2,6 +2,7 @@ from typing import Set, Type import bcrypt +from pydantic import EmailStr from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -26,7 +27,7 @@ async def get_user( self, session: AsyncSession, user_id: uuid.UUID | Type[Empty] = Empty, - email: str | Type[Empty] = Empty, + email: EmailStr | Type[Empty] = Empty, ) -> User | None: filters = [] if user_id is not Empty: diff --git a/sapphire/users/settings.py b/sapphire/users/settings.py index 5abf0d31..926fb953 100644 --- a/sapphire/users/settings.py +++ b/sapphire/users/settings.py @@ -5,6 +5,7 @@ from sapphire.common.jwt.settings import JWTSettings from .api import Settings as APISettings +from .broker import Settings as BrokerSettings from .cache import Settings as CacheSettings from .database import Settings as DatabaseSettings from .oauth2.habr import Settings as OAuth2HabrSettings @@ -18,3 +19,4 @@ class Settings(BaseModel): habr: HabrSettings = HabrSettings() habr_career: HabrCareerSettings = HabrCareerSettings() oauth2_habr: OAuth2HabrSettings = OAuth2HabrSettings() + broker: BrokerSettings = BrokerSettings()