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

fix settings users cache service #250

Merged
merged 1 commit into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ build:
down:
docker stack rm sapphire || true

clean: down
up:
docker stack deploy -c docker-compose.yaml sapphire

clean:
docker rmi sapphire --force

up: clean build
sleep:
ifeq ($(OSFLAG),WIN)
timeout /t 15
else
sleep 15
endif
docker stack deploy -c docker-compose.yaml sapphire

restart: down clean build sleep up
9 changes: 9 additions & 0 deletions autotests/clients/rest/users/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
AuthorizeResponse,
HealthResponse,
JWTData,
ResetPasswordRequest,
UserResponse,
UserUpdateRequest,
)
Expand Down Expand Up @@ -55,6 +56,14 @@ async def sign_in(self, email: str, password: str) -> AuthorizeResponse:

return await self.rest_post(path=path, response_model=AuthorizeResponse, data=request)

async def reset_password_request(self, email: str):
path = f"/api/rest/auth/reset-password"
request = ResetPasswordRequest(email=email)

response = await self.post(url=path, data=request.model_dump_json())
if response.status_code // 100 != 2:
raise ResponseException(status_code=response.status_code, body=response.content)

async def get_user(self, user_id: uuid.UUID) -> UserResponse:
path = f"/api/rest/users/{user_id}"

Expand Down
14 changes: 13 additions & 1 deletion autotests/clients/rest/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from pydantic import AwareDatetime, BaseModel, EmailStr, constr

Password = constr(pattern=r"^[\w\(\)\[\]\{}\^\$\+\*@#%!&]{8,}$")


class HealthResponse(BaseModel):
name: Literal["Users"]
Expand Down Expand Up @@ -38,10 +40,20 @@ class UserUpdateRequest(BaseModel):

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):
email: EmailStr
code: str
new_password: Password
7 changes: 7 additions & 0 deletions autotests/flow/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ async def test_signin_user(self, users_rest_client: UsersRestClient):
assert response.user.id == user_id
assert response.user.email == email

@pytest.mark.dependency(depends=["TestUserAuthFlow::test_signin_user"])
@pytest.mark.asyncio
async def test_reset_password_request(self, users_rest_client: UsersRestClient):
email: str = self.CONTEXT["email"]

await users_rest_client.reset_password_request(email=email)


