feat(): migrate to auth identity

This commit is contained in:
doas root
2025-11-18 00:25:24 +03:00
parent 63a057b020
commit f44e688662
18 changed files with 219 additions and 48 deletions
@@ -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())
@@ -1,23 +1,30 @@
__all__ = ( from sqlalchemy import (
"access_token_table", Boolean,
"meta_data", Column,
"user_table", DateTime,
Enum,
ForeignKey,
MetaData,
String,
Table,
UniqueConstraint,
) )
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import UUID, Boolean, Column, DateTime, ForeignKey, MetaData, String, Table
from sqlalchemy.orm import registry from sqlalchemy.orm import registry
from template_project.application.access_token.entity import AccessToken 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 from template_project.application.user.entity import User
meta_data = MetaData() meta_data = MetaData()
mapper_registry = registry()
user_table = Table( user_table = Table(
"users", "users",
meta_data, meta_data,
Column("id", UUID, primary_key=True), Column("id", UUID, primary_key=True),
Column("email", String, unique=True, nullable=False), Column("email", String, unique=True, nullable=True),
Column("hashed_password", String, nullable=False), Column("hashed_password", String, nullable=True),
Column("deleted_at", DateTime(timezone=True)), Column("deleted_at", DateTime(timezone=True)),
Column("created_at", DateTime(timezone=True), nullable=False), Column("created_at", DateTime(timezone=True), nullable=False),
) )
@@ -33,7 +40,18 @@ access_token_table = Table(
Column("created_at", DateTime(timezone=True), nullable=False), 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(User, user_table)
mapper_registry.map_imperatively(AccessToken, access_token_table) mapper_registry.map_imperatively(AccessToken, access_token_table)
mapper_registry.map_imperatively(AuthIdentity, auth_identity_table)
@@ -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 sqlalchemy.ext.asyncio import AsyncSession
from template_project.adapters.data_gateways.tables import user_table 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_) statement = select(User).where(user_table.c.id == id_)
result = await self._session.execute(statement) result = await self._session.execute(statement)
return result.scalar_one_or_none() 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])
@@ -1,9 +1,7 @@
import copy
from typing import Any, override from typing import Any, override
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from template_project.application.common.entity import Entity
from template_project.application.common.unit_of_work import UnitOfWork from template_project.application.common.unit_of_work import UnitOfWork
@@ -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
@@ -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),
)
@@ -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"
@@ -1,36 +1,42 @@
from template_project.application.access_token.cryptographer import AccessTokenCryptographer from template_project.application.access_token.cryptographer import AccessTokenCryptographer
from template_project.application.access_token.entity_factory import AccessTokenFactory 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.containers import SecretString
from template_project.application.common.data_structure import to_data_structure 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.interactor import to_interactor
from template_project.application.common.unit_of_work import UnitOfWork 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, UserId
from template_project.application.user.entity import User
from template_project.application.user.errors import UserWithEmailAlreadyExistsError
from template_project.application.user.password_utils import PasswordHasher from template_project.application.user.password_utils import PasswordHasher
@to_data_structure @to_data_structure
class UserSignUpResponse: class UserSignUpResponse:
user_id: UserId
access_token: str access_token: str
@to_interactor @to_interactor
class UserSignUpInteractor: class AuthIdentityInteractor:
unit_of_work: UnitOfWork unit_of_work: UnitOfWork
password_hasher: PasswordHasher password_hasher: PasswordHasher
user_data_gateway: UserDataGateway auth_identity_data_gateway: AuthIdentityDataGateway
access_token_factory: AccessTokenFactory access_token_factory: AccessTokenFactory
access_token_cryptographer: AccessTokenCryptographer access_token_cryptographer: AccessTokenCryptographer
async def execute( async def sign_up_email(
self, self,
email: str, email: str,
password: SecretString, password: SecretString,
) -> UserSignUpResponse: ) -> UserSignUpResponse:
exists_by_email = await self.user_data_gateway.exists_by_email(email) existing_user = await self.auth_identity_data_gateway.load_by_method_and_identifier(
if exists_by_email: method=AuthMethod.EMAIL,
raise UserWithEmailAlreadyExistsError(email=email) identifier=email,
)
if existing_user:
raise UserAlreadyExistsError(identifier=email, auth_method=AuthMethod.EMAIL)
hashed_password = self.password_hasher.hash(password) hashed_password = self.password_hasher.hash(password)
@@ -38,14 +44,20 @@ class UserSignUpInteractor:
email=email, email=email,
hashed_password=hashed_password, 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) access_token = self.access_token_factory.execute(user.id)
crypted_access_token = self.access_token_cryptographer.crypto(access_token.id) crypted_access_token = self.access_token_cryptographer.crypto(access_token.id)
response = UserSignUpResponse(access_token=crypted_access_token) for entity in (user, auth_identity, access_token):
for entity in (user, access_token): # preserve creation order
await self.unit_of_work.add(entity) await self.unit_of_work.add(entity)
await self.unit_of_work.commit() await self.unit_of_work.commit()
return response return UserSignUpResponse(user_id=user.id, access_token=crypted_access_token)
@@ -8,7 +8,3 @@ class UserDataGateway(Protocol):
@abstractmethod @abstractmethod
async def load_with_id(self, id_: UserId) -> User | None: async def load_with_id(self, id_: UserId) -> User | None:
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def exists_by_email(self, email: str) -> bool:
raise NotImplementedError
@@ -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 ###
+2 -2
View File
@@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
from template_project.web_api.configuration import load_configuration from template_project.web_api.configuration import load_configuration
from template_project.web_api.ioc.make import make_ioc 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 = { LOG_CONFIG: Final = {
"version": 1, "version": 1,
@@ -62,7 +62,7 @@ def make_asgi_application(
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(user.router) app.include_router(auth.router)
app.include_router(healthcheck.router) app.include_router(healthcheck.router)
setup_dishka(container=ioc, app=app) setup_dishka(container=ioc, app=app)
@@ -17,7 +17,7 @@ class ConnectionProvider(Provider):
async def async_session(self, engine: AsyncEngine) -> AsyncIterable[AsyncSession]: async def async_session(self, engine: AsyncEngine) -> AsyncIterable[AsyncSession]:
session = AsyncSession( session = AsyncSession(
bind=engine, bind=engine,
expire_on_commit=True, expire_on_commit=False,
) )
async with session: async with session:
yield session yield session
@@ -1,6 +1,7 @@
from dishka import BaseScope, Provider, Scope, WithParents, provide, provide_all 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.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.data_gateways.user import DefaultUserDataGateway
from template_project.adapters.unit_of_work import DefaultUnitOfWork from template_project.adapters.unit_of_work import DefaultUnitOfWork
@@ -12,4 +13,5 @@ class DataGatewayProvider(Provider):
data_gateways = provide_all( data_gateways = provide_all(
WithParents[DefaultUserDataGateway], WithParents[DefaultUserDataGateway],
WithParents[DefaultAccessTokenDataGateway], WithParents[DefaultAccessTokenDataGateway],
WithParents[DefaultAuthIdentityDataGateway],
) )
@@ -1,11 +1,11 @@
from dishka import BaseScope, Provider, Scope, provide_all 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): class InteractorProvider(Provider):
scope: BaseScope | None = Scope.REQUEST scope: BaseScope | None = Scope.REQUEST
interactors = provide_all( interactors = provide_all(
UserSignUpInteractor, AuthIdentityInteractor,
) )
@@ -3,8 +3,8 @@ from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel, SecretStr 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.common.containers import SecretString
from template_project.application.user.interactors.sign_up import UserSignUpInteractor
router = APIRouter(route_class=DishkaRoute) router = APIRouter(route_class=DishkaRoute)
@@ -18,12 +18,12 @@ class UserSignUpResponse(BaseModel):
access_token: str access_token: str
@router.post("/user/sign_up") @router.post("/auth/sign_up/email")
async def sign_up( async def sign_up(
request: UserSignUpRequest, request: UserSignUpRequest,
interactor: FromDishka[UserSignUpInteractor], interactor: FromDishka[AuthIdentityInteractor],
) -> UserSignUpResponse: ) -> UserSignUpResponse:
response_interactor = await interactor.execute( response_interactor = await interactor.sign_up_email(
email=request.email, password=SecretString(request.password.get_secret_value()) email=request.email, password=SecretString(request.password.get_secret_value())
) )
return UserSignUpResponse( return UserSignUpResponse(