diff --git a/Makefile b/Makefile index 06f3cc12..7003221b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/autotests/clients/rest/users/client.py b/autotests/clients/rest/users/client.py index 44425c1b..b086fa69 100644 --- a/autotests/clients/rest/users/client.py +++ b/autotests/clients/rest/users/client.py @@ -11,6 +11,7 @@ AuthorizeResponse, HealthResponse, JWTData, + ResetPasswordRequest, UserResponse, UserUpdateRequest, ) @@ -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}" diff --git a/autotests/clients/rest/users/models.py b/autotests/clients/rest/users/models.py index c0677f10..b68ac911 100644 --- a/autotests/clients/rest/users/models.py +++ b/autotests/clients/rest/users/models.py @@ -3,6 +3,8 @@ from pydantic import AwareDatetime, BaseModel, EmailStr, constr +Password = constr(pattern=r"^[\w\(\)\[\]\{}\^\$\+\*@#%!&]{8,}$") + class HealthResponse(BaseModel): name: Literal["Users"] @@ -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 diff --git a/autotests/flow/test_users.py b/autotests/flow/test_users.py index 3cbdc9fb..78a89d5b 100644 --- a/autotests/flow/test_users.py +++ b/autotests/flow/test_users.py @@ -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 = {} diff --git a/docker-compose.yaml b/docker-compose.yaml index c6f034d5..1d4ff602 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/sapphire/database/models/projects.py b/sapphire/database/models/projects.py index b9dc2e43..def3dd87 100644 --- a/sapphire/database/models/projects.py +++ b/sapphire/database/models/projects.py @@ -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"), diff --git a/sapphire/service.py b/sapphire/service.py index e9913ae6..ee21aab8 100644 --- a/sapphire/service.py +++ b/sapphire/service.py @@ -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, diff --git a/sapphire/users/api/rest/auth/handlers.py b/sapphire/users/api/rest/auth/handlers.py index 3078b75b..6c8995d3 100644 --- a/sapphire/users/api/rest/auth/handlers.py +++ b/sapphire/users/api/rest/auth/handlers.py @@ -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 @@ -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( @@ -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) @@ -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, ) diff --git a/sapphire/users/api/rest/auth/router.py b/sapphire/users/api/rest/auth/router.py index bbada402..a509c82d 100644 --- a/sapphire/users/api/rest/auth/router.py +++ b/sapphire/users/api/rest/auth/router.py @@ -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) diff --git a/sapphire/users/api/service.py b/sapphire/users/api/service.py index 28c36a0f..a01be02f 100644 --- a/sapphire/users/api/service.py +++ b/sapphire/users/api/service.py @@ -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 @@ -18,6 +18,7 @@ class Service(BaseAPIService): def __init__( self, + broker: broker.Service, database: database.Service, cache: cache.Service, oauth2_habr: oauth2.habr.Service, @@ -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 @@ -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 @@ -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, @@ -112,6 +120,7 @@ def get_service( settings: Settings, ) -> Service: return Service( + broker=broker, database=database, cache=cache, oauth2_habr=oauth2_habr, diff --git a/sapphire/users/broker/service.py b/sapphire/users/broker/service.py index c69db35b..ad67c102 100644 --- a/sapphire/users/broker/service.py +++ b/sapphire/users/broker/service.py @@ -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}), ) diff --git a/sapphire/users/cache/service.py b/sapphire/users/cache/service.py index c7e3833c..48332a5d 100644 --- a/sapphire/users/cache/service.py +++ b/sapphire/users/cache/service.py @@ -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: @@ -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, + ) diff --git a/sapphire/users/cache/settings.py b/sapphire/users/cache/settings.py index db9f087d..dbdf3d5d 100644 --- a/sapphire/users/cache/settings.py +++ b/sapphire/users/cache/settings.py @@ -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 diff --git a/sapphire/users/cli.py b/sapphire/users/cli.py index ab6311b5..f6e55c0d 100644 --- a/sapphire/users/cli.py +++ b/sapphire/users/cli.py @@ -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()) diff --git a/sapphire/users/service.py b/sapphire/users/service.py index af5ac1e8..7a9ecbdc 100644 --- a/sapphire/users/service.py +++ b/sapphire/users/service.py @@ -1,10 +1,12 @@ +import asyncio + from facet import ServiceMixin from sapphire.common.habr import get_habr_client from sapphire.common.habr_career import get_habr_career_client from sapphire.common.jwt.methods import get_jwt_methods -from . import api, cache, database, oauth2 +from . import api, broker, cache, database, oauth2 from .settings import Settings @@ -23,7 +25,8 @@ def api(self) -> api.Service: return self._api -def get_service(settings: Settings) -> Service: +def get_service(loop: asyncio.AbstractEventLoop, settings: Settings) -> Service: + broker_service = broker.get_service(loop=loop, settings=settings.broker) cache_service = cache.get_service(settings=settings.cache) database_service = database.get_service(settings=settings.database) oauth2_habr_service = oauth2.habr.get_service(settings=settings.oauth2_habr) @@ -31,6 +34,7 @@ def get_service(settings: Settings) -> Service: habr_career_client = get_habr_career_client(settings=settings.habr_career) jwt_methods = get_jwt_methods(settings=settings.jwt) api_service = api.get_service( + broker=broker_service, cache=cache_service, database=database_service, oauth2_habr=oauth2_habr_service,