class TestUserUpdateFlow:
CONTEXT = {}
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ services:
USERS__API__ALLOWED_ORIGINS: ${USERS__API__ALLOWED_ORIGINS:-["http://localhost:3000"]}
USERS__API__OAUTH2_HABR_CALLBACK_URL: ${USERS__API__OAUTH2_HABR_CALLBACK_URL:-http://localhost:3000/users/api/rest/auth/oauth2/habr/callback}
USERS__API__MEDIA_DIR_PATH: "/users/media"
USERS__BROKER__SERVERS: '["kafka:9091"]'
USERS__CACHE__URL: "redis://redis:6379/0"
USERS__DATABASE__DSN: ${USERS__DATABASE__DSN:-postgresql+asyncpg://sapphire:P%40ssw0rd@database:5432/sapphire}
secrets:
Expand Down
2 changes: 1 addition & 1 deletion sapphire/database/models/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ class Participant(Base):
joined_at: Mapped[datetime | None]

position: Mapped[Position] = relationship(back_populates="participants", lazy=False)
user: Mapped[User] = relationship()
user: Mapped[User] = relationship(lazy=False)

__table_args__ = (
Index("participants__position_id_idx", "position_id", postgresql_using="hash"),
Expand Down
2 changes: 1 addition & 1 deletion sapphire/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def get_service(loop: asyncio.AbstractEventLoop, settings: Settings) -> Service:
notifications_service = notifications.get_service(loop=loop, settings=settings.notifications)
projects_service = projects.get_service(loop=loop, settings=settings.projects)
storage_service = storage.get_service(settings=settings.storage)
users_service = users.get_service(settings=settings.users)
users_service = users.get_service(loop=loop, settings=settings.users)

return Service(
email=email_service,
Expand Down
52 changes: 20 additions & 32 deletions sapphire/users/api/rest/auth/handlers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fastapi

from sapphire.common.api.exceptions import HTTPForbidden, HTTPNotAuthenticated, HTTPNotFound
from sapphire.common.api.exceptions import 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
Expand All @@ -27,13 +27,13 @@ async def check(jwt_data: JWTData | None = fastapi.Depends(get_jwt_data)) -> JWT
async def sign_up(
request: fastapi.Request,
response: fastapi.Response,
auth_data: AuthorizeRequest,
data: AuthorizeRequest,
) -> AuthorizeResponse:
jwt_methods: JWTMethods = request.app.service.jwt_methods
database_service: database.Service = request.app.service.database

async with database_service.transaction() as session:
user = await database_service.get_user(session=session, email=auth_data.email)
user = await database_service.get_user(session=session, email=data.email)
if user is not None:
if user.password is not None:
raise fastapi.HTTPException(
Expand All @@ -43,13 +43,13 @@ async def sign_up(
user = await database_service.update_user(
session=session,
user=user,
password=auth_data.password,
password=data.password,
)
else:
user = await database_service.create_user(
session=session,
email=auth_data.email,
password=auth_data.password,
email=data.email,
password=data.password,
)

return generate_authorize_response(jwt_methods=jwt_methods, response=response, user=user)
Expand All @@ -58,61 +58,49 @@ async def sign_up(
async def sign_in(
request: fastapi.Request,
response: fastapi.Response,
auth_data: AuthorizeRequest,
data: AuthorizeRequest,
):
jwt_methods: JWTMethods = request.app.service.jwt_methods
database_service: database.Service = request.app.service.database

async with database_service.transaction() as session:
user = await database_service.get_user(session=session, email=auth_data.email)
user = await database_service.get_user(session=session, email=data.email)
if (
user is None or
not database_service.check_user_password(user=user, password=auth_data.password)
not database_service.check_user_password(user=user, password=data.password)
):
raise HTTPNotAuthenticated()

return generate_authorize_response(jwt_methods=jwt_methods, response=response, user=user)


async def reset_password(
request: fastapi.Request,
reset_data: ResetPasswordRequest
):
async def reset_password_request(request: fastapi.Request, 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
)
user = await database_service.get_user(session=session, email=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)
code = await cache_service.reset_password_set_code(email=data.email)
await broker_service.send_reset_password_email(email=data.email, code=code)


async def change_password(
request: fastapi.Request,
change_password_data: ChangePasswordRequest
):
async def change_password(request: fastapi.Request, 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=data.email)

if not user or not cache_service.reset_password_validate_code(email=data.email, code=data.code):
raise HTTPNotFound()

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
password=data.new_password,
)
3 changes: 2 additions & 1 deletion sapphire/users/api/rest/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
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)
router.add_api_route(methods=["POST"], path="/reset-password",
endpoint=handlers.reset_password_request)
11 changes: 10 additions & 1 deletion sapphire/users/api/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from sapphire.common.habr_career.client import HabrCareerClient
from sapphire.common.jwt import JWTMethods
from sapphire.common.utils.package import get_version
from sapphire.users import cache, database, oauth2
from sapphire.users import broker, cache, database, oauth2

from . import health, router
from .settings import Settings
Expand All @@ -18,6 +18,7 @@
class Service(BaseAPIService):
def __init__(
self,
broker: broker.Service,
database: database.Service,
cache: cache.Service,
oauth2_habr: oauth2.habr.Service,
Expand All @@ -33,6 +34,7 @@ def __init__(
allowed_origins: Iterable[str] = (),
port: int = 8000,
):
self._broker = broker
self._database = database
self._oauth2_habr = oauth2_habr
self._oauth2_habr_callback_url = oauth2_habr_callback_url
Expand All @@ -59,12 +61,17 @@ def setup_app(self, app: fastapi.FastAPI):
@property
def dependencies(self) -> list[ServiceMixin]:
return [
self._broker,
self._database,
self._oauth2_habr,
self._habr_client,
self._cache,
]

@property
def broker(self) -> broker.Service:
return self._broker

@property
def database(self) -> database.Service:
return self._database
Expand Down Expand Up @@ -103,6 +110,7 @@ def load_file_chunk_size(self) -> int:


def get_service(
broker: broker.Service,
database: database.Service,
cache: cache.Service,
oauth2_habr: oauth2.habr.Service,
Expand All @@ -112,6 +120,7 @@ def get_service(
settings: Settings,
) -> Service:
return Service(
broker=broker,
database=database,
cache=cache,
oauth2_habr=oauth2_habr,
Expand Down
8 changes: 2 additions & 6 deletions sapphire/users/broker/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,10 @@


class Service(BaseBrokerProducerService):
async def send_email_code(self, email: EmailStr, code: str, topic: str = "email"):
async def send_reset_password_email(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}
)
message=Email(to=[email], type=EmailType.RESET_PASSWORD, data={"code": code}),
)


Expand Down
26 changes: 18 additions & 8 deletions sapphire/users/cache/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@


class Service(BaseCacheService):
def __init__(self, url: str, oauth2_state_ttl: int = 120, reset_password_code_ttl: int = 86400):
super().__init__(url=url)

self._oauth2_state_ttl = oauth2_state_ttl
self._reset_password_code_ttl = reset_password_code_ttl

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=Settings.oauth_storage_time)
await self.redis.set(key, state, ex=self._reset_password_code_ttl)
return state

async def oauth_validate_state(self, state: str) -> bool:
Expand All @@ -23,20 +29,24 @@ async def oauth_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
async def reset_password_set_code(self, email: EmailStr) -> str:
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
await self.redis.set(key, code, ex=self._reset_password_code_ttl)
return code

async def reset_password_validate_code(self, secret_code: str, email: EmailStr) -> bool:
async def reset_password_validate_code(self, email: EmailStr, code: str) -> bool:
key = f"users:auth:change_password:secret_code:{email}"
value = await self.redis.get(key)
if value == secret_code:
if value == code:
await self.redis.delete(key)
return True
return False


def get_service(settings: Settings) -> Service:
return Service(url=str(settings.url))
return Service(
url=str(settings.url),
oauth2_state_ttl=settings.oauth2_state_ttl,
reset_password_code_ttl=settings.reset_password_code_ttl,
)
6 changes: 4 additions & 2 deletions sapphire/users/cache/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from pydantic import NonNegativeInt

from sapphire.common.cache.settings import BaseCacheSettings


class Settings(BaseCacheSettings):
oauth_storage_time: int = 120
code_storage_time: int = 43200
oauth2_state_ttl: NonNegativeInt = 120 # in seconds: 2 minutes
reset_password_code_ttl: NonNegativeInt = 86400 # in seconds: 24 hours
2 changes: 1 addition & 1 deletion sapphire/users/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def run(ctx: typer.Context):
loop: asyncio.AbstractEventLoop = ctx.obj["loop"]
settings: Settings = ctx.obj["settings"]

users_service = get_service(settings=settings)
users_service = get_service(loop=loop, settings=settings)

loop.run_until_complete(users_service.run())

Expand Down
Loading