diff --git a/api/app_builder/dependencies.py b/api/app_builder/dependencies.py index 9185619..d5f8280 100644 --- a/api/app_builder/dependencies.py +++ b/api/app_builder/dependencies.py @@ -1,6 +1,5 @@ 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 @@ -9,21 +8,33 @@ from api.application.protocols.password_hasher import PasswordHasher from api.application.usecase.auth.auth_user import LoginUser 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.configs import app_settings -from api.infrastructure.dependencies.protocols import (get_date_time_provider, - get_jwt_token_processor, - get_password_hasher, - get_user_login) +from api.infrastructure.auth.jwt_settings import JwtSettings +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, + get_db_settings, + get_jwt_settings, +) +from api.infrastructure.dependencies.protocols import ( + get_date_time_provider, + get_jwt_token_processor, + get_password_hasher, + get_user_login, +) from api.infrastructure.dependencies.repositories import get_user_repository from api.infrastructure.dependencies.usecases import provide_create_user +from api.infrastructure.persistence.db_setings import DBSettings from api.infrastructure.settings import Settings def init_dependencies(app: FastAPI) -> None: + app.dependency_overrides[DBSettings] = get_db_settings + app.dependency_overrides[JwtSettings] = get_jwt_settings app.dependency_overrides[Settings] = app_settings app.dependency_overrides[AsyncEngine] = create_engine diff --git a/api/app_builder/error_handlers.py b/api/app_builder/error_handlers.py new file mode 100644 index 0000000..7d65408 --- /dev/null +++ b/api/app_builder/error_handlers.py @@ -0,0 +1,51 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from api.domain.error import DomainValidationError +from api.domain.user.error import (UserAlreadyExistsError, + UserInvalidCredentialsError, + UserIsNotAuthorizedError) + + +async def validation_error_exc_handler( + request: Request, exc: DomainValidationError +) -> JSONResponse: + return JSONResponse(status_code=400, content={"detail": exc.message}) + + +async def user_authentication_error_exc_handler( + request: Request, exc: UserIsNotAuthorizedError +) -> JSONResponse: + return JSONResponse( + status_code=401, + content={"detail": exc.message}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def user_already_exist_error_exc_handler( + request: Request, exc: UserAlreadyExistsError +) -> JSONResponse: + return JSONResponse(status_code=409, content={"detail": exc.message}) + + +async def user_invalid_credentials_error_exc_handler( + request: Request, exc: UserInvalidCredentialsError +) -> JSONResponse: + return JSONResponse(status_code=401, content={"detail": exc.message}) + + +def init_exc_handlers(app: FastAPI) -> None: + app.add_exception_handler( + DomainValidationError, + validation_error_exc_handler, + ) + app.add_exception_handler( + UserIsNotAuthorizedError, user_authentication_error_exc_handler + ) + app.add_exception_handler( + UserAlreadyExistsError, user_already_exist_error_exc_handler + ) + app.add_exception_handler( + UserInvalidCredentialsError, user_invalid_credentials_error_exc_handler + ) diff --git a/api/app_builder/main.py b/api/app_builder/main.py index 52eed1d..9d820e1 100644 --- a/api/app_builder/main.py +++ b/api/app_builder/main.py @@ -5,8 +5,15 @@ from fastapi import FastAPI from sqlalchemy.ext.asyncio import AsyncEngine from api.app_builder.dependencies import init_dependencies +from api.app_builder.error_handlers import init_exc_handlers +from api.infrastructure.auth.jwt_settings import JwtSettings from api.infrastructure.dependencies.adapters import create_engine -from api.infrastructure.dependencies.configs import app_settings +from api.infrastructure.dependencies.configs import ( + app_settings, + get_db_settings, + get_jwt_settings, +) +from api.infrastructure.persistence.db_setings import DBSettings from api.infrastructure.persistence.models import Base from api.infrastructure.settings import Settings @@ -16,9 +23,16 @@ from .routers import init_routers @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator: print("init lifespan") + app.dependency_overrides[DBSettings] = get_db_settings + app.dependency_overrides[JwtSettings] = get_jwt_settings app.dependency_overrides[Settings] = app_settings app.dependency_overrides[AsyncEngine] = create_engine - engine = app.dependency_overrides[AsyncEngine](app.dependency_overrides[Settings]()) + engine = app.dependency_overrides[AsyncEngine]( + app.dependency_overrides[Settings]( + app.dependency_overrides[DBSettings](), + app.dependency_overrides[JwtSettings](), + ), + ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) @@ -32,5 +46,6 @@ def app_factory() -> FastAPI: ) init_dependencies(app) init_routers(app) + init_exc_handlers(app) return app diff --git a/api/application/protocols/jwt.py b/api/application/protocols/jwt.py index 35652e8..a5ea97d 100644 --- a/api/application/protocols/jwt.py +++ b/api/application/protocols/jwt.py @@ -9,3 +9,6 @@ class JwtTokenProcessor(Protocol): def validate_token(self, token: str) -> UserId | None: raise NotImplementedError + + def refresh_token(self, token: str) -> str: + raise NotImplementedError diff --git a/api/application/usecase/auth/auth_user.py b/api/application/usecase/auth/auth_user.py index 1ce19bc..a07b4ad 100644 --- a/api/application/usecase/auth/auth_user.py +++ b/api/application/usecase/auth/auth_user.py @@ -15,16 +15,12 @@ class LoginUser: self.hasher = password_hasher async def __call__(self, request: LoginRequest) -> AuthenticationResponse: - print("__call__ request", request) user = await self.user_repository.get_user(filter={"email": request.email}) - print("__call__ user from repo", user) error = UserInvalidCredentialsError("Email or password is incorrect") if user is None: - print("user is none in LoginUser __call__") raise error if not self.hasher.verify_password(request.password, user.hashed_password): - print("wrong pass in LoginUser __call__") raise error return AuthenticationResponse( diff --git a/api/infrastructure/auth/jwt_processor.py b/api/infrastructure/auth/jwt_processor.py index 2e04fe3..d9860f1 100644 --- a/api/infrastructure/auth/jwt_processor.py +++ b/api/infrastructure/auth/jwt_processor.py @@ -17,7 +17,7 @@ class JoseJwtTokenProcessor(JwtTokenProcessor): 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) + expiration_time = issued_at + timedelta(minutes=self.jwt_options.expires_in) claims = { "iat": issued_at, @@ -30,8 +30,10 @@ class JoseJwtTokenProcessor(JwtTokenProcessor): 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 + + def refresh_token(self, token: str) -> str: + return "" diff --git a/api/infrastructure/dependencies/configs.py b/api/infrastructure/dependencies/configs.py index bb73885..e7fc019 100644 --- a/api/infrastructure/dependencies/configs.py +++ b/api/infrastructure/dependencies/configs.py @@ -1,9 +1,12 @@ import os from functools import lru_cache +from typing import Annotated import yaml # type: ignore +from fastapi import Depends from api.infrastructure.auth.jwt_settings import JwtSettings +from api.infrastructure.dependencies.stub import Stub from api.infrastructure.persistence.db_setings import DBSettings from api.infrastructure.settings import Settings @@ -14,23 +17,36 @@ def yaml_loader(file: str) -> dict[str, dict[str, str]]: return yaml_data -@lru_cache -def app_settings() -> Settings: +def get_db_settings() -> DBSettings: config_data = yaml_loader( file=os.getenv("CONFIG_PATH", "./config/api_config.yml"), ) - - return Settings( - db=DBSettings( - pg_user=config_data["db"]["user"], - pg_pass=config_data["db"]["password"], - pg_host=config_data["db"]["host"], - 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"], - ), + return DBSettings( + pg_user=config_data["db"]["user"], + pg_pass=config_data["db"]["password"], + pg_host=config_data["db"]["host"], + pg_port=int(config_data["db"]["port"]), + pg_db=config_data["db"]["database"], + ) + + +def get_jwt_settings() -> JwtSettings: + config_data = yaml_loader( + file=os.getenv("CONFIG_PATH", "./config/api_config.yml"), + ) + return JwtSettings( + secret=config_data["jwt"]["secret_key"], + expires_in=int(config_data["jwt"]["expires_in"]), + algorithm=config_data["jwt"]["algorithm"], + ) + + +@lru_cache +def app_settings( + db_conf: Annotated[DBSettings, Depends(Stub(DBSettings))], + jwt_conf: Annotated[JwtSettings, Depends(Stub(JwtSettings))], +) -> Settings: + return Settings( + db=db_conf, + jwt=jwt_conf, ) diff --git a/api/infrastructure/dependencies/protocols.py b/api/infrastructure/dependencies/protocols.py index 2807056..b1203d4 100644 --- a/api/infrastructure/dependencies/protocols.py +++ b/api/infrastructure/dependencies/protocols.py @@ -26,9 +26,7 @@ def get_jwt_token_processor( settings: Annotated[Settings, Depends(Stub(Settings))], date_time_provider: Annotated[DateTimeProvider, Depends(Stub(DateTimeProvider))], ) -> JwtTokenProcessor: - return JoseJwtTokenProcessor( - jwt_options=settings.jwt, date_time_provider=date_time_provider - ) + return JoseJwtTokenProcessor(jwt_options=settings.jwt, date_time_provider=date_time_provider) def get_user_login( diff --git a/api/infrastructure/persistence/repositories/user_repository.py b/api/infrastructure/persistence/repositories/user_repository.py index c40f4cf..b9db15a 100644 --- a/api/infrastructure/persistence/repositories/user_repository.py +++ b/api/infrastructure/persistence/repositories/user_repository.py @@ -28,9 +28,12 @@ class SqlAlchemyUserRepository(UserRepository): async def get_user(self, filter: dict) -> User | None: stmt = text("""SELECT * FROM users WHERE email = :val""") result = await self.session.execute(stmt, {"val": filter["email"]}) - if not result: + + result = result.mappings().one_or_none() + + if result is None: return None - result = result.mappings().one() + return User( id=UserId(result.id), name=UserFirstName(result.name), diff --git a/api/presentation/auth/__init__.py b/api/presentation/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/presentation/auth/fasapi_auth.py b/api/presentation/auth/fasapi_auth.py new file mode 100644 index 0000000..fc391cf --- /dev/null +++ b/api/presentation/auth/fasapi_auth.py @@ -0,0 +1,60 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, Response, status +from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel +from fastapi.security import OAuth2 +from fastapi.security.utils import get_authorization_scheme_param + +from api.application.protocols.jwt import JwtTokenProcessor +from api.domain.user.error import UserIsNotAuthorizedError +from api.infrastructure.dependencies.stub import Stub + + +class OAuth2PasswordBearerWithCookie(OAuth2): + def __init__( + self, + tokenUrl: str, + scheme_name: str | None = None, + scopes: dict[str, str] | None = None, + auto_error: bool = True, + ): + if not scopes: + scopes = {} + flows = OAuthFlowsModel(password={"tokenUrl": tokenUrl, "scopes": scopes}) + super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error) + + async def __call__(self, request: Request) -> str | None: + authorization: str | None = request.cookies.get("access_token") + + scheme, param = get_authorization_scheme_param(authorization) + if authorization is None or scheme.lower() != "bearer": + if self.auto_error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + return None + print(param) + return param + + +oauth2_scheme = OAuth2PasswordBearerWithCookie("/auth/login") + + +async def auth_required( + request: Request, + token: Annotated[ + str, + Depends(oauth2_scheme), + ], + jwt_processor: Annotated[JwtTokenProcessor, Depends(Stub(JwtTokenProcessor))], +) -> None: + if token is None: + raise UserIsNotAuthorizedError("Invalid authorization credentials") + + if jwt_processor.validate_token(token=token) is None: + raise UserIsNotAuthorizedError("authorization credentials is old") + + request.scope["auth"] = token diff --git a/api/presentation/routers/auth.py b/api/presentation/routers/auth.py index bba5f01..2b4b950 100644 --- a/api/presentation/routers/auth.py +++ b/api/presentation/routers/auth.py @@ -39,3 +39,13 @@ async def login( response.set_cookie(key="access_token", value=f"Bearer {token}", httponly=True) return user + + +@auth_router.post("/logout") +async def logout( + response: Response, +): + + response.delete_cookie(key="access_token", httponly=True) + + return {"result": "logout"} diff --git a/api/presentation/routers/user.py b/api/presentation/routers/user.py index ad4a59e..ff503ed 100644 --- a/api/presentation/routers/user.py +++ b/api/presentation/routers/user.py @@ -1,10 +1,15 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from api.application.contracts.user import UserResponse +from api.presentation.auth.fasapi_auth import auth_required user_router = APIRouter(prefix="/users", tags=["Users"]) -@user_router.get("/") +@user_router.get( + "/", + response_model=list[UserResponse], + dependencies=[Depends(auth_required)], +) async def get_all_users() -> list[UserResponse]: return []