Skip to content

Commit

Permalink
Split User into Account and PasswordUser
Browse files Browse the repository at this point in the history
  • Loading branch information
florimondmanca committed Aug 3, 2022
1 parent c5fc42e commit 1f801ec
Show file tree
Hide file tree
Showing 35 changed files with 520 additions and 277 deletions.
12 changes: 6 additions & 6 deletions server/api/auth/backends/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
)
from starlette.requests import HTTPConnection

from server.application.auth.queries import GetUserByAPIToken
from server.application.auth.queries import GetAccountByAPIToken
from server.config.di import resolve
from server.domain.auth.exceptions import UserDoesNotExist
from server.domain.auth.exceptions import AccountDoesNotExist
from server.seedwork.application.messages import MessageBus

from ..models import ApiUser
Expand Down Expand Up @@ -44,11 +44,11 @@ async def authenticate(

bus = resolve(MessageBus)

query = GetUserByAPIToken(api_token=api_token)
query = GetAccountByAPIToken(api_token=api_token)

try:
user = await bus.execute(query)
except UserDoesNotExist:
account = await bus.execute(query)
except AccountDoesNotExist:
raise AuthenticationError()

return AuthCredentials(scopes=["authenticated"]), ApiUser(user)
return AuthCredentials(scopes=["authenticated"]), ApiUser(account)
18 changes: 9 additions & 9 deletions server/api/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,29 @@

from starlette.authentication import BaseUser

from server.application.auth.views import UserView
from server.application.auth.views import AccountView


class ApiUser(BaseUser):
def __init__(self, user: Optional[UserView]) -> None:
self._user = user
def __init__(self, account: Optional[AccountView]) -> None:
self._account = account

@property
def obj(self) -> UserView:
if self._user is None:
def account(self) -> AccountView:
if self._account is None:
raise RuntimeError(
"Cannot access .obj, as the user is anonymous. "
"Cannot access .account, as the user is anonymous. "
"Hint: did you forget to check for .is_authenticated?"
)

return self._user
return self._account

# Implement the 'BaseUser' interface.

@property
def is_authenticated(self) -> bool:
return self._user is not None
return self._account is not None

@property
def display_name(self) -> str:
return self._user.email if self._user is not None else ""
return self._account.email if self._account is not None else ""
2 changes: 1 addition & 1 deletion server/api/auth/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def has_permission(self, request: APIRequest) -> bool:
"Hint: use IsAuthenticated() & HasRole(...)"
)

return request.user.obj.role in self._roles
return request.user.account.role in self._roles


def _patch_openapi_security_params(*permissions: BasePermission) -> Callable:
Expand Down
26 changes: 13 additions & 13 deletions server/api/auth/routes.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException

from server.application.auth.commands import CreateUser, DeleteUser
from server.application.auth.queries import GetUserByEmail, Login
from server.application.auth.views import AuthenticatedUserView, UserView
from server.application.auth.commands import CreatePasswordUser, DeletePasswordUser
from server.application.auth.queries import GetAccountByEmail, LoginPasswordUser
from server.application.auth.views import AccountView, AuthenticatedAccountView
from server.config.di import resolve
from server.domain.auth.entities import UserRole
from server.domain.auth.exceptions import EmailAlreadyExists, LoginFailed
from server.domain.common.types import ID
from server.seedwork.application.messages import MessageBus

from .permissions import HasRole, IsAuthenticated
from .schemas import CheckAuthResponse, UserCreate, UserLogin
from .schemas import CheckAuthResponse, PasswordUserCreate, PasswordUserLogin

router = APIRouter(prefix="/auth", tags=["auth"])


@router.post(
"/users/",
dependencies=[Depends(IsAuthenticated() & HasRole(UserRole.ADMIN))],
response_model=UserView,
response_model=AccountView,
status_code=201,
)
async def create_user(data: UserCreate) -> UserView:
async def create_password_user(data: PasswordUserCreate) -> AccountView:
bus = resolve(MessageBus)

command = CreateUser(email=data.email, password=data.password)
command = CreatePasswordUser(email=data.email, password=data.password)

