diff --git a/autotests/clients/rest/users/client.py b/autotests/clients/rest/users/client.py index 47197251..44425c1b 100644 --- a/autotests/clients/rest/users/client.py +++ b/autotests/clients/rest/users/client.py @@ -6,7 +6,14 @@ from autotests.clients.rest.base_client import BaseRestClient from autotests.clients.rest.exceptions import ResponseException -from .models import HealthResponse, JWTData, UserResponse, UserUpdateRequest +from .models import ( + AuthorizeRequest, + AuthorizeResponse, + HealthResponse, + JWTData, + UserResponse, + UserUpdateRequest, +) class UsersRestClient(BaseRestClient): @@ -36,6 +43,18 @@ async def logout(self) -> httpx.Response: return response + async def sign_up(self, email: str, password: str) -> AuthorizeResponse: + path = f"/api/rest/auth/signup" + request = AuthorizeRequest(email=email, password=password) + + return await self.rest_post(path=path, response_model=AuthorizeResponse, data=request) + + async def sign_in(self, email: str, password: str) -> AuthorizeResponse: + path = f"/api/rest/auth/signin" + request = AuthorizeRequest(email=email, password=password) + + return await self.rest_post(path=path, response_model=AuthorizeResponse, data=request) + 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 452dc1c6..c0677f10 100644 --- a/autotests/clients/rest/users/models.py +++ b/autotests/clients/rest/users/models.py @@ -34,3 +34,14 @@ class UserUpdateRequest(BaseModel): about: str | None main_specialization_id: uuid.UUID | None secondary_specialization_id: uuid.UUID | None + + +class AuthorizeRequest(BaseModel): + email: EmailStr + password: constr(pattern=r"^[\w\(\)\[\]\{}\^\$\+\*@#%!&]{8,}$") + + +class AuthorizeResponse(BaseModel): + user: UserResponse + access_token: str + refresh_token: str diff --git a/autotests/flow/test_users.py b/autotests/flow/test_users.py index cfb3acc0..3cbdc9fb 100644 --- a/autotests/flow/test_users.py +++ b/autotests/flow/test_users.py @@ -2,8 +2,53 @@ import uuid import pytest +from faker import Faker from autotests.clients.rest.users.client import UsersRestClient +from autotests.settings import AutotestsSettings + + +class TestUserAuthFlow: + CONTEXT = {} + + @pytest.mark.dependency() + @pytest.mark.asyncio + async def test_signup_user(self, faker: Faker, users_rest_client: UsersRestClient): + email = f"{uuid.uuid4()}@{faker.domain_name()}" + password = faker.password(length=16) + + response = await users_rest_client.sign_up(email=email, password=password) + + self.CONTEXT["access_token"] = response.access_token + self.CONTEXT["user_id"] = response.user.id + self.CONTEXT["email"] = email + self.CONTEXT["password"] = password + + @pytest.mark.dependency(depends=["TestUserAuthFlow::test_signup_user"]) + @pytest.mark.asyncio + async def test_check(self, settings: AutotestsSettings): + user_id: uuid.UUID = self.CONTEXT["user_id"] + access_token: str = self.CONTEXT["access_token"] + users_rest_client = UsersRestClient( + base_url=str(settings.users_base_url), + headers={"Authorization": f"Bearer {access_token}"}, + ) + + response = await users_rest_client.check_auth() + + assert response.user_id == user_id + + @pytest.mark.dependency(depends=["TestUserAuthFlow::test_signup_user"]) + @pytest.mark.asyncio + async def test_signin_user(self, users_rest_client: UsersRestClient): + user_id: uuid.UUID = self.CONTEXT["user_id"] + email: str = self.CONTEXT["email"] + password: str = self.CONTEXT["password"] + + response = await users_rest_client.sign_in(email=email, password=password) + + assert response.user.id == user_id + assert response.user.email == email class TestUserUpdateFlow: diff --git a/poetry.lock b/poetry.lock index 8b848465..373f26fd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -227,6 +227,46 @@ files = [ {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, ] +[[package]] +name = "bcrypt" +version = "4.1.2" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "bcrypt-4.1.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:ac621c093edb28200728a9cca214d7e838529e557027ef0581685909acd28b5e"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea505c97a5c465ab8c3ba75c0805a102ce526695cd6818c6de3b1a38f6f60da1"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57fa9442758da926ed33a91644649d3e340a71e2d0a5a8de064fb621fd5a3326"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb3bd3321517916696233b5e0c67fd7d6281f0ef48e66812db35fc963a422a1c"}, + {file = "bcrypt-4.1.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6cad43d8c63f34b26aef462b6f5e44fdcf9860b723d2453b5d391258c4c8e966"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:44290ccc827d3a24604f2c8bcd00d0da349e336e6503656cb8192133e27335e2"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:732b3920a08eacf12f93e6b04ea276c489f1c8fb49344f564cca2adb663b3e4c"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c28973decf4e0e69cee78c68e30a523be441972c826703bb93099868a8ff5b5"}, + {file = "bcrypt-4.1.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b8df79979c5bae07f1db22dcc49cc5bccf08a0380ca5c6f391cbb5790355c0b0"}, + {file = "bcrypt-4.1.2-cp37-abi3-win32.whl", hash = "sha256:fbe188b878313d01b7718390f31528be4010fed1faa798c5a1d0469c9c48c369"}, + {file = "bcrypt-4.1.2-cp37-abi3-win_amd64.whl", hash = "sha256:9800ae5bd5077b13725e2e3934aa3c9c37e49d3ea3d06318010aa40f54c63551"}, + {file = "bcrypt-4.1.2-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:71b8be82bc46cedd61a9f4ccb6c1a493211d031415a34adde3669ee1b0afbb63"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e3c6642077b0c8092580c819c1684161262b2e30c4f45deb000c38947bf483"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:387e7e1af9a4dd636b9505a465032f2f5cb8e61ba1120e79a0e1cd0b512f3dfc"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f70d9c61f9c4ca7d57f3bfe88a5ccf62546ffbadf3681bb1e268d9d2e41c91a7"}, + {file = "bcrypt-4.1.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2a298db2a8ab20056120b45e86c00a0a5eb50ec4075b6142db35f593b97cb3fb"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:ba55e40de38a24e2d78d34c2d36d6e864f93e0d79d0b6ce915e4335aa81d01b1"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3566a88234e8de2ccae31968127b0ecccbb4cddb629da744165db72b58d88ca4"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b90e216dc36864ae7132cb151ffe95155a37a14e0de3a8f64b49655dd959ff9c"}, + {file = "bcrypt-4.1.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:69057b9fc5093ea1ab00dd24ede891f3e5e65bee040395fb1e66ee196f9c9b4a"}, + {file = "bcrypt-4.1.2-cp39-abi3-win32.whl", hash = "sha256:02d9ef8915f72dd6daaef40e0baeef8a017ce624369f09754baf32bb32dba25f"}, + {file = "bcrypt-4.1.2-cp39-abi3-win_amd64.whl", hash = "sha256:be3ab1071662f6065899fe08428e45c16aa36e28bc42921c4901a191fda6ee42"}, + {file = "bcrypt-4.1.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d75fc8cd0ba23f97bae88a6ec04e9e5351ff3c6ad06f38fe32ba50cbd0d11946"}, + {file = "bcrypt-4.1.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:a97e07e83e3262599434816f631cc4c7ca2aa8e9c072c1b1a7fec2ae809a1d2d"}, + {file = "bcrypt-4.1.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e51c42750b7585cee7892c2614be0d14107fad9581d1738d954a262556dd1aab"}, + {file = "bcrypt-4.1.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba4e4cc26610581a6329b3937e02d319f5ad4b85b074846bf4fef8a8cf51e7bb"}, + {file = "bcrypt-4.1.2.tar.gz", hash = "sha256:33313a1200a3ae90b75587ceac502b048b840fc69e7f7a0905b5f87fac7a1258"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "certifi" version = "2024.2.2" @@ -2149,4 +2189,4 @@ sqlite = ["aiosqlite"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "b501623dc687ceecfcf51ec62233224aab582b992da591092adbbf0a894888d1" +content-hash = "31583722c41103a21e9bf69f79564a1b2ce052e8a234b5009026b1cfe2b0c99c" diff --git a/pyproject.toml b/pyproject.toml index 8e34425e..eea48ee1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ redis = ">=4.2.0rc1" pydantic = {version = "^2.5.1", extras = ["email"]} py-fast-grpc = "^0.3.4" cryptography = "^42.0.4" +bcrypt = "^4.1.2" [tool.poetry.extras] sqlite = ["aiosqlite"] diff --git a/sapphire/database/migrations/versions/891640b6839e_init.py b/sapphire/database/migrations/versions/891640b6839e_init.py index cef146dd..f9b82dbe 100644 --- a/sapphire/database/migrations/versions/891640b6839e_init.py +++ b/sapphire/database/migrations/versions/891640b6839e_init.py @@ -45,6 +45,7 @@ def upgrade() -> None: op.create_table("users", sa.Column("id", sa.Uuid(), nullable=False), sa.Column("email", sa.String(), nullable=False), + sa.Column("password", sa.String(length=72), nullable=True), sa.Column("first_name", sa.String(), nullable=True), sa.Column("last_name", sa.String(), nullable=True), sa.Column("avatar", sa.String(), nullable=True), diff --git a/sapphire/database/models/users.py b/sapphire/database/models/users.py index 0e5e30c3..e012672c 100644 --- a/sapphire/database/models/users.py +++ b/sapphire/database/models/users.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import ForeignKey, Text +from sqlalchemy import ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import Base @@ -15,6 +15,7 @@ class User(Base): id: Mapped[uuid.UUID] = mapped_column(default=uuid.uuid4, primary_key=True) email: Mapped[str] = mapped_column(unique=True) + password: Mapped[str | None] = mapped_column(String(length=72)) first_name: Mapped[str | None] last_name: Mapped[str | None] avatar: Mapped[str | None] = mapped_column(unique=True) diff --git a/sapphire/users/api/rest/auth/handlers.py b/sapphire/users/api/rest/auth/handlers.py index 7a44cfc6..1e6745af 100644 --- a/sapphire/users/api/rest/auth/handlers.py +++ b/sapphire/users/api/rest/auth/handlers.py @@ -1,7 +1,13 @@ import fastapi +from sapphire.common.api.exceptions import HTTPNotAuthenticated 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 .schemas import AuthorizeRequest, AuthorizeResponse +from .utils import generate_authorize_response async def logout(response: fastapi.Response): @@ -11,3 +17,53 @@ async def logout(response: fastapi.Response): async def check(jwt_data: JWTData | None = fastapi.Depends(get_jwt_data)) -> JWTData | None: return jwt_data + + +async def sign_up( + request: fastapi.Request, + response: fastapi.Response, + auth_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) + if user is not None: + if user.password is not None: + raise fastapi.HTTPException( + status_code=fastapi.status.HTTP_400_BAD_REQUEST, + detail="User already registered", + ) + user = await database_service.update_user( + session=session, + user=user, + password=auth_data.password, + ) + else: + user = await database_service.create_user( + session=session, + email=auth_data.email, + password=auth_data.password, + ) + + return generate_authorize_response(jwt_methods=jwt_methods, response=response, user=user) + + +async def sign_in( + request: fastapi.Request, + response: fastapi.Response, + auth_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) + if ( + user is None or + not database_service.check_user_password(user=user, password=auth_data.password) + ): + raise HTTPNotAuthenticated() + + return generate_authorize_response(jwt_methods=jwt_methods, response=response, user=user) diff --git a/sapphire/users/api/rest/auth/oauth2/habr.py b/sapphire/users/api/rest/auth/oauth2/habr.py index cf590bda..53cf1b28 100644 --- a/sapphire/users/api/rest/auth/oauth2/habr.py +++ b/sapphire/users/api/rest/auth/oauth2/habr.py @@ -3,13 +3,12 @@ import fastapi from fastapi.responses import RedirectResponse -from sapphire.common.api.utils import set_cookie from sapphire.common.habr import HabrClient from sapphire.common.habr_career import HabrCareerClient from sapphire.common.jwt import JWTMethods from sapphire.users import cache, database, oauth2 from sapphire.users.api.rest.auth.schemas import AuthorizeResponse -from sapphire.users.api.rest.schemas import UserResponse +from sapphire.users.api.rest.auth.utils import generate_authorize_response router = fastapi.APIRouter() @@ -88,16 +87,4 @@ async def callback( ) db_user.activate() - access_token = jwt_methods.issue_access_token(db_user.id, is_activated=db_user.is_activated) - refresh_token = jwt_methods.issue_refresh_token(db_user.id, is_activated=db_user.is_activated) - - response = set_cookie(response=response, name="access_token", value=access_token, - expires=jwt_methods.access_token_expires_utc) - response = set_cookie(response=response, name="refresh_token", value=refresh_token, - expires=jwt_methods.refresh_token_expires_utc) - - return AuthorizeResponse( - user=UserResponse.from_db_model(user=db_user), - access_token=access_token, - refresh_token=refresh_token, - ) + return generate_authorize_response(jwt_methods=jwt_methods, response=response, user=db_user) diff --git a/sapphire/users/api/rest/auth/router.py b/sapphire/users/api/rest/auth/router.py index d2ac0fee..7dcce4c4 100644 --- a/sapphire/users/api/rest/auth/router.py +++ b/sapphire/users/api/rest/auth/router.py @@ -6,4 +6,6 @@ router.add_api_route(methods=["GET"], path="/check", endpoint=handlers.check) router.add_api_route(methods=["DELETE"], path="/logout", endpoint=handlers.logout) +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") diff --git a/sapphire/users/api/rest/auth/schemas.py b/sapphire/users/api/rest/auth/schemas.py index 07655bb9..1c66d362 100644 --- a/sapphire/users/api/rest/auth/schemas.py +++ b/sapphire/users/api/rest/auth/schemas.py @@ -1,8 +1,13 @@ -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr, constr from sapphire.users.api.rest.schemas import UserResponse +class AuthorizeRequest(BaseModel): + email: EmailStr + password: constr(pattern=r"^[\w\(\)\[\]\{\}\^\$\+\*@#%!&]{8,}$") + + class AuthorizeResponse(BaseModel): user: UserResponse access_token: str diff --git a/sapphire/users/api/rest/auth/utils.py b/sapphire/users/api/rest/auth/utils.py new file mode 100644 index 00000000..2d458d25 --- /dev/null +++ b/sapphire/users/api/rest/auth/utils.py @@ -0,0 +1,28 @@ +import fastapi + +from sapphire.common.api.utils import set_cookie +from sapphire.common.jwt.methods import JWTMethods +from sapphire.database.models import User +from sapphire.users.api.rest.schemas import UserResponse + +from .schemas import AuthorizeResponse + + +def generate_authorize_response( + jwt_methods: JWTMethods, + response: fastapi.Response, + user: User, +) -> AuthorizeResponse: + access_token = jwt_methods.issue_access_token(user.id, is_activated=user.is_activated) + refresh_token = jwt_methods.issue_refresh_token(user.id, is_activated=user.is_activated) + + response = set_cookie(response=response, name="access_token", value=access_token, + expires=jwt_methods.access_token_expires_utc) + response = set_cookie(response=response, name="refresh_token", value=refresh_token, + expires=jwt_methods.refresh_token_expires_utc) + + return AuthorizeResponse( + user=UserResponse.from_db_model(user=user), + access_token=access_token, + refresh_token=refresh_token, + ) diff --git a/sapphire/users/database/service.py b/sapphire/users/database/service.py index a010217c..77ad929c 100644 --- a/sapphire/users/database/service.py +++ b/sapphire/users/database/service.py @@ -1,6 +1,7 @@ import uuid from typing import Set, Type +import bcrypt from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -12,6 +13,15 @@ class Service(BaseDatabaseService): # pylint: disable=abstract-method + def hash_user_password(self, password: str) -> str: + hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + return hashed_password.decode("utf-8") + + def check_user_password(self, user: User, password: str) -> bool: + if user.password is None: + return False + return bcrypt.checkpw(password.encode("utf-8"), user.password.encode("utf-8")) + async def get_user( self, session: AsyncSession, @@ -34,6 +44,7 @@ async def update_user( self, session: AsyncSession, user: User, + password: str | Type[Empty] = Empty, first_name: str | None | Type[Empty] = Empty, last_name: str | None | Type[Empty] = Empty, avatar: str | None | Type[Empty] = Empty, @@ -41,6 +52,8 @@ async def update_user( main_specialization_id: uuid.UUID | None | Type[Empty] = Empty, secondary_specialization_id: uuid.UUID | None | Type[Empty] = Empty, ) -> User: + if password is not Empty: + user.password = self.hash_user_password(password) if first_name is not Empty: user.first_name = first_name if last_name is not Empty: @@ -61,6 +74,7 @@ async def create_user( self, session: AsyncSession, email: str, + password: str | None = None, first_name: str | None = None, last_name: str | None = None, ) -> User: @@ -68,6 +82,7 @@ async def create_user( email=email, first_name=first_name, last_name=last_name, + password=None if password is None else self.hash_user_password(password), ) profile = Profile(user=user) user.profile = profile