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

Commit

Permalink
Implement email authorization
Browse files Browse the repository at this point in the history
  • Loading branch information
OlegYurchik committed Mar 13, 2024
1 parent c81388c commit dcb9125
Show file tree
Hide file tree
Showing 13 changed files with 230 additions and 19 deletions.
21 changes: 20 additions & 1 deletion autotests/clients/rest/users/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}"

Expand Down
11 changes: 11 additions & 0 deletions autotests/clients/rest/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 45 additions & 0 deletions autotests/flow/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
42 changes: 41 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
1 change: 1 addition & 0 deletions sapphire/database/migrations/versions/891640b6839e_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion sapphire/database/models/users.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions sapphire/users/api/rest/auth/handlers.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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)
17 changes: 2 additions & 15 deletions sapphire/users/api/rest/auth/oauth2/habr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)
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 @@ -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")
7 changes: 6 additions & 1 deletion sapphire/users/api/rest/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
28 changes: 28 additions & 0 deletions sapphire/users/api/rest/auth/utils.py
Original file line number Diff line number Diff line change
@@ -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,
)
15 changes: 15 additions & 0 deletions sapphire/users/database/service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import uuid
from typing import Set, Type

import bcrypt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

Expand All @@ -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,
Expand All @@ -34,13 +44,16 @@ 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,
about: str | None | Type[Empty] = Empty,
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:
Expand All @@ -61,13 +74,15 @@ async def create_user(
self,
session: AsyncSession,
email: str,
password: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
) -> User:
user = 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
Expand Down

0 comments on commit dcb9125

Please sign in to comment.