Skip to content
This repository has been archived by the owner on Apr 14, 2024. It is now read-only.

part of change_password logic #249

Merged
merged 16 commits into from
Mar 25, 2024
8 changes: 5 additions & 3 deletions sapphire/common/broker/models/email.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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] = {}
3 changes: 3 additions & 0 deletions sapphire/common/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from pydantic import constr

Password = constr(pattern=r"^[\w\(\)\[\]\{\}\^\$\+\*@#%!&]{8,}$")
12 changes: 3 additions & 9 deletions sapphire/email/sender/service.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 8 additions & 8 deletions sapphire/projects/broker/service.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
)

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

Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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,
)

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

Expand Down Expand Up @@ -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))

Expand Down
53 changes: 50 additions & 3 deletions sapphire/users/api/rest/auth/handlers.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -67,3 +72,45 @@ 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)
brulitsan marked this conversation as resolved.
Show resolved Hide resolved
await database_service.update_user(
session=session,
user=user,
password=change_password_data.new_password
)
2 changes: 1 addition & 1 deletion sapphire/users/api/rest/auth/oauth2/habr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions sapphire/users/api/rest/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
15 changes: 13 additions & 2 deletions sapphire/users/api/rest/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -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):
secret_code: str
email: EmailStr
new_password: Password
2 changes: 2 additions & 0 deletions sapphire/users/broker/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .service import Service, get_service
from .settings import Settings
27 changes: 27 additions & 0 deletions sapphire/users/broker/service.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions sapphire/users/broker/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from sapphire.common.broker.settings import BaseBrokerProducerSettings


class Settings(BaseBrokerProducerSettings):
servers: list[str] = ["localhost:9091"]
23 changes: 20 additions & 3 deletions sapphire/users/cache/service.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
import secrets
import uuid

from pydantic import EmailStr

from sapphire.common.cache.service import BaseCacheService

from .settings import Settings


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)
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:
await self.redis.delete(key)
return True
return False

async def change_password_set_secret_code(self, email: EmailStr) -> str:
secret_code = str(secrets.token_urlsafe(12))
brulitsan marked this conversation as resolved.
Show resolved Hide resolved
key = f"users:auth:change_password:secret_code:{email}"
await self.redis.set(key, secret_code, ex=43200)
brulitsan marked this conversation as resolved.
Show resolved Hide resolved
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))
3 changes: 2 additions & 1 deletion sapphire/users/database/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions sapphire/users/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,3 +19,4 @@ class Settings(BaseModel):
habr: HabrSettings = HabrSettings()
habr_career: HabrCareerSettings = HabrCareerSettings()
oauth2_habr: OAuth2HabrSettings = OAuth2HabrSettings()
broker: BrokerSettings = BrokerSettings()