try:
await bus.execute(command)
except EmailAlreadyExists as exc:
raise HTTPException(400, detail=str(exc))

query = GetUserByEmail(email=data.email)
query = GetAccountByEmail(email=data.email)
return await bus.execute(query)


@router.post("/login/", response_model=AuthenticatedUserView)
async def login(data: UserLogin) -> AuthenticatedUserView:
@router.post("/login/", response_model=AuthenticatedAccountView)
async def login_password_user(data: PasswordUserLogin) -> AuthenticatedAccountView:
bus = resolve(MessageBus)

query = Login(email=data.email, password=data.password)
query = LoginPasswordUser(email=data.email, password=data.password)

try:
return await bus.execute(query)
Expand All @@ -52,10 +52,10 @@ async def login(data: UserLogin) -> AuthenticatedUserView:
dependencies=[Depends(IsAuthenticated() & HasRole(UserRole.ADMIN))],
status_code=204,
)
async def delete_user(id: ID) -> None:
async def delete_password_user(id: ID) -> None:
bus = resolve(MessageBus)

command = DeleteUser(id=id)
command = DeletePasswordUser(account_id=id)
await bus.execute(command)


Expand Down
4 changes: 2 additions & 2 deletions server/api/auth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
from pydantic import BaseModel, EmailStr, SecretStr


class UserCreate(BaseModel):
class PasswordUserCreate(BaseModel):
email: EmailStr
password: SecretStr


class UserLogin(BaseModel):
class PasswordUserLogin(BaseModel):
email: EmailStr
password: SecretStr

Expand Down
6 changes: 3 additions & 3 deletions server/application/auth/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
from server.seedwork.application.commands import Command


class CreateUser(Command[ID]):
class CreatePasswordUser(Command[ID]):
organization_siret: Siret = LEGACY_ORGANIZATION_SIRET
email: EmailStr
password: SecretStr


class DeleteUser(Command[None]):
id: ID
class DeletePasswordUser(Command[None]):
account_id: ID


class ChangePassword(Command[None]):
Expand Down
91 changes: 49 additions & 42 deletions server/application/auth/handlers.py
Original file line number Diff line number Diff line change
@@ -1,106 +1,113 @@
from server.application.auth.views import AuthenticatedUserView, UserView
from server.application.auth.views import AccountView, AuthenticatedAccountView
from server.config.di import resolve
from server.domain.auth.entities import User, UserRole
from server.domain.auth.entities import Account, PasswordUser, UserRole
from server.domain.auth.exceptions import (
AccountDoesNotExist,
EmailAlreadyExists,
LoginFailed,
UserDoesNotExist,
)
from server.domain.auth.repositories import UserRepository
from server.domain.auth.repositories import AccountRepository, PasswordUserRepository
from server.domain.common.types import ID

from .commands import ChangePassword, CreateUser, DeleteUser
from .commands import ChangePassword, CreatePasswordUser, DeletePasswordUser
from .passwords import PasswordEncoder, generate_api_token
from .queries import GetUserByAPIToken, GetUserByEmail, Login
from .queries import GetAccountByAPIToken, GetAccountByEmail, LoginPasswordUser


