From f44e688662a7855e7b98ea7f3cbcfaaf2c66e682 Mon Sep 17 00:00:00 2001 From: doas root Date: Tue, 18 Nov 2025 00:25:24 +0300 Subject: [PATCH] feat(): migrate to auth identity --- .../adapters/data_gateways/auth_identity.py | 28 ++++++++++ .../adapters/data_gateways/tables.py | 36 +++++++++---- .../adapters/data_gateways/user.py | 13 +---- src/template_project/adapters/unit_of_work.py | 2 - .../application/access_token/__init__.py | 0 .../application/auth_identity/__init__.py | 0 .../application/auth_identity/data_gateway.py | 15 ++++++ .../application/auth_identity/entity.py | 40 ++++++++++++++ .../application/auth_identity/errors.py | 17 ++++++ .../auth_identity/interactors/__init__.py | 0 .../interactors/sign_up.py | 38 ++++++++----- .../application/user/data_gateway.py | 4 -- .../migrations/versions/f29dccaa6356_.py | 54 +++++++++++++++++++ src/template_project/web_api/entry_point.py | 4 +- .../web_api/ioc/connection.py | 2 +- .../web_api/ioc/data_gateway.py | 2 + .../web_api/ioc/interactor.py | 4 +- .../web_api/routes/{user.py => auth.py} | 8 +-- 18 files changed, 219 insertions(+), 48 deletions(-) create mode 100644 src/template_project/adapters/data_gateways/auth_identity.py create mode 100644 src/template_project/application/access_token/__init__.py create mode 100644 src/template_project/application/auth_identity/__init__.py create mode 100644 src/template_project/application/auth_identity/data_gateway.py create mode 100644 src/template_project/application/auth_identity/entity.py create mode 100644 src/template_project/application/auth_identity/errors.py create mode 100644 src/template_project/application/auth_identity/interactors/__init__.py rename src/template_project/application/{user => auth_identity}/interactors/sign_up.py (54%) create mode 100644 src/template_project/migrations/versions/f29dccaa6356_.py rename src/template_project/web_api/routes/{user.py => auth.py} (73%) diff --git a/src/template_project/adapters/data_gateways/auth_identity.py b/src/template_project/adapters/data_gateways/auth_identity.py new file mode 100644 index 0000000..5a6f33c --- /dev/null +++ b/src/template_project/adapters/data_gateways/auth_identity.py @@ -0,0 +1,28 @@ +from typing import override + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from template_project.adapters.data_gateways.tables import auth_identity_table +from template_project.application.auth_identity.data_gateway import AuthIdentityDataGateway +from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod +from template_project.application.user.entity import UserId + + +class DefaultAuthIdentityDataGateway(AuthIdentityDataGateway): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + @override + async def load_by_method_and_identifier(self, method: AuthMethod, identifier: str) -> AuthIdentity | None: + statement = select(AuthIdentity).where( + auth_identity_table.c.method == method, auth_identity_table.c.identifier == identifier + ) + result = await self._session.execute(statement) + return result.scalar_one_or_none() + + @override + async def load_all_for_user(self, user_id: UserId) -> list[AuthIdentity]: + statement = select(AuthIdentity).where(auth_identity_table.c.user_id == user_id) + result = await self._session.execute(statement) + return list(result.scalars().all()) diff --git a/src/template_project/adapters/data_gateways/tables.py b/src/template_project/adapters/data_gateways/tables.py index 324543c..433627e 100644 --- a/src/template_project/adapters/data_gateways/tables.py +++ b/src/template_project/adapters/data_gateways/tables.py @@ -1,23 +1,30 @@ -__all__ = ( - "access_token_table", - "meta_data", - "user_table", +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + MetaData, + String, + Table, + UniqueConstraint, ) - -from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, MetaData, String, Table +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import registry from template_project.application.access_token.entity import AccessToken +from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod from template_project.application.user.entity import User meta_data = MetaData() +mapper_registry = registry() user_table = Table( "users", meta_data, Column("id", UUID, primary_key=True), - Column("email", String, unique=True, nullable=False), - Column("hashed_password", String, nullable=False), + Column("email", String, unique=True, nullable=True), + Column("hashed_password", String, nullable=True), Column("deleted_at", DateTime(timezone=True)), Column("created_at", DateTime(timezone=True), nullable=False), ) @@ -33,7 +40,18 @@ access_token_table = Table( Column("created_at", DateTime(timezone=True), nullable=False), ) -mapper_registry = registry() +auth_identity_table = Table( + "auth_identities", + meta_data, + Column("id", UUID, primary_key=True), + Column("user_id", UUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + Column("method", Enum(AuthMethod, name="auth_method"), nullable=False), + Column("identifier", String, nullable=False), + Column("secret_key", String, nullable=True), + Column("created_at", DateTime(timezone=True), nullable=False), + UniqueConstraint("method", "identifier", name="uq_auth_method_identifier"), +) mapper_registry.map_imperatively(User, user_table) mapper_registry.map_imperatively(AccessToken, access_token_table) +mapper_registry.map_imperatively(AuthIdentity, auth_identity_table) diff --git a/src/template_project/adapters/data_gateways/user.py b/src/template_project/adapters/data_gateways/user.py index 346b3fc..b7483c6 100644 --- a/src/template_project/adapters/data_gateways/user.py +++ b/src/template_project/adapters/data_gateways/user.py @@ -1,6 +1,6 @@ -from typing import cast, override +from typing import override -from sqlalchemy import exists, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from template_project.adapters.data_gateways.tables import user_table @@ -17,12 +17,3 @@ class DefaultUserDataGateway(UserDataGateway): statement = select(User).where(user_table.c.id == id_) result = await self._session.execute(statement) return result.scalar_one_or_none() - - @override - async def exists_by_email(self, email: str) -> bool: - statement = select(exists(select(user_table).where(user_table.c.email == email))) - result = await self._session.execute(statement) - result_fetchone = result.fetchone() - if result_fetchone is None: - return False - return cast(bool, result_fetchone[0]) diff --git a/src/template_project/adapters/unit_of_work.py b/src/template_project/adapters/unit_of_work.py index 505cb2c..a37e6da 100644 --- a/src/template_project/adapters/unit_of_work.py +++ b/src/template_project/adapters/unit_of_work.py @@ -1,9 +1,7 @@ -import copy from typing import Any, override from sqlalchemy.ext.asyncio import AsyncSession -from template_project.application.common.entity import Entity from template_project.application.common.unit_of_work import UnitOfWork diff --git a/src/template_project/application/access_token/__init__.py b/src/template_project/application/access_token/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/application/auth_identity/__init__.py b/src/template_project/application/auth_identity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/application/auth_identity/data_gateway.py b/src/template_project/application/auth_identity/data_gateway.py new file mode 100644 index 0000000..6b183bb --- /dev/null +++ b/src/template_project/application/auth_identity/data_gateway.py @@ -0,0 +1,15 @@ +from abc import abstractmethod +from typing import Protocol + +from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod +from template_project.application.user.entity import UserId + + +class AuthIdentityDataGateway(Protocol): + @abstractmethod + async def load_by_method_and_identifier(self, method: AuthMethod, identifier: str) -> AuthIdentity | None: + raise NotImplementedError + + @abstractmethod + async def load_all_for_user(self, user_id: UserId) -> list[AuthIdentity]: + raise NotImplementedError diff --git a/src/template_project/application/auth_identity/entity.py b/src/template_project/application/auth_identity/entity.py new file mode 100644 index 0000000..90b8919 --- /dev/null +++ b/src/template_project/application/auth_identity/entity.py @@ -0,0 +1,40 @@ +from datetime import UTC, datetime +from enum import StrEnum +from typing import NewType, Self +from uuid import UUID + +from uuid_utils.compat import uuid7 + +from template_project.application.common.entity import Entity, to_entity +from template_project.application.user.entity import UserId + +AuthIdentityId = NewType("AuthIdentityId", UUID) + + +class AuthMethod(StrEnum): + EMAIL = "email" + + +@to_entity +class AuthIdentity(Entity[AuthIdentityId]): + user_id: UserId + method: AuthMethod + identifier: str + secret_key: str | None + + @classmethod + def factory( + cls, + user_id: UserId, + method: AuthMethod, + identifier: str, + secret_key: str | None = None, + ) -> Self: + return cls( + id=AuthIdentityId(uuid7()), + user_id=user_id, + method=method, + identifier=identifier, + secret_key=secret_key, + created_at=datetime.now(tz=UTC), + ) diff --git a/src/template_project/application/auth_identity/errors.py b/src/template_project/application/auth_identity/errors.py new file mode 100644 index 0000000..d883050 --- /dev/null +++ b/src/template_project/application/auth_identity/errors.py @@ -0,0 +1,17 @@ +from typing import TYPE_CHECKING, override + +from template_project.application.auth_identity.entity import AuthMethod +from template_project.application.common.errors import ApplicationError, to_error + +if TYPE_CHECKING: + pass + + +@to_error +class UserAlreadyExistsError(ApplicationError): + identifier: str + auth_method: AuthMethod + + @override + def __str__(self) -> str: + return f"User with identifier={self.identifier!r} and auth method={self.auth_method.value} already exists" diff --git a/src/template_project/application/auth_identity/interactors/__init__.py b/src/template_project/application/auth_identity/interactors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/application/user/interactors/sign_up.py b/src/template_project/application/auth_identity/interactors/sign_up.py similarity index 54% rename from src/template_project/application/user/interactors/sign_up.py rename to src/template_project/application/auth_identity/interactors/sign_up.py index de4f7c5..ead8492 100644 --- a/src/template_project/application/user/interactors/sign_up.py +++ b/src/template_project/application/auth_identity/interactors/sign_up.py @@ -1,36 +1,42 @@ from template_project.application.access_token.cryptographer import AccessTokenCryptographer from template_project.application.access_token.entity_factory import AccessTokenFactory +from template_project.application.auth_identity.data_gateway import AuthIdentityDataGateway +from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod +from template_project.application.auth_identity.errors import UserAlreadyExistsError from template_project.application.common.containers import SecretString from template_project.application.common.data_structure import to_data_structure from template_project.application.common.interactor import to_interactor from template_project.application.common.unit_of_work import UnitOfWork -from template_project.application.user.data_gateway import UserDataGateway -from template_project.application.user.entity import User -from template_project.application.user.errors import UserWithEmailAlreadyExistsError +from template_project.application.user.entity import User, UserId from template_project.application.user.password_utils import PasswordHasher @to_data_structure class UserSignUpResponse: + user_id: UserId access_token: str @to_interactor -class UserSignUpInteractor: +class AuthIdentityInteractor: unit_of_work: UnitOfWork password_hasher: PasswordHasher - user_data_gateway: UserDataGateway + auth_identity_data_gateway: AuthIdentityDataGateway access_token_factory: AccessTokenFactory access_token_cryptographer: AccessTokenCryptographer - async def execute( + async def sign_up_email( self, email: str, password: SecretString, ) -> UserSignUpResponse: - exists_by_email = await self.user_data_gateway.exists_by_email(email) - if exists_by_email: - raise UserWithEmailAlreadyExistsError(email=email) + existing_user = await self.auth_identity_data_gateway.load_by_method_and_identifier( + method=AuthMethod.EMAIL, + identifier=email, + ) + + if existing_user: + raise UserAlreadyExistsError(identifier=email, auth_method=AuthMethod.EMAIL) hashed_password = self.password_hasher.hash(password) @@ -38,14 +44,20 @@ class UserSignUpInteractor: email=email, hashed_password=hashed_password, ) + + auth_identity = AuthIdentity.factory( + user_id=user.id, + method=AuthMethod.EMAIL, + identifier=email, + secret_key=hashed_password, + ) + access_token = self.access_token_factory.execute(user.id) crypted_access_token = self.access_token_cryptographer.crypto(access_token.id) - response = UserSignUpResponse(access_token=crypted_access_token) - - for entity in (user, access_token): # preserve creation order + for entity in (user, auth_identity, access_token): await self.unit_of_work.add(entity) await self.unit_of_work.commit() - return response + return UserSignUpResponse(user_id=user.id, access_token=crypted_access_token) diff --git a/src/template_project/application/user/data_gateway.py b/src/template_project/application/user/data_gateway.py index 4a96ace..bab09e5 100644 --- a/src/template_project/application/user/data_gateway.py +++ b/src/template_project/application/user/data_gateway.py @@ -8,7 +8,3 @@ class UserDataGateway(Protocol): @abstractmethod async def load_with_id(self, id_: UserId) -> User | None: raise NotImplementedError - - @abstractmethod - async def exists_by_email(self, email: str) -> bool: - raise NotImplementedError diff --git a/src/template_project/migrations/versions/f29dccaa6356_.py b/src/template_project/migrations/versions/f29dccaa6356_.py new file mode 100644 index 0000000..7c78c8f --- /dev/null +++ b/src/template_project/migrations/versions/f29dccaa6356_.py @@ -0,0 +1,54 @@ +"""empty message + +Revision ID: f29dccaa6356 +Revises: ad80834713c3 +Create Date: 2025-11-18 00:14:48.550720 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f29dccaa6356' +down_revision: Union[str, Sequence[str], None] = 'ad80834713c3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('auth_identities', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('method', sa.Enum('EMAIL', name='auth_method'), nullable=False), + sa.Column('identifier', sa.String(), nullable=False), + sa.Column('secret_key', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('method', 'identifier', name='uq_auth_method_identifier') + ) + op.alter_column('users', 'email', + existing_type=sa.VARCHAR(), + nullable=True) + op.alter_column('users', 'hashed_password', + existing_type=sa.VARCHAR(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'hashed_password', + existing_type=sa.VARCHAR(), + nullable=False) + op.alter_column('users', 'email', + existing_type=sa.VARCHAR(), + nullable=False) + op.drop_table('auth_identities') + # ### end Alembic commands ### diff --git a/src/template_project/web_api/entry_point.py b/src/template_project/web_api/entry_point.py index 4ed8c51..ecb3931 100644 --- a/src/template_project/web_api/entry_point.py +++ b/src/template_project/web_api/entry_point.py @@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware from template_project.web_api.configuration import load_configuration from template_project.web_api.ioc.make import make_ioc -from template_project.web_api.routes import healthcheck, user +from template_project.web_api.routes import auth, healthcheck LOG_CONFIG: Final = { "version": 1, @@ -62,7 +62,7 @@ def make_asgi_application( allow_methods=["*"], allow_headers=["*"], ) - app.include_router(user.router) + app.include_router(auth.router) app.include_router(healthcheck.router) setup_dishka(container=ioc, app=app) diff --git a/src/template_project/web_api/ioc/connection.py b/src/template_project/web_api/ioc/connection.py index fb66eae..b7e4aec 100644 --- a/src/template_project/web_api/ioc/connection.py +++ b/src/template_project/web_api/ioc/connection.py @@ -17,7 +17,7 @@ class ConnectionProvider(Provider): async def async_session(self, engine: AsyncEngine) -> AsyncIterable[AsyncSession]: session = AsyncSession( bind=engine, - expire_on_commit=True, + expire_on_commit=False, ) async with session: yield session diff --git a/src/template_project/web_api/ioc/data_gateway.py b/src/template_project/web_api/ioc/data_gateway.py index dec7533..f5d2724 100644 --- a/src/template_project/web_api/ioc/data_gateway.py +++ b/src/template_project/web_api/ioc/data_gateway.py @@ -1,6 +1,7 @@ from dishka import BaseScope, Provider, Scope, WithParents, provide, provide_all from template_project.adapters.data_gateways.access_token import DefaultAccessTokenDataGateway +from template_project.adapters.data_gateways.auth_identity import DefaultAuthIdentityDataGateway from template_project.adapters.data_gateways.user import DefaultUserDataGateway from template_project.adapters.unit_of_work import DefaultUnitOfWork @@ -12,4 +13,5 @@ class DataGatewayProvider(Provider): data_gateways = provide_all( WithParents[DefaultUserDataGateway], WithParents[DefaultAccessTokenDataGateway], + WithParents[DefaultAuthIdentityDataGateway], ) diff --git a/src/template_project/web_api/ioc/interactor.py b/src/template_project/web_api/ioc/interactor.py index 87b3f3d..24c51b6 100644 --- a/src/template_project/web_api/ioc/interactor.py +++ b/src/template_project/web_api/ioc/interactor.py @@ -1,11 +1,11 @@ from dishka import BaseScope, Provider, Scope, provide_all -from template_project.application.user.interactors.sign_up import UserSignUpInteractor +from template_project.application.auth_identity.interactors.sign_up import AuthIdentityInteractor class InteractorProvider(Provider): scope: BaseScope | None = Scope.REQUEST interactors = provide_all( - UserSignUpInteractor, + AuthIdentityInteractor, ) diff --git a/src/template_project/web_api/routes/user.py b/src/template_project/web_api/routes/auth.py similarity index 73% rename from src/template_project/web_api/routes/user.py rename to src/template_project/web_api/routes/auth.py index cc98a95..f999ab7 100644 --- a/src/template_project/web_api/routes/user.py +++ b/src/template_project/web_api/routes/auth.py @@ -3,8 +3,8 @@ from dishka.integrations.fastapi import DishkaRoute from fastapi import APIRouter from pydantic import BaseModel, SecretStr +from template_project.application.auth_identity.interactors.sign_up import AuthIdentityInteractor from template_project.application.common.containers import SecretString -from template_project.application.user.interactors.sign_up import UserSignUpInteractor router = APIRouter(route_class=DishkaRoute) @@ -18,12 +18,12 @@ class UserSignUpResponse(BaseModel): access_token: str -@router.post("/user/sign_up") +@router.post("/auth/sign_up/email") async def sign_up( request: UserSignUpRequest, - interactor: FromDishka[UserSignUpInteractor], + interactor: FromDishka[AuthIdentityInteractor], ) -> UserSignUpResponse: - response_interactor = await interactor.execute( + response_interactor = await interactor.sign_up_email( email=request.email, password=SecretString(request.password.get_secret_value()) ) return UserSignUpResponse(