You've already forked RekomenciBackend
feat(): migrate to auth identity
This commit is contained in:
@@ -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__ = (
|
||||
"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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
+25
-13
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+4
-4
@@ -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(
|
||||
Reference in New Issue
Block a user