async def create_user(
command: CreateUser, *, id_: ID = None, role: UserRole = UserRole.USER
async def create_password_user(
command: CreatePasswordUser, *, id_: ID = None, role: UserRole = UserRole.USER
) -> ID:
repository = resolve(UserRepository)
repository = resolve(PasswordUserRepository)
password_encoder = resolve(PasswordEncoder)

if id_ is None:
id_ = repository.make_id()

email = command.email

user = await repository.get_by_email(email)
password_user = await repository.get_by_email(email)

if user is not None:
if password_user is not None:
raise EmailAlreadyExists(email)

password_hash = password_encoder.hash(command.password)
api_token = generate_api_token()

user = User(
account = Account(
id=id_,
organization_siret=command.organization_siret,
email=email,
password_hash=password_hash,
role=role,
api_token=api_token,
)

return await repository.insert(user)
password_user = PasswordUser(
account_id=id_,
account=account,
password_hash=password_hash,
)

return await repository.insert(password_user)


async def delete_user(command: DeleteUser) -> None:
repository = resolve(UserRepository)
await repository.delete(command.id)
async def delete_password_user(command: DeletePasswordUser) -> None:
repository = resolve(PasswordUserRepository)
await repository.delete(command.account_id)


async def login(query: Login) -> AuthenticatedUserView:
repository = resolve(UserRepository)
async def login_password_user(query: LoginPasswordUser) -> AuthenticatedAccountView:
repository = resolve(PasswordUserRepository)
password_encoder = resolve(PasswordEncoder)

user = await repository.get_by_email(query.email)
password_user = await repository.get_by_email(query.email)

if user is None:
if password_user is None:
password_encoder.hash(query.password) # Mitigate timing attacks.
raise LoginFailed("Invalid credentials")

if not password_encoder.verify(password=query.password, hash=user.password_hash):
if not password_encoder.verify(
password=query.password, hash=password_user.password_hash
):
raise LoginFailed("Invalid credentials")

return AuthenticatedUserView(**user.dict())
return AuthenticatedAccountView(**password_user.account.dict())


async def get_user_by_email(query: GetUserByEmail) -> UserView:
repository = resolve(UserRepository)
async def get_account_by_email(query: GetAccountByEmail) -> AccountView:
repository = resolve(AccountRepository)

email = query.email

user = await repository.get_by_email(email)
account = await repository.get_by_email(email)

if user is None:
raise UserDoesNotExist(email)
if account is None:
raise AccountDoesNotExist(email)

return UserView(**user.dict())
return AccountView(**account.dict())


async def get_user_by_api_token(query: GetUserByAPIToken) -> UserView:
repository = resolve(UserRepository)
async def get_account_by_api_token(query: GetAccountByAPIToken) -> AccountView:
repository = resolve(AccountRepository)

user = await repository.get_by_api_token(query.api_token)
account = await repository.get_by_api_token(query.api_token)

if user is None:
raise UserDoesNotExist("__token__")
if account is None:
raise AccountDoesNotExist("__token__")

return UserView(**user.dict())
return AccountView(**account.dict())


async def change_password(command: ChangePassword) -> None:
repository = resolve(UserRepository)
repository = resolve(PasswordUserRepository)
password_encoder = resolve(PasswordEncoder)

email = command.email
user = await repository.get_by_email(email)
password_user = await repository.get_by_email(email)

if user is None:
raise UserDoesNotExist(email)
if password_user is None:
raise AccountDoesNotExist(email)

user.update_password(password_encoder.hash(command.password))
user.update_api_token(generate_api_token()) # Require new login
password_user.update_password(password_encoder.hash(command.password))
password_user.account.update_api_token(generate_api_token()) # Require new login

await repository.update(user)
await repository.update(password_user)
8 changes: 4 additions & 4 deletions server/application/auth/queries.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
from pydantic import EmailStr, SecretStr

from server.application.auth.views import AuthenticatedUserView, UserView
from server.application.auth.views import AccountView, AuthenticatedAccountView
from server.seedwork.application.queries import Query


class Login(Query[AuthenticatedUserView]):
class LoginPasswordUser(Query[AuthenticatedAccountView]):
email: EmailStr
password: SecretStr


class GetUserByEmail(Query[UserView]):
class GetAccountByEmail(Query[AccountView]):
email: EmailStr


class GetUserByAPIToken(Query[UserView]):
class GetAccountByAPIToken(Query[AccountView]):
api_token: str
4 changes: 2 additions & 2 deletions server/application/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
from server.domain.organizations.types import Siret


class UserView(BaseModel):
class AccountView(BaseModel):
id: ID
organization_siret: Siret
email: str
role: UserRole


class AuthenticatedUserView(BaseModel):
class AuthenticatedAccountView(BaseModel):
id: ID
organization_siret: Siret
email: str
Expand Down
Loading

0 comments on commit 1f801ec

Please sign in to comment.