diff --git a/api/app_builder/dependencies.py b/api/app_builder/dependencies.py index dd1faf1..17da4de 100644 --- a/api/app_builder/dependencies.py +++ b/api/app_builder/dependencies.py @@ -1,17 +1,22 @@ from fastapi import FastAPI -from sqlalchemy.ext.asyncio import (AsyncEngine, AsyncSession, - async_sessionmaker) +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from api.application.abstractions.uow import UnitOfWork +from api.application.protocols.date_time import DateTimeProvider from api.application.protocols.password_hasher import PasswordHasher from api.application.usecase.auth.create_user import CreateUser from api.domain.user.repository import UserRepository -from api.infrastructure.dependencies.adapters import (create_engine, - create_session_maker, - new_session, - new_unit_of_work) +from api.infrastructure.dependencies.adapters import ( + create_engine, + create_session_maker, + new_session, + new_unit_of_work, +) from api.infrastructure.dependencies.configs import app_settings -from api.infrastructure.dependencies.protocols import get_password_hasher +from api.infrastructure.dependencies.protocols import ( + get_date_time_provider, + get_password_hasher, +) from api.infrastructure.dependencies.repositories import get_user_repository from api.infrastructure.dependencies.usecases import provide_create_user from api.infrastructure.settings import Settings @@ -26,6 +31,7 @@ def init_dependencies(app: FastAPI) -> None: app.dependency_overrides[UnitOfWork] = new_unit_of_work + app.dependency_overrides[DateTimeProvider] = get_date_time_provider app.dependency_overrides[PasswordHasher] = get_password_hasher app.dependency_overrides[UserRepository] = get_user_repository diff --git a/api/app_builder/routers.py b/api/app_builder/routers.py index e326c71..ac62dbf 100644 --- a/api/app_builder/routers.py +++ b/api/app_builder/routers.py @@ -1,7 +1,6 @@ from fastapi import FastAPI -from api.presentation.routers import (auth_router, healthcheck_router, - user_router) +from api.presentation.routers import auth_router, healthcheck_router, user_router def init_routers(app: FastAPI) -> None: diff --git a/api/application/contracts/auth/__init__.py b/api/application/contracts/auth/__init__.py index 81cd9cd..0518d37 100644 --- a/api/application/contracts/auth/__init__.py +++ b/api/application/contracts/auth/__init__.py @@ -1,3 +1,3 @@ -from .auth_request import UserCreateRequest +from .auth_request import LoginRequest, UserCreateRequest -__all__ = ("UserCreateRequest",) +__all__ = ("UserCreateRequest", "LoginRequest") diff --git a/api/application/contracts/auth/auth_request.py b/api/application/contracts/auth/auth_request.py index aeae1b7..e1b9592 100644 --- a/api/application/contracts/auth/auth_request.py +++ b/api/application/contracts/auth/auth_request.py @@ -6,3 +6,9 @@ class UserCreateRequest: name: str email: str password: str + + +@dataclass(frozen=True) +class LoginRequest: + email: str + password: str diff --git a/api/application/contracts/auth/auth_response.py b/api/application/contracts/auth/auth_response.py new file mode 100644 index 0000000..d201705 --- /dev/null +++ b/api/application/contracts/auth/auth_response.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class AuthenticationResponse: + id: UUID + name: str + email: str diff --git a/api/application/protocols/date_time.py b/api/application/protocols/date_time.py new file mode 100644 index 0000000..b7f6b17 --- /dev/null +++ b/api/application/protocols/date_time.py @@ -0,0 +1,7 @@ +from datetime import datetime +from typing import Protocol + + +class DateTimeProvider(Protocol): + def get_current_time(self) -> datetime: + raise NotImplementedError diff --git a/api/application/protocols/jwt.py b/api/application/protocols/jwt.py new file mode 100644 index 0000000..35652e8 --- /dev/null +++ b/api/application/protocols/jwt.py @@ -0,0 +1,11 @@ +from typing import Protocol + +from api.domain.user.model import UserId + + +class JwtTokenProcessor(Protocol): + def generate_token(self, user_id: UserId) -> str: + raise NotImplementedError + + def validate_token(self, token: str) -> UserId | None: + raise NotImplementedError diff --git a/api/application/usecase/auth/auth_user.py b/api/application/usecase/auth/auth_user.py new file mode 100644 index 0000000..a07b4ad --- /dev/null +++ b/api/application/usecase/auth/auth_user.py @@ -0,0 +1,30 @@ +from api.application.contracts.auth.auth_request import LoginRequest +from api.application.contracts.auth.auth_response import AuthenticationResponse +from api.application.protocols.password_hasher import PasswordHasher +from api.domain.user.error import UserInvalidCredentialsError +from api.domain.user.repository import UserRepository + + +class LoginUser: + def __init__( + self, + user_repository: UserRepository, + password_hasher: PasswordHasher, + ) -> None: + self.user_repository = user_repository + self.hasher = password_hasher + + async def __call__(self, request: LoginRequest) -> AuthenticationResponse: + user = await self.user_repository.get_user(filter={"email": request.email}) + error = UserInvalidCredentialsError("Email or password is incorrect") + if user is None: + raise error + + if not self.hasher.verify_password(request.password, user.hashed_password): + raise error + + return AuthenticationResponse( + id=user.id.value, + name=user.name.value, + email=user.email.value, + ) diff --git a/api/infrastructure/auth/__init__.py b/api/infrastructure/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/infrastructure/auth/jwt_processor.py b/api/infrastructure/auth/jwt_processor.py new file mode 100644 index 0000000..2e04fe3 --- /dev/null +++ b/api/infrastructure/auth/jwt_processor.py @@ -0,0 +1,37 @@ +from datetime import timedelta +from uuid import UUID + +from jose import JWTError +from jose.jwt import decode, encode + +from api.application.protocols.date_time import DateTimeProvider +from api.application.protocols.jwt import JwtTokenProcessor +from api.domain.user.model import UserId +from api.infrastructure.auth.jwt_settings import JwtSettings + + +class JoseJwtTokenProcessor(JwtTokenProcessor): + def __init__(self, jwt_options: JwtSettings, date_time_provider: DateTimeProvider) -> None: + self.jwt_options = jwt_options + self.date_time_provider = date_time_provider + + def generate_token(self, user_id: UserId) -> str: + issued_at = self.date_time_provider.get_current_time() + expiration_time = issued_at + timedelta(hours=self.jwt_options.expires_in) + + claims = { + "iat": issued_at, + "exp": expiration_time, + "sub": str(user_id.value), + } + + return encode(claims, self.jwt_options.secret, self.jwt_options.algorithm) + + def validate_token(self, token: str) -> UserId | None: + try: + payload = decode(token, self.jwt_options.secret, [self.jwt_options.algorithm]) + + return UserId(UUID(payload["sub"])) + + except (JWTError, ValueError, KeyError): + return None diff --git a/api/infrastructure/auth/jwt_settings.py b/api/infrastructure/auth/jwt_settings.py new file mode 100644 index 0000000..62ae7ff --- /dev/null +++ b/api/infrastructure/auth/jwt_settings.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class JwtSettings: + secret: str + expires_in: int = field(default=2) + algorithm: str = field(default="HS256") diff --git a/api/infrastructure/date_time.py b/api/infrastructure/date_time.py new file mode 100644 index 0000000..ec1afe7 --- /dev/null +++ b/api/infrastructure/date_time.py @@ -0,0 +1,26 @@ +from datetime import datetime, timedelta, timezone +from enum import Enum + +from api.application.protocols.date_time import DateTimeProvider + + +class Timezone(Enum): + UTC = timezone.utc + GMT = timezone(timedelta(hours=0)) + CET = timezone(timedelta(hours=1)) + EET = timezone(timedelta(hours=2)) + MSK = timezone(timedelta(hours=3)) + IST = timezone(timedelta(hours=5, minutes=30)) + WIB = timezone(timedelta(hours=7)) + CST = timezone(timedelta(hours=8)) + JST = timezone(timedelta(hours=9)) + AEST = timezone(timedelta(hours=10)) + NZST = timezone(timedelta(hours=12)) + + +class SystemDateTimeProvider(DateTimeProvider): + def __init__(self, tz: Timezone) -> None: + self.tz = tz + + def get_current_time(self) -> datetime: + return datetime.now(tz=self.tz.value) diff --git a/api/infrastructure/dependencies/configs.py b/api/infrastructure/dependencies/configs.py index 9979b06..bb73885 100644 --- a/api/infrastructure/dependencies/configs.py +++ b/api/infrastructure/dependencies/configs.py @@ -3,6 +3,7 @@ from functools import lru_cache import yaml # type: ignore +from api.infrastructure.auth.jwt_settings import JwtSettings from api.infrastructure.persistence.db_setings import DBSettings from api.infrastructure.settings import Settings @@ -27,4 +28,9 @@ def app_settings() -> Settings: pg_port=int(config_data["db"]["port"]), pg_db=config_data["db"]["database"], ), + jwt=JwtSettings( + secret=config_data["jwt"]["secret_key"], + expires_in=int(config_data["jwt"]["expires_in"]), + algorithm=config_data["jwt"]["algorithm"], + ), ) diff --git a/api/infrastructure/dependencies/protocols.py b/api/infrastructure/dependencies/protocols.py index 8406257..873dc17 100644 --- a/api/infrastructure/dependencies/protocols.py +++ b/api/infrastructure/dependencies/protocols.py @@ -1,6 +1,12 @@ +from api.application.protocols.date_time import DateTimeProvider from api.application.protocols.password_hasher import PasswordHasher +from api.infrastructure.date_time import SystemDateTimeProvider, Timezone from api.infrastructure.security.password_hasher import Pbkdf2PasswordHasher def get_password_hasher() -> PasswordHasher: return Pbkdf2PasswordHasher() + + +def get_date_time_provider() -> DateTimeProvider: + return SystemDateTimeProvider(Timezone.UTC) diff --git a/api/infrastructure/dependencies/usecases.py b/api/infrastructure/dependencies/usecases.py index 7ccc671..3c33cf7 100644 --- a/api/infrastructure/dependencies/usecases.py +++ b/api/infrastructure/dependencies/usecases.py @@ -14,6 +14,4 @@ def provide_create_user( uow: Annotated[UnitOfWork, Depends(Stub(UnitOfWork))], password_hasher: Annotated[PasswordHasher, Depends(Stub(PasswordHasher))], ) -> CreateUser: - return CreateUser( - uow=uow, user_repository=user_repository, password_hasher=password_hasher - ) + return CreateUser(uow=uow, user_repository=user_repository, password_hasher=password_hasher) diff --git a/api/infrastructure/persistence/repositories/user_repository.py b/api/infrastructure/persistence/repositories/user_repository.py index 30fe2f8..5106fdc 100644 --- a/api/infrastructure/persistence/repositories/user_repository.py +++ b/api/infrastructure/persistence/repositories/user_repository.py @@ -2,7 +2,6 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from api.domain.user import User, UserRepository -from api.infrastructure.persistence.models.user import UserModel class SqlAlchemyUserRepository(UserRepository): @@ -10,12 +9,6 @@ class SqlAlchemyUserRepository(UserRepository): self.session = session async def create_user(self, user: User) -> None: - # stmt = insert(UserModel).values( - # id=user.id.value, - # name=user.name.value, - # email=user.email.value, - # hashed_password=user.hashed_password, - # ) stmt = text( """INSERT INTO users (id, name, email, hashed_password) VALUES(:id, :name, :email, :hashed_password) diff --git a/api/infrastructure/settings.py b/api/infrastructure/settings.py index a5cedbc..dca5510 100644 --- a/api/infrastructure/settings.py +++ b/api/infrastructure/settings.py @@ -1,8 +1,10 @@ from dataclasses import dataclass +from api.infrastructure.auth.jwt_settings import JwtSettings from api.infrastructure.persistence.db_setings import DBSettings @dataclass() class Settings: db: DBSettings + jwt: JwtSettings diff --git a/api/presentation/routers/auth.py b/api/presentation/routers/auth.py index 3af3d31..bba5f01 100644 --- a/api/presentation/routers/auth.py +++ b/api/presentation/routers/auth.py @@ -1,9 +1,14 @@ from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Response +from fastapi.security import OAuth2PasswordRequestForm -from api.application.contracts.auth import UserCreateRequest +from api.application.contracts.auth import LoginRequest, UserCreateRequest +from api.application.contracts.auth.auth_response import AuthenticationResponse +from api.application.protocols.jwt import JwtTokenProcessor +from api.application.usecase.auth.auth_user import LoginUser from api.application.usecase.auth.create_user import CreateUser +from api.domain.user.model import UserId from api.infrastructure.dependencies.stub import Stub auth_router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -15,3 +20,22 @@ async def create_user( usecase: Annotated[CreateUser, Depends(Stub(CreateUser))], ) -> None: return await usecase.execute(request) + + +@auth_router.post("/login", response_model=AuthenticationResponse) +async def login( + response: Response, + login_request: Annotated[OAuth2PasswordRequestForm, Depends()], + login_interactor: Annotated[LoginUser, Depends(Stub(LoginUser))], + token_processor: Annotated[JwtTokenProcessor, Depends(Stub(JwtTokenProcessor))], +) -> AuthenticationResponse: + user = await login_interactor( + LoginRequest( + email=login_request.username, + password=login_request.password, + ) + ) + token = token_processor.generate_token(UserId(user.id)) + response.set_cookie(key="access_token", value=f"Bearer {token}", httponly=True) + + return user diff --git a/api/presentation/routers/user.py b/api/presentation/routers/user.py index bef5f84..ad4a59e 100644 --- a/api/presentation/routers/user.py +++ b/api/presentation/routers/user.py @@ -1,9 +1,6 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends +from fastapi import APIRouter from api.application.contracts.user import UserResponse -from api.infrastructure.dependencies.stub import Stub user_router = APIRouter(prefix="/users", tags=["Users"]) diff --git a/config/api_config.yml b/config/api_config.yml index eb29195..37c39ea 100644 --- a/config/api_config.yml +++ b/config/api_config.yml @@ -4,3 +4,8 @@ db: database: "serviceman_db" user: "demo_user" password: "user_pass" + +jwt: + secret_key: "abra-cadabra" + algorithm: "HS256" + expires_in: 2 diff --git a/poetry.lock b/poetry.lock index 7cdb516..4392949 100644 --- a/poetry.lock +++ b/poetry.lock @@ -347,6 +347,24 @@ files = [ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] +[[package]] +name = "ecdsa" +version = "0.18.0" +description = "ECDSA cryptographic signature library (pure python)" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, + {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -600,6 +618,19 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "jose" +version = "1.0.0" +description = "An implementation of the JOSE draft" +optional = false +python-versions = "*" +files = [ + {file = "jose-1.0.0.tar.gz", hash = "sha256:8436c3617cd94e1ba97828fbb1ce27c129f66c78fb855b4bb47e122b5f345fba"}, +] + +[package.dependencies] +pycrypto = ">=2.6" + [[package]] name = "magic-filter" version = "1.0.12" @@ -949,6 +980,27 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pyasn1" +version = "0.6.0" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.0-py2.py3-none-any.whl", hash = "sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473"}, + {file = "pyasn1-0.6.0.tar.gz", hash = "sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c"}, +] + +[[package]] +name = "pycrypto" +version = "2.6.1" +description = "Cryptographic modules for Python." +optional = false +python-versions = "*" +files = [ + {file = "pycrypto-2.6.1.tar.gz", hash = "sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c"}, +] + [[package]] name = "pydantic" version = "2.5.3" @@ -1107,6 +1159,27 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "python-jose" +version = "3.3.0" +description = "JOSE implementation in Python" +optional = false +python-versions = "*" +files = [ + {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"}, + {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"}, +] + +[package.dependencies] +ecdsa = "!=0.15" +pyasn1 = "*" +rsa = "*" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pyasn1", "pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pyasn1", "pycryptodome (>=3.3.1,<4.0.0)"] + [[package]] name = "pyyaml" version = "6.0.1" @@ -1132,7 +1205,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1167,6 +1239,20 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "setuptools" version = "69.2.0" @@ -1183,6 +1269,17 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1476,4 +1573,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "15cfd4e717fc2a2ca5dc844e5174d992f7fd8739bc2a056f2bbbc352e91018d4" +content-hash = "cb90b25778130dc1445290f65ee84f37029b7db4838a16b510a9f2efaf5ae841" diff --git a/pyproject.toml b/pyproject.toml index 13b39e2..a8d77c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,8 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.10" +jose = "^1.0.0" +python-jose = "^3.3.0"