From a70cfcdd98c256140a1f470ebe18abaa6bd4d0b6 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 16:58:34 +0300 Subject: [PATCH 01/84] Update project version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 274456c..4f07982 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "authapigateway" -version = "0.0.1" +version = "0.0.3" description = "Cookie-based JWT Auth API" readme = "README.md" requires-python = ">=3.12" From 133f72cdcab518a3fe16fe2536ad9931ce762cd4 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 16:58:43 +0300 Subject: [PATCH 02/84] Update project version --- src/app/main/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index 127c1da..a92fa2c 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -48,7 +48,7 @@ class Settings(BaseSettings): # FastAPI settings title: str = "JWT Auth API Gateway" description: str = "RBAC JWT Cookies-based API Gateway" - version: str = "0.0.1" + version: str = "0.0.3" debug: bool = True docs_url: str = "/" From 39edb4a8b7e61549bd31ba343c15b76483146e85 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 16:59:53 +0300 Subject: [PATCH 03/84] Update title and description settings for FastAPI app --- src/app/main/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index a92fa2c..291ef3b 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -46,8 +46,8 @@ class Settings(BaseSettings): """Main project settings.""" # FastAPI settings - title: str = "JWT Auth API Gateway" - description: str = "RBAC JWT Cookies-based API Gateway" + title: str = "Auth API" + description: str = "RBAC JWT Cookies-based API" version: str = "0.0.3" debug: bool = True docs_url: str = "/" From 6a2e5fcb750e37c7c492ce57fa927db8524fe3c4 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 17:00:12 +0300 Subject: [PATCH 04/84] Update project description --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4f07982..fb089ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "authapigateway" version = "0.0.3" -description = "Cookie-based JWT Auth API" +description = "RBAC JWT Cookies-based API" readme = "README.md" requires-python = ">=3.12" dependencies = [ From 3168074e8147997869d65a5585def85abc2b7f59 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 17:00:36 +0300 Subject: [PATCH 05/84] Update project name --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fb089ac..aa99109 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "authapigateway" +name = "Auth API" version = "0.0.3" description = "RBAC JWT Cookies-based API" readme = "README.md" From 15205070bffcd277ec3b077046b14657c21e2fc6 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 17:01:28 +0300 Subject: [PATCH 06/84] Disable debug mode by default --- src/app/main/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index 291ef3b..7df1930 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -49,13 +49,13 @@ class Settings(BaseSettings): title: str = "Auth API" description: str = "RBAC JWT Cookies-based API" version: str = "0.0.3" - debug: bool = True + debug: bool = False docs_url: str = "/" # Admin app setting admin_base_url: str = "/admin" admin_title: str = "Auth API Admin" - admin_debug: bool = True + admin_debug: bool = False # Logging settings log_name: str = "app" From f64232e5e36f6271d98139db0a4f9593fcfb167f Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 17:02:03 +0300 Subject: [PATCH 07/84] Update value of admin_debug parameter --- src/app/main/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index 7df1930..62278cc 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -55,7 +55,7 @@ class Settings(BaseSettings): # Admin app setting admin_base_url: str = "/admin" admin_title: str = "Auth API Admin" - admin_debug: bool = False + admin_debug: bool = debug # Logging settings log_name: str = "app" From 0b0f4ab913daa5d3a563c67ab0c417f398c7d557 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 17:02:51 +0300 Subject: [PATCH 08/84] Reset value of jwt_secret_key --- src/app/main/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index 62278cc..248b9db 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -83,7 +83,7 @@ class Settings(BaseSettings): # JWT settings jwt_algorithm: str = "HS256" - jwt_secret_key: str = "supersecret12345" # noqa: S105 + jwt_secret_key: str = "" # JWT Tokens settings access_token_expiration_time: int = 5 # minutes From f580dfa29740287513c43f3d00c747d6b72131bc Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 17:04:52 +0300 Subject: [PATCH 09/84] Fix code style errors --- src/alembic/env.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/alembic/env.py b/src/alembic/env.py index eb5a3ea..cdf07e0 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -1,11 +1,11 @@ -import asyncio # noqa: INP001 +import asyncio from logging.config import fileConfig +from alembic import context from sqlalchemy.engine.base import Connection from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.pool import NullPool -from alembic import context from app.main.settings import settings from app.user.models import User From 799cc3e826d90aa7d9ea17a08d063903f2eeeac1 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 17:05:11 +0300 Subject: [PATCH 10/84] Change ruff ignore rules for alembic --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aa99109..9ea4767 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ lint.ignore = [ [tool.ruff.lint.per-file-ignores] "src/tests/*" = ["D103", "S101"] "src/app/user/exceptions.py" = ["D101"] -"src/alembic/versions/*" = ["D103", "D400", "D415", "INP001"] +"src/alembic/*" = ["D103", "D400", "D415", "INP001", "I001"] [tool.mypy] python_version = "3.12" From 1c084d786cde184183e7d6c96c00eb3bd6d85d0e Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 17:18:56 +0300 Subject: [PATCH 11/84] Rename variable to specify the database engine object --- src/app/main/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/main/app.py b/src/app/main/app.py index 27c4691..5633d62 100644 --- a/src/app/main/app.py +++ b/src/app/main/app.py @@ -37,9 +37,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: application to the testing environment. """ redis_client = getattr(app.state, "redis", redis) - database_engine = getattr(app.state, "engine", engine) + db = getattr(app.state, "engine", engine) - init_admin_app(app, database_engine) + init_admin_app(app, db) # set application state app.state.logger = configure_logging() @@ -49,7 +49,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await redis_client.close() await redis_client.connection_pool.disconnect() - await database_engine.dispose() + await db.dispose() app = FastAPI( From 774fb7210dcf9989a7a56b71e3f90404e8500e69 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 17:34:02 +0300 Subject: [PATCH 12/84] Change values of the constants to be auto --- src/app/user/constants.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/user/constants.py b/src/app/user/constants.py index a256985..959ff90 100644 --- a/src/app/user/constants.py +++ b/src/app/user/constants.py @@ -1,23 +1,23 @@ -from enum import StrEnum +from enum import StrEnum, auto class TokenType(StrEnum): """Types of JWT tokens.""" - access = "access" - refresh = "refresh" + access = auto() + refresh = auto() class CookieType(StrEnum): """Types of cookies.""" - access_token = "access_token" # noqa: S105 - refresh_token = "refresh_token" # noqa: S105 + access_token = auto() + refresh_token = auto() class UserRole(StrEnum): """User roles of the app.""" - admin = "admin" - moderator = "moderator" - user = "user" + admin = auto() + moderator = auto() + user = auto() From 668856810e62b5657f37d6125ef94c8640eedd0f Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:28:02 +0300 Subject: [PATCH 13/84] Move general error handlers into a separate file --- src/app/main/errors.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/app/main/errors.py diff --git a/src/app/main/errors.py b/src/app/main/errors.py new file mode 100644 index 0000000..07348b6 --- /dev/null +++ b/src/app/main/errors.py @@ -0,0 +1,42 @@ +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any, cast + +from fastapi import Request, Response, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +if TYPE_CHECKING: + from logging import Logger + + +async def validation_error_handler( + _: Request, + e: RequestValidationError, +) -> JSONResponse: + """Pydantic validation error handler.""" + error = e.errors()[0] + field = error["loc"][1] + error_message = f"{error['msg']}. Field: {field}" + client_message = {"detail": error_message} + return JSONResponse(client_message, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +async def unexpected_error_handler( + request: Request, + e: Exception, +) -> JSONResponse: + """Error handler for all uncaught exceptions.""" + logger = cast("Logger", request.app.state.logger) + logger.critical("Internal Server Error: %s", e) + client_message = {"error": "Service is temporarily unavailable"} + return JSONResponse(client_message, status.HTTP_500_INTERNAL_SERVER_ERROR) + + +# error handlers mapping to be registered in the main FastAPI app +error_handlers: dict[ + int | type[Exception], + Callable[[Request, Any], Coroutine[Any, Any, Response]], +] = { + RequestValidationError: validation_error_handler, + Exception: unexpected_error_handler, +} From aee15b50b4d8b9b76a0ccc0910d93619b61a56f0 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:28:22 +0300 Subject: [PATCH 14/84] Update list of error handlers for FastAPI app --- src/app/main/app.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/app/main/app.py b/src/app/main/app.py index 5633d62..2200af6 100644 --- a/src/app/main/app.py +++ b/src/app/main/app.py @@ -2,19 +2,15 @@ from contextlib import asynccontextmanager from fastapi import FastAPI -from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.exc import IntegrityError from app.admin.app import init_admin_app -from app.user.errors import ( - db_integrity_error_handler, - unexpected_error_handler, - validation_error_handler, -) +from app.user.errors import db_integrity_error_handler from app.user.routes import router as user_router from .db import engine +from .errors import error_handlers as main_error_handlers from .logger import configure_logging from .redis import redis from .routes import router as default_router @@ -57,8 +53,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: lifespan=lifespan, exception_handlers={ IntegrityError: db_integrity_error_handler, - RequestValidationError: validation_error_handler, - Exception: unexpected_error_handler, + **main_error_handlers, }, ) app.add_middleware(CORSMiddleware, **settings.cors_kwargs) From d1b20e037d6c2fd0d83517dfb55346de279d8b2d Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:29:09 +0300 Subject: [PATCH 15/84] Move general error handlers to app.main.errors --- src/app/user/errors.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/app/user/errors.py b/src/app/user/errors.py index 56291d5..a17236f 100644 --- a/src/app/user/errors.py +++ b/src/app/user/errors.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING, cast from fastapi import Request, status -from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError @@ -18,26 +17,3 @@ async def db_integrity_error_handler( logger.warning("Database Integrity Error: %s", e) client_message = {"detail": "User already exists"} return JSONResponse(client_message, status.HTTP_409_CONFLICT) - - -async def validation_error_handler( - _: Request, - e: RequestValidationError, -) -> JSONResponse: - """Pydantic validation error handler.""" - error = e.errors()[0] - field = error["loc"][1] - error_message = f"{error['msg']}. Field: {field}" - client_message = {"detail": error_message} - return JSONResponse(client_message, status.HTTP_422_UNPROCESSABLE_ENTITY) - - -async def unexpected_error_handler( - request: Request, - e: Exception, -) -> JSONResponse: - """Error handler for all uncaught exceptions.""" - logger = cast("Logger", request.app.state.logger) - logger.critical("Internal Server Error: %s", e) - client_message = {"error": "Service is temporarily unavailable"} - return JSONResponse(client_message, status.HTTP_500_INTERNAL_SERVER_ERROR) From dc4fc6f0b48d398c64bda45857ffc4d9e9965d1a Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:31:41 +0300 Subject: [PATCH 16/84] Define a dict of error handlers of the user module --- src/app/user/errors.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/user/errors.py b/src/app/user/errors.py index a17236f..0ca9186 100644 --- a/src/app/user/errors.py +++ b/src/app/user/errors.py @@ -1,6 +1,7 @@ -from typing import TYPE_CHECKING, cast +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any, cast -from fastapi import Request, status +from fastapi import Request, Response, status from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError @@ -17,3 +18,12 @@ async def db_integrity_error_handler( logger.warning("Database Integrity Error: %s", e) client_message = {"detail": "User already exists"} return JSONResponse(client_message, status.HTTP_409_CONFLICT) + + +# error handlers mapping to be registered in the main FastAPI app +error_handlers: dict[ + int | type[Exception], + Callable[[Request, Any], Coroutine[Any, Any, Response]], +] = { + IntegrityError: db_integrity_error_handler, +} From 7af5f54c028bd0c74e0fbb682c6b7805b37e3a22 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:32:10 +0300 Subject: [PATCH 17/84] Update list of error handlers for FastAPI app --- src/app/main/app.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/main/app.py b/src/app/main/app.py index 2200af6..51c3973 100644 --- a/src/app/main/app.py +++ b/src/app/main/app.py @@ -3,10 +3,9 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy.exc import IntegrityError from app.admin.app import init_admin_app -from app.user.errors import db_integrity_error_handler +from app.user.errors import error_handlers as user_error_handlers from app.user.routes import router as user_router from .db import engine @@ -52,7 +51,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: **settings.fastapi_kwargs, lifespan=lifespan, exception_handlers={ - IntegrityError: db_integrity_error_handler, + **user_error_handlers, **main_error_handlers, }, ) From 6475d7e9afe5c5bdbd25176326f57ea321710136 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:42:51 +0300 Subject: [PATCH 18/84] Fix incorrect name of the project --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9ea4767..6fe8f1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "Auth API" +name = "Auth-API" version = "0.0.3" description = "RBAC JWT Cookies-based API" readme = "README.md" diff --git a/uv.lock b/uv.lock index 73f3b8b..8aac60e 100644 --- a/uv.lock +++ b/uv.lock @@ -88,8 +88,8 @@ wheels = [ ] [[package]] -name = "authapigateway" -version = "0.0.1" +name = "auth-api" +version = "0.0.3" source = { virtual = "." } dependencies = [ { name = "alembic" }, From 7bc121208d2297f162af3d9c17159923b6925f42 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:43:21 +0300 Subject: [PATCH 19/84] Remove file containing definition of the Redis client --- src/app/main/redis.py | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/app/main/redis.py diff --git a/src/app/main/redis.py b/src/app/main/redis.py deleted file mode 100644 index 3283d12..0000000 --- a/src/app/main/redis.py +++ /dev/null @@ -1,9 +0,0 @@ -from redis.asyncio import ConnectionPool, Redis - -from .settings import settings - -redis_connection_pool = ConnectionPool.from_url( - settings.redis_url, - decode_responses=True, -) -redis = Redis(connection_pool=redis_connection_pool) From 9f9db089974ed5655381fd218f1ca340a573cb8f Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:54:54 +0300 Subject: [PATCH 20/84] Add separate settings for Redis client --- src/app/main/settings.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index 248b9db..7edc502 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -4,6 +4,7 @@ from typing import TypedDict from pydantic_settings import BaseSettings +from redis.asyncio import ConnectionPool class FastAPIKwargs(TypedDict): @@ -42,6 +43,12 @@ class CORSMiddlewareKwargs(TypedDict): max_age: int +class RedisKwargs(TypedDict): + """Kwargs for Redis client.""" + + connection_pool: ConnectionPool + + class Settings(BaseSettings): """Main project settings.""" @@ -145,6 +152,13 @@ def cors_kwargs(self) -> CORSMiddlewareKwargs: max_age=self.cors_max_age, ) + @property + def redis_kwargs(self) -> RedisKwargs: + """Kwargs for Redis client.""" + return RedisKwargs( + connection_pool=ConnectionPool.from_url(self.redis_url, decode_responses=True), + ) + @lru_cache def get_settings() -> Settings: From c8ab2c2631023d7fa6bcf9212a39789b996e421a Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:55:13 +0300 Subject: [PATCH 21/84] Update state objects declaration in the lifespan --- src/app/main/app.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/app/main/app.py b/src/app/main/app.py index 51c3973..643adbf 100644 --- a/src/app/main/app.py +++ b/src/app/main/app.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from redis.asyncio import Redis from app.admin.app import init_admin_app from app.user.errors import error_handlers as user_error_handlers @@ -11,7 +12,6 @@ from .db import engine from .errors import error_handlers as main_error_handlers from .logger import configure_logging -from .redis import redis from .routes import router as default_router from .settings import settings @@ -21,6 +21,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Init and release objects on app startup and shutdown. On startup: + - init application logger object; - init Redis client with connection pool; - init Admin app with the specified database engine; @@ -28,22 +29,22 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: - dispose Redis client with connection pool; - dispose database engine. - NOTE: during testing state's objects are redeclared to adapt the + NOTE: during testing state objects are redeclared to adapt the application to the testing environment. + See: tests/conftest.py """ - redis_client = getattr(app.state, "redis", redis) db = getattr(app.state, "engine", engine) + logger = getattr(app.state, "logger", configure_logging()) + redis = getattr(app.state, "redis", Redis(**settings.redis_kwargs)) init_admin_app(app, db) - # set application state - app.state.logger = configure_logging() - app.state.redis = redis_client + app.state.logger = logger + app.state.redis = redis yield - await redis_client.close() - await redis_client.connection_pool.disconnect() + await redis.aclose() await db.dispose() From f5ceebe4158efd95ebf5d66635051a47d318c842 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:56:32 +0300 Subject: [PATCH 22/84] Add comment --- src/app/main/dependencies.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/main/dependencies.py b/src/app/main/dependencies.py index 7dbaba4..7a67bcb 100644 --- a/src/app/main/dependencies.py +++ b/src/app/main/dependencies.py @@ -28,5 +28,6 @@ async def get_redis(request: Request) -> "Redis": return cast("Redis", request.app.state.redis) +# https://fastapi.tiangolo.com/tutorial/dependencies/#share-annotated-dependencies DbSession: TypeAlias = Annotated["AsyncSession", Depends(get_db)] RedisT: TypeAlias = Annotated["Redis", Depends(get_redis)] From dd12860b8381268428f5a1a603c3b2af031c7c03 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 18:57:21 +0300 Subject: [PATCH 23/84] Update docstring --- src/app/main/dependencies.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/main/dependencies.py b/src/app/main/dependencies.py index 7a67bcb..79c858c 100644 --- a/src/app/main/dependencies.py +++ b/src/app/main/dependencies.py @@ -20,10 +20,10 @@ async def get_db() -> AsyncIterator["AsyncSession"]: async def get_redis(request: Request) -> "Redis": - """Return initialized Redis client to be used as a dependency. + """Return initialized in the lifespan client for Redis. :param request: request object providing access to the app state - :return: Redis client + :return: client for Redis """ return cast("Redis", request.app.state.redis) From 09029ebf31764cfcc012fef08576eaa184b524b6 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:24:07 +0300 Subject: [PATCH 24/84] Init module --- src/app/user/security/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/app/user/security/__init__.py diff --git a/src/app/user/security/__init__.py b/src/app/user/security/__init__.py new file mode 100644 index 0000000..e69de29 From c350b36dc56e9fd6734fc4e88289c6c271d276cf Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:34:26 +0300 Subject: [PATCH 25/84] Move functions to manage user passwords to a separate file --- src/app/user/auth.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/app/user/auth.py b/src/app/user/auth.py index 6329982..68d5411 100644 --- a/src/app/user/auth.py +++ b/src/app/user/auth.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, cast from jose import JWTError, jwt -from passlib.context import CryptContext from app.main.settings import settings @@ -16,27 +15,6 @@ type PolicyT = dict[str, dict[str, list[str]]] -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def generate_password_hash(plain_password: str) -> str: - """Generate hash of the plain password. - - :param plain_password: plain password - :return: hash of the password - """ - return pwd_context.hash(plain_password) - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify plain and hashed password. - - :param plain_password: plain password - :param hashed_password: hashed password - :return: boolean result of the verification - """ - return pwd_context.verify(plain_password, hashed_password) - def generate_token(token_type: "TokenType", username: str, role: str) -> str: """Generate a JWT token of the specific type and claims. From 487e295ddc3dce47f1d46dba472d47c663916acc Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:34:54 +0300 Subject: [PATCH 26/84] Add helper function to return cached instance of CryptContext --- src/app/user/security/passwords.py | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/app/user/security/passwords.py diff --git a/src/app/user/security/passwords.py b/src/app/user/security/passwords.py new file mode 100644 index 0000000..2df0948 --- /dev/null +++ b/src/app/user/security/passwords.py @@ -0,0 +1,34 @@ +from functools import lru_cache + +from passlib.context import CryptContext + + +@lru_cache +def get_pwd_context() -> CryptContext: + """Return cached helper for hashing & verifying passwords. + + :return: helper for hashing & verifying passwords + """ + return CryptContext(schemes=["bcrypt"], deprecated="auto") + + +pwd_context = get_pwd_context() + + +def generate_password_hash(plain_password: str) -> str: + """Generate hash of the plain password. + + :param plain_password: plain password + :return: hash of the password + """ + return pwd_context.hash(plain_password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify plain and hashed password. + + :param plain_password: plain password + :param hashed_password: hashed password + :return: boolean result of the verification + """ + return pwd_context.verify(plain_password, hashed_password) From 6bfc6de2eb3e85481bbad6a0e5740fc02f558362 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:35:43 +0300 Subject: [PATCH 27/84] Change import path for verify_password function --- src/app/user/dependencies.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/user/dependencies.py b/src/app/user/dependencies.py index 30235a6..bcda182 100644 --- a/src/app/user/dependencies.py +++ b/src/app/user/dependencies.py @@ -6,11 +6,12 @@ from app.main.dependencies import DbSession, RedisT from app.main.settings import settings -from .auth import get_token_payload, verify_password +from .auth import get_token_payload from .exceptions import ExpiredTokenError, IncorrectPasswordError from .middlewares import OAuth2PasswordBearerWithCookie from .models import User from .schemas import TokenPayload, UserInDB +from .security.passwords import verify_password oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="user/login") From a760d463347893354c2f2317f4e6d5768c278a93 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:35:56 +0300 Subject: [PATCH 28/84] Change import path for verify_password function --- src/app/admin/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/admin/views.py b/src/app/admin/views.py index abf3bb3..9efcb81 100644 --- a/src/app/admin/views.py +++ b/src/app/admin/views.py @@ -4,9 +4,9 @@ from sqladmin import ModelView from wtforms.fields import EmailField, Field, PasswordField, SelectField -from app.user.auth import generate_password_hash from app.user.constants import UserRole from app.user.models import User +from app.user.security.passwords import generate_password_hash if TYPE_CHECKING: from sqladmin._types import MODEL_ATTR From 6682e16a3abc2632b1e0200629ca0ac004e8bf3e Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:36:00 +0300 Subject: [PATCH 29/84] Change import path for verify_password function --- src/app/user/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/user/models.py b/src/app/user/models.py index bae32e8..473381b 100644 --- a/src/app/user/models.py +++ b/src/app/user/models.py @@ -6,10 +6,10 @@ from app.main.db import Base -from .auth import generate_password_hash from .constants import UserRole from .exceptions import UserNotFoundError from .schemas import UserInDB +from .security.passwords import generate_password_hash if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession From d032b7598445292b3acd1b03fb0fb24eee4cc22c Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:41:58 +0300 Subject: [PATCH 30/84] Move functions to manage JWT --- src/app/user/security/tokens.py | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/app/user/security/tokens.py diff --git a/src/app/user/security/tokens.py b/src/app/user/security/tokens.py new file mode 100644 index 0000000..cf0ed6f --- /dev/null +++ b/src/app/user/security/tokens.py @@ -0,0 +1,47 @@ +from functools import lru_cache +from typing import TYPE_CHECKING + +from jose import JWTError, jwt + +from app.main.settings import settings +from app.user.exceptions import InvalidCredentialsError +from app.user.schemas import TokenPayload + +if TYPE_CHECKING: + from app.user.constants import TokenType + + +def generate_token(token_type: "TokenType", username: str, role: str) -> str: + """Generate a JWT token of the specific type and claims. + + :param token_type: type of the token (access or refresh) + :param username: username to generate the token for + :param role: role of the user (user, admin, etc.) + :return: JWT token + """ + payload = TokenPayload(sub=username, typ=token_type, role=role) + token = jwt.encode( + claims=payload.model_dump(), + key=settings.jwt_secret_key, + algorithm=settings.jwt_algorithm, + ) + return f"{settings.token_scheme} {token}" + + +@lru_cache(maxsize=settings.token_payload_max_cache_hits) +def get_token_payload(token: str) -> TokenPayload: + """Decode the JWT token and return its payload. + + :param token: JWT token + :raises InvalidCredentialsError: if the token can't be decoded + :return: payload of the token + """ + try: + payload = jwt.decode( + token, + key=settings.jwt_secret_key, + algorithms=[settings.jwt_algorithm], + ) + except JWTError as e: + raise InvalidCredentialsError from e + return TokenPayload.model_construct(**payload) From e4696e28a9c6960c7a40eab8b331ace9305ae8ce Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:42:18 +0300 Subject: [PATCH 31/84] Move functions to manage JWT to .security.tokens --- src/app/user/auth.py | 43 +------------------------------------------ 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/src/app/user/auth.py b/src/app/user/auth.py index 68d5411..282cecf 100644 --- a/src/app/user/auth.py +++ b/src/app/user/auth.py @@ -3,55 +3,14 @@ from functools import lru_cache from typing import TYPE_CHECKING, cast -from jose import JWTError, jwt - from app.main.settings import settings -from .exceptions import InvalidCredentialsError -from .schemas import TokenPayload - if TYPE_CHECKING: - from .constants import TokenType, UserRole + from .constants import UserRole type PolicyT = dict[str, dict[str, list[str]]] -def generate_token(token_type: "TokenType", username: str, role: str) -> str: - """Generate a JWT token of the specific type and claims. - - :param token_type: type of the token (access or refresh) - :param username: username to generate the token for - :param role: role of the user (user, admin, etc.) - :return: JWT token - """ - payload = TokenPayload(sub=username, typ=token_type, role=role) - token = jwt.encode( - claims=payload.model_dump(), - key=settings.jwt_secret_key, - algorithm=settings.jwt_algorithm, - ) - return f"{settings.token_scheme} {token}" - - -@lru_cache(maxsize=settings.token_payload_max_cache_hits) -def get_token_payload(token: str) -> TokenPayload: - """Decode the JWT token and return its payload. - - :param token: JWT token - :raises InvalidCredentialsError: if the token can't be decoded - :return: payload of the token - """ - try: - payload = jwt.decode( - token, - key=settings.jwt_secret_key, - algorithms=[settings.jwt_algorithm], - ) - except JWTError as e: - raise InvalidCredentialsError from e - return TokenPayload.model_construct(**payload) - - @lru_cache def verify_access(role: "UserRole", url: str) -> bool: """Verify if the user can access the URL according to one's role. From aa3b03fccda7e83ba7734db26a348385229fd671 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:42:40 +0300 Subject: [PATCH 32/84] Change import path for get_token_payload function --- src/app/user/dependencies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/user/dependencies.py b/src/app/user/dependencies.py index bcda182..5435743 100644 --- a/src/app/user/dependencies.py +++ b/src/app/user/dependencies.py @@ -6,12 +6,12 @@ from app.main.dependencies import DbSession, RedisT from app.main.settings import settings -from .auth import get_token_payload from .exceptions import ExpiredTokenError, IncorrectPasswordError from .middlewares import OAuth2PasswordBearerWithCookie from .models import User from .schemas import TokenPayload, UserInDB from .security.passwords import verify_password +from .security.tokens import get_token_payload oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="user/login") From 447e3e037f4212d99d78981e96dc33afd4598dfa Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:42:47 +0300 Subject: [PATCH 33/84] Change import path for get_token_payload function --- src/app/user/routes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/user/routes.py b/src/app/user/routes.py index db99f2e..f8ed8cd 100644 --- a/src/app/user/routes.py +++ b/src/app/user/routes.py @@ -4,7 +4,7 @@ from app.main.dependencies import DbSession -from .auth import generate_token, verify_access +from .auth import verify_access from .constants import CookieType, TokenType from .dependencies import ( add_tokens_to_blacklist, @@ -15,6 +15,7 @@ from .exceptions import InactiveUserError, PermissionDenied from .models import User as DBUser from .schemas import NewUser, Token, TokenPayload, User, UserCookie, UserInDB +from .security.tokens import generate_token router = APIRouter(tags=["user"]) From 1c247e1c9533e6576aa89598d12046077cb74d15 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:44:40 +0300 Subject: [PATCH 34/84] Move functions to manage user policies --- src/app/user/security/rbac.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/app/user/security/rbac.py diff --git a/src/app/user/security/rbac.py b/src/app/user/security/rbac.py new file mode 100644 index 0000000..076aff8 --- /dev/null +++ b/src/app/user/security/rbac.py @@ -0,0 +1,33 @@ +import json +import re +from functools import lru_cache +from typing import TYPE_CHECKING, cast + +from app.main.settings import settings + +if TYPE_CHECKING: + from app.user.constants import UserRole + +type PolicyT = dict[str, dict[str, list[str]]] + + +@lru_cache +def verify_access(role: "UserRole", url: str) -> bool: + """Verify if the user can access the URL according to one's role. + + :param role: role of the user + :param url: target URL to check access to + :return: boolean result of the verification + """ + policy = read_user_policy() + return any(re.match(pattern, url) for pattern in policy[role]["locations"]) + + +@lru_cache +def read_user_policy() -> PolicyT: + """Read user policy file and return its content as JSON. + + :return: user policy as JSON + """ + with settings.user_policy_file.open() as f: + return cast("PolicyT", json.load(f)) From b1ab78e02fca2b9cf83f10edd4a4630621f2d84a Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:45:03 +0300 Subject: [PATCH 35/84] Move functions to manage user policies to .security.rbac --- src/app/user/auth.py | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 src/app/user/auth.py diff --git a/src/app/user/auth.py b/src/app/user/auth.py deleted file mode 100644 index 282cecf..0000000 --- a/src/app/user/auth.py +++ /dev/null @@ -1,33 +0,0 @@ -import json -import re -from functools import lru_cache -from typing import TYPE_CHECKING, cast - -from app.main.settings import settings - -if TYPE_CHECKING: - from .constants import UserRole - -type PolicyT = dict[str, dict[str, list[str]]] - - -@lru_cache -def verify_access(role: "UserRole", url: str) -> bool: - """Verify if the user can access the URL according to one's role. - - :param role: role of the user - :param url: target URL to check access to - :return: boolean result of the verification - """ - policy = read_user_policy() - return any(re.match(pattern, url) for pattern in policy[role]["locations"]) - - -@lru_cache -def read_user_policy() -> PolicyT: - """Read user policy file and return its content as JSON. - - :return: user policy as JSON - """ - with settings.user_policy_file.open() as f: - return cast("PolicyT", json.load(f)) From 2756465ef697fb5edee13b66cbcbd91732ca9c41 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 19:45:15 +0300 Subject: [PATCH 36/84] Change import path for verify_access function --- src/app/user/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/user/routes.py b/src/app/user/routes.py index f8ed8cd..1b77861 100644 --- a/src/app/user/routes.py +++ b/src/app/user/routes.py @@ -4,7 +4,6 @@ from app.main.dependencies import DbSession -from .auth import verify_access from .constants import CookieType, TokenType from .dependencies import ( add_tokens_to_blacklist, @@ -15,6 +14,7 @@ from .exceptions import InactiveUserError, PermissionDenied from .models import User as DBUser from .schemas import NewUser, Token, TokenPayload, User, UserCookie, UserInDB +from .security.rbac import verify_access from .security.tokens import generate_token router = APIRouter(tags=["user"]) From 059cfb9b91257b0eedc3f75baa665ae6d4fce774 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 20:16:25 +0300 Subject: [PATCH 37/84] Remove fixture to provide fake async redis client --- src/tests/conftest.py | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 49398e8..3e5526b 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -26,7 +26,6 @@ if TYPE_CHECKING: from faker import Faker - from redis.asyncio import Redis # End-to-end mode should be enabled when running end-to-end tests @@ -63,23 +62,15 @@ async def override_get_db() -> AsyncIterator[AsyncSession]: @pytest.fixture(scope="session") -async def redis_client() -> AsyncIterator["Redis"]: - """Fixture to provide fake redis client object.""" - async with FakeAsyncRedis() as r: - yield r - - -@pytest.fixture(scope="session") -async def client(redis_client: "Redis") -> AsyncIterator[AsyncClient]: +async def client() -> AsyncIterator[AsyncClient]: """Fixture to provide an HTTP-client for integration tests. - :param redis_client: fixture providing a client for Redis. :yield: async HTTP-client. """ # override app's state objects specifically for testing app.state.logger = logging.getLogger(settings.test_log_name) app.state.engine = engine - app.state.redis = redis_client + app.state.redis = FakeAsyncRedis() app.dependency_overrides[get_db] = override_get_db async with LifespanManager(app) as manager: @@ -131,10 +122,7 @@ async def _deactivate_user() -> None: del app.dependency_overrides[authenticate_user] -async def create_user_by_role( - user: NewUser, - role: UserRole, -) -> NewUser: +async def create_user_by_role(user: NewUser, role: UserRole) -> NewUser: """Create a user with the specified role for further testing. :param user: info about user @@ -177,10 +165,7 @@ async def db_admin(user: NewUser) -> NewUser: return await create_user_by_role(user, role=UserRole.admin) -async def authorize_client( - client: AsyncClient, - user: NewUser, -) -> AsyncClient: +async def authorize_client(client: AsyncClient, user: NewUser) -> AsyncClient: """Authorize a user, setting authorization cookies into the client. :param client: async HTTP-client @@ -195,10 +180,7 @@ async def authorize_client( @pytest.fixture -async def authorized_client( - client: AsyncClient, - db_user: NewUser, -) -> AsyncClient: +async def authorized_client(client: AsyncClient, db_user: NewUser) -> AsyncClient: """Async HTTP-client with authorization cookies for a regular user. NOTE: it's used for integration tests. From 249ee6e824559896ceb8bc2e49c588c335f410f7 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 20:16:49 +0300 Subject: [PATCH 38/84] Override logger of the main app --- src/tests/conftest.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 3e5526b..f9944f7 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -31,6 +31,9 @@ # End-to-end mode should be enabled when running end-to-end tests E2E_MODE_DISABLED = bool(int(os.getenv("E2E_MODE_DISABLED", "1"))) +# use a different logger specifically for testing +app.state.logger = logging.getLogger(settings.test_log_name) + engine = create_async_engine(settings.test_database_url) async_session = async_sessionmaker( engine, @@ -67,11 +70,8 @@ async def client() -> AsyncIterator[AsyncClient]: :yield: async HTTP-client. """ - # override app's state objects specifically for testing - app.state.logger = logging.getLogger(settings.test_log_name) app.state.engine = engine app.state.redis = FakeAsyncRedis() - app.dependency_overrides[get_db] = override_get_db async with LifespanManager(app) as manager: transport = ASGITransport(manager.app) @@ -83,8 +83,6 @@ async def client() -> AsyncIterator[AsyncClient]: @pytest.fixture(scope="session") async def e2e_client() -> AsyncIterator[AsyncClient]: """Fixture to provide an HTTP-client for end-to-end tests.""" - # use a different logger specifically for testing - app.state.logger = logging.getLogger(settings.test_log_name) base_url = f"http://{settings.host_server_domain}" async with AsyncClient(base_url=base_url) as ac: yield ac From 4507a2d3c67031aaa887ece522303ad5398a8d64 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 23:22:00 +0300 Subject: [PATCH 39/84] Remove constant for enabling E2E mode --- src/tests/conftest.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index f9944f7..99a9875 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,5 +1,4 @@ import logging -import os from collections.abc import AsyncIterator from typing import TYPE_CHECKING @@ -27,13 +26,6 @@ if TYPE_CHECKING: from faker import Faker - -# End-to-end mode should be enabled when running end-to-end tests -E2E_MODE_DISABLED = bool(int(os.getenv("E2E_MODE_DISABLED", "1"))) - -# use a different logger specifically for testing -app.state.logger = logging.getLogger(settings.test_log_name) - engine = create_async_engine(settings.test_database_url) async_session = async_sessionmaker( engine, @@ -42,6 +34,12 @@ ) +# Globally override state objects of the main app +app.state.logger = logging.getLogger(settings.test_log_name) +app.state.engine = engine +app.state.redis = FakeAsyncRedis() + + @pytest.fixture(scope="session") def anyio_backend() -> str: """Backend (asyncio) for pytest to run async tests.""" @@ -70,21 +68,20 @@ async def client() -> AsyncIterator[AsyncClient]: :yield: async HTTP-client. """ - app.state.engine = engine - app.state.redis = FakeAsyncRedis() app.dependency_overrides[get_db] = override_get_db async with LifespanManager(app) as manager: transport = ASGITransport(manager.app) - base_url = f"http://{settings.host_server_domain}" - async with AsyncClient(base_url=base_url, transport=transport) as ac: + async with AsyncClient( + base_url=settings.test_client_base_url, + transport=transport, + ) as ac: yield ac @pytest.fixture(scope="session") async def e2e_client() -> AsyncIterator[AsyncClient]: """Fixture to provide an HTTP-client for end-to-end tests.""" - base_url = f"http://{settings.host_server_domain}" - async with AsyncClient(base_url=base_url) as ac: + async with AsyncClient(base_url=settings.test_client_base_url) as ac: yield ac @@ -95,7 +92,6 @@ def user(faker: "Faker") -> NewUser: :param faker: faker fixture :return: user with random info. """ - # ensure randomness for each call faker.seed_instance() password = faker.password() return NewUser.model_construct( From 807ab8778f7da60460cd0a73239e77dd1f0946f2 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 23:22:19 +0300 Subject: [PATCH 40/84] Set base url used by the test clients --- src/app/main/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index 7edc502..e08093e 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -73,6 +73,7 @@ class Settings(BaseSettings): test_log_name: str = "test" host_server_domain: str = "localhost" + test_client_base_url: str = f"http://{host_server_domain}" # CORS settings cors_max_age: int = 600 # seconds From 60ec4fe8faa55d3c39bdccb9cd45ac8b7d52eaa9 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 23:22:43 +0300 Subject: [PATCH 41/84] Replace constant with the marker to run E2E tests --- src/tests/app/admin/test_views.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/tests/app/admin/test_views.py b/src/tests/app/admin/test_views.py index eadaab7..505c629 100644 --- a/src/tests/app/admin/test_views.py +++ b/src/tests/app/admin/test_views.py @@ -4,8 +4,6 @@ from fastapi import status from httpx import AsyncClient -from tests.conftest import E2E_MODE_DISABLED - if TYPE_CHECKING: from httpx import AsyncClient @@ -23,13 +21,13 @@ async def test_admin_users_index(client: "AsyncClient") -> None: assert '

Users

' in resp.text -@pytest.mark.skipif(E2E_MODE_DISABLED, reason="E2E mode disabled") +@pytest.mark.e2e async def test_admin_home_as_unauthorized_user(e2e_client: "AsyncClient") -> None: resp = await e2e_client.get("/admin/") assert resp.status_code == status.HTTP_401_UNAUTHORIZED -@pytest.mark.skipif(E2E_MODE_DISABLED, reason="E2E mode disabled") +@pytest.mark.e2e async def test_admin_home_as_authorized_user( authorized_user_client: "AsyncClient", ) -> None: @@ -37,7 +35,7 @@ async def test_admin_home_as_authorized_user( assert resp.status_code == status.HTTP_403_FORBIDDEN -@pytest.mark.skipif(E2E_MODE_DISABLED, reason="E2E mode disabled") +@pytest.mark.e2e async def test_admin_home_as_moderator( authorized_moderator_client: "AsyncClient", ) -> None: @@ -45,7 +43,7 @@ async def test_admin_home_as_moderator( assert resp.status_code == status.HTTP_403_FORBIDDEN -@pytest.mark.skipif(E2E_MODE_DISABLED, reason="E2E mode disabled") +@pytest.mark.e2e async def test_admin_home_as_admin(e2e_client: "AsyncClient") -> None: resp = await e2e_client.get("/admin/") assert resp.status_code == status.HTTP_200_OK From 302cb3a5aaef76fe154baa82875e28adb1c307c4 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Fri, 17 Oct 2025 23:22:47 +0300 Subject: [PATCH 42/84] Replace constant with the marker to run E2E tests --- src/tests/app/main/test_routes.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/tests/app/main/test_routes.py b/src/tests/app/main/test_routes.py index 8aeaac7..0c777e9 100644 --- a/src/tests/app/main/test_routes.py +++ b/src/tests/app/main/test_routes.py @@ -3,8 +3,6 @@ import pytest from fastapi import status -from tests.conftest import E2E_MODE_DISABLED - if TYPE_CHECKING: from httpx import AsyncClient @@ -16,13 +14,13 @@ async def test_health(client: "AsyncClient") -> None: assert resp.status_code == status.HTTP_204_NO_CONTENT -@pytest.mark.skipif(E2E_MODE_DISABLED, reason="E2E mode disabled") +@pytest.mark.e2e async def test_health_as_unauthorized_user(e2e_client: "AsyncClient") -> None: resp = await e2e_client.get("/health") assert resp.status_code == status.HTTP_401_UNAUTHORIZED -@pytest.mark.skipif(E2E_MODE_DISABLED, reason="E2E mode disabled") +@pytest.mark.e2e async def test_health_as_authorized_user( authorized_user_client: "AsyncClient", ) -> None: @@ -30,7 +28,7 @@ async def test_health_as_authorized_user( assert resp.status_code == status.HTTP_403_FORBIDDEN -@pytest.mark.skipif(E2E_MODE_DISABLED, reason="E2E mode disabled") +@pytest.mark.e2e async def test_health_as_moderator( authorized_moderator_client: "AsyncClient", ) -> None: @@ -38,7 +36,7 @@ async def test_health_as_moderator( assert resp.status_code == status.HTTP_403_FORBIDDEN -@pytest.mark.skipif(E2E_MODE_DISABLED, reason="E2E mode disabled") +@pytest.mark.e2e async def test_health_as_admin(authorized_admin_client: "AsyncClient") -> None: resp = await authorized_admin_client.get("/health") assert resp.status_code == status.HTTP_204_NO_CONTENT From a2eb72e39ac048332bedf5fe122a0bcb5a7fde2a Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 00:08:40 +0300 Subject: [PATCH 43/84] Update command to run tests using pytest --- .github/workflows/checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f701291..ca7fdd5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -34,7 +34,7 @@ jobs: run: uv run mypy - name: Run Pytest with Coverage - run: uv run pytest --cov + run: uv run pytest --cov -m "not e2e" - name: Creating coverage folder run: mkdir -p coverage From 964b6ab0dc94bcfe44f7b256ef870d8aa4a54acb Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 13:35:54 +0300 Subject: [PATCH 44/84] Replace constants to specify types of JWT and cookies --- src/app/user/constants.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/app/user/constants.py b/src/app/user/constants.py index 959ff90..aec5373 100644 --- a/src/app/user/constants.py +++ b/src/app/user/constants.py @@ -1,15 +1,8 @@ from enum import StrEnum, auto -class TokenType(StrEnum): - """Types of JWT tokens.""" - - access = auto() - refresh = auto() - - -class CookieType(StrEnum): - """Types of cookies.""" +class TT(StrEnum): + """TT stands for "Token Type" and specifies types of JWT.""" access_token = auto() refresh_token = auto() From 504b783f0c9d0cff67c57d4acace5f58ee92daf7 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 13:36:27 +0300 Subject: [PATCH 45/84] Replace static names of JWT with related constants --- src/app/user/dependencies.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app/user/dependencies.py b/src/app/user/dependencies.py index 5435743..85aa3a2 100644 --- a/src/app/user/dependencies.py +++ b/src/app/user/dependencies.py @@ -6,6 +6,7 @@ from app.main.dependencies import DbSession, RedisT from app.main.settings import settings +from .constants import TT from .exceptions import ExpiredTokenError, IncorrectPasswordError from .middlewares import OAuth2PasswordBearerWithCookie from .models import User @@ -24,7 +25,7 @@ async def authenticate_user( :param form: input info about user from the form :param db: DB session - :raises IncorrectPasswordError: if the password isn't verified + :raises IncorrectPasswordError: if the password isn't valid :return: info about user from the database """ user = await User.get(db, form.username) @@ -52,9 +53,9 @@ async def verify_token_against_blacklist(request: Request, redis: RedisT) -> Non if not request.cookies: return async with redis.pipeline() as pipe: - if access_token := request.cookies.get("access_token"): + if access_token := request.cookies.get(TT.access_token.value): pipe.exists(access_token) - if refresh_token := request.cookies.get("refresh_token"): + if refresh_token := request.cookies.get(TT.refresh_token.value): pipe.exists(refresh_token) if any(await pipe.execute()): raise ExpiredTokenError @@ -74,11 +75,11 @@ async def add_tokens_to_blacklist(request: Request, redis: RedisT) -> None: if not request.cookies: return async with redis.pipeline() as pipe: - if access_token := request.cookies.get("access_token"): + if access_token := request.cookies.get(TT.access_token.value): pipe.setex( access_token, settings.access_token_cookie_expiration_time, "blacklist" ) - if refresh_token := request.cookies.get("refresh_token"): + if refresh_token := request.cookies.get(TT.refresh_token.value): pipe.setex( refresh_token, settings.refresh_token_cookie_expiration_time, "blacklist" ) From 3588f6c51df82c1257f60c37d2c956977714eb53 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 13:36:38 +0300 Subject: [PATCH 46/84] Replace static names of JWT with related constants --- src/app/user/middlewares.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/user/middlewares.py b/src/app/user/middlewares.py index e5e29fe..35f084d 100644 --- a/src/app/user/middlewares.py +++ b/src/app/user/middlewares.py @@ -5,6 +5,7 @@ from app.main.settings import settings +from .constants import TT from .exceptions import AuthenticationError @@ -38,14 +39,14 @@ async def __call__(self, request: Request) -> str | None: :return: access or refresh token """ # Check if access token is valid - access_token_cookie = request.cookies.get("access_token") + access_token_cookie = request.cookies.get(TT.access_token.value) access_token_scheme, access_token = get_authorization_scheme_param( access_token_cookie ) if access_token or access_token_scheme == settings.token_scheme: return access_token # Check if refresh token is valid - refresh_token_cookie = request.cookies.get("refresh_token") + refresh_token_cookie = request.cookies.get(TT.refresh_token.value) refresh_token_scheme, refresh_token = get_authorization_scheme_param( refresh_token_cookie ) From 3b1fcd5f92e86067876d7a0bc30a4af9ed73e567 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 13:37:09 +0300 Subject: [PATCH 47/84] Replace static names of JWT with related constants --- src/app/user/routes.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/user/routes.py b/src/app/user/routes.py index 1b77861..46287bd 100644 --- a/src/app/user/routes.py +++ b/src/app/user/routes.py @@ -4,7 +4,7 @@ from app.main.dependencies import DbSession -from .constants import CookieType, TokenType +from .constants import TT from .dependencies import ( add_tokens_to_blacklist, authenticate_user, @@ -32,7 +32,7 @@ async def logout(response: Response) -> None: Once the user is logged out, the authorization cookies are deleted and the tokens are blacklisted. """ - for cookie in CookieType: + for cookie in TT: response.delete_cookie(cookie.name) @@ -44,10 +44,10 @@ async def login( """Authenticate the user generating authorization cookies.""" if not user.is_active: raise InactiveUserError - access_token = generate_token(TokenType.access, user.username, user.role) - refresh_token = generate_token(TokenType.refresh, user.username, user.role) - access_token_cookie = UserCookie(key=CookieType.access_token, value=access_token) - refresh_token_cookie = UserCookie(key=CookieType.refresh_token, value=refresh_token) + access_token = generate_token(TT.access_token, user.username, user.role) + refresh_token = generate_token(TT.refresh_token, user.username, user.role) + access_token_cookie = UserCookie(key=TT.access_token, value=access_token) + refresh_token_cookie = UserCookie(key=TT.refresh_token, value=refresh_token) response.set_cookie(**access_token_cookie.model_dump()) response.set_cookie(**refresh_token_cookie.model_dump()) return Token.model_construct(access_token=access_token, refresh_token=refresh_token) @@ -83,7 +83,7 @@ async def auth( """ if x_original_uri and not verify_access(payload.role, x_original_uri): raise PermissionDenied - if payload.typ == TokenType.refresh: - access_token = generate_token(TokenType.access, payload.sub, payload.role) - access_token_cookie = UserCookie(key=CookieType.access_token, value=access_token) + if payload.typ == TT.refresh_token: + access_token = generate_token(TT.access_token, payload.sub, payload.role) + access_token_cookie = UserCookie(key=TT.access_token, value=access_token) response.set_cookie(**access_token_cookie.model_dump()) From e0f973f767b86c5d8738b74bc1f43e58a2e2df47 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 13:37:13 +0300 Subject: [PATCH 48/84] Replace static names of JWT with related constants --- src/app/user/schemas.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/user/schemas.py b/src/app/user/schemas.py index 38942de..ec9f0e0 100644 --- a/src/app/user/schemas.py +++ b/src/app/user/schemas.py @@ -15,7 +15,7 @@ from app.main.settings import settings -from .constants import CookieType, TokenType +from .constants import TT from .exceptions import PasswordsDontMatchError @@ -36,7 +36,7 @@ class TokenPayload(BaseModel): """Schema to provide info about payload of a JWT token.""" sub: str = Field(description="Subject of the token (user's username)") - typ: TokenType = Field(description="Type of the token (access or refresh)") + typ: TT = Field(description="Type of the token (access or refresh)") role: str = Field(description="Role of the user (admin, user, etc.)") jti: str = Field( default_factory=lambda: str(uuid4()), @@ -59,7 +59,7 @@ def set_exp(cls, now: datetime, info: ValidationInfo) -> datetime: """ exp = ( settings.access_token_expiration_time - if info.data["typ"] == TokenType.access + if info.data["typ"] == TT.access_token else settings.refresh_token_expiration_time ) return now + timedelta(minutes=exp) @@ -68,7 +68,7 @@ def set_exp(cls, now: datetime, info: ValidationInfo) -> datetime: class UserCookie(BaseModel): """Schema to set a cookie to store a JWT token.""" - key: CookieType | str = Field(description="Key (name) of the cookie") + key: TT | str = Field(description="Key (name) of the cookie") value: str = Field(description="Value of the cookie (JWT token)") domain: str = Field( default=settings.host_server_domain, @@ -89,7 +89,7 @@ class UserCookie(BaseModel): @field_validator("key") @classmethod - def key_to_str(cls, key: CookieType) -> str: + def key_to_str(cls, key: TT) -> str: """Convert key from Enum to str. :param key: enum key @@ -108,7 +108,7 @@ def set_cookie_expiration_time(cls, _: None, info: ValidationInfo) -> int: """ return ( settings.access_token_cookie_expiration_time - if info.data["key"] == CookieType.access_token + if info.data["key"] == TT.access_token else settings.refresh_token_cookie_expiration_time ) From 67e8fc6d98ad961969a37e20c1be528ad82dcb91 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 13:37:56 +0300 Subject: [PATCH 49/84] Replace constant to specify types of JWT --- src/app/user/security/tokens.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/user/security/tokens.py b/src/app/user/security/tokens.py index cf0ed6f..1ace46d 100644 --- a/src/app/user/security/tokens.py +++ b/src/app/user/security/tokens.py @@ -8,11 +8,11 @@ from app.user.schemas import TokenPayload if TYPE_CHECKING: - from app.user.constants import TokenType + from app.user.constants import TT -def generate_token(token_type: "TokenType", username: str, role: str) -> str: - """Generate a JWT token of the specific type and claims. +def generate_token(token_type: "TT", username: str, role: str) -> str: + """Generate JWT of the specified types and claims. :param token_type: type of the token (access or refresh) :param username: username to generate the token for From 17887709a7e4ae7f45d58f91dbcd88f2078296ec Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 13:38:04 +0300 Subject: [PATCH 50/84] Replace static names of JWT with related constants --- src/tests/app/user/test_routes.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/tests/app/user/test_routes.py b/src/tests/app/user/test_routes.py index 53931d6..f437642 100644 --- a/src/tests/app/user/test_routes.py +++ b/src/tests/app/user/test_routes.py @@ -6,7 +6,7 @@ from app.main.settings import settings from app.user import exceptions as ex -from app.user.constants import CookieType +from app.user.constants import TT if TYPE_CHECKING: from httpx import AsyncClient @@ -98,8 +98,8 @@ async def test_user_login_success(client: "AsyncClient", user: "NewUser") -> Non data={"username": user.username, "password": user.password}, ) assert login_resp.status_code == status.HTTP_200_OK - assert login_resp.cookies.get(CookieType.access_token.name) - assert login_resp.cookies.get(CookieType.refresh_token.name) + assert login_resp.cookies.get(TT.access_token.name) + assert login_resp.cookies.get(TT.refresh_token.name) login_resp_data = login_resp.json() assert "accessToken" in login_resp_data assert "refreshToken" in login_resp_data @@ -127,7 +127,7 @@ async def test_unauthorized_auth(client: "AsyncClient") -> None: async def test_corrupted_access_token(authorized_client: "AsyncClient") -> None: - access_token = CookieType.access_token.name + access_token = TT.access_token.name authorized_client.cookies[access_token] = ( f"{authorized_client.cookies[access_token]}." ) @@ -151,7 +151,7 @@ async def test_expired_access_token( data={"username": user.username, "password": user.password}, ) assert login_resp.status_code == status.HTTP_200_OK - assert CookieType.access_token.name in login_resp.cookies + assert TT.access_token.name in login_resp.cookies # copy authorization cookies into the http-client for further requests client.cookies = Cookies(dict(login_resp.cookies.items())) auth_resp = await client.get("/auth") @@ -176,12 +176,12 @@ async def test_expired_access_token_cookie( data={"username": user.username, "password": user.password}, ) assert login_resp.status_code == status.HTTP_200_OK - assert CookieType.access_token.name not in login_resp.cookies + assert TT.access_token.name not in login_resp.cookies # copy authorization cookies into the http-client for further requests client.cookies = Cookies(dict(login_resp.cookies.items())) auth_resp = await client.get("/auth") assert auth_resp.status_code == status.HTTP_204_NO_CONTENT - assert CookieType.access_token.name in auth_resp.headers["set-cookie"] + assert TT.access_token.name in auth_resp.headers["set-cookie"] async def test_expired_refresh_token( @@ -200,8 +200,8 @@ async def test_expired_refresh_token( data={"username": user.username, "password": user.password}, ) assert login_resp.status_code == status.HTTP_200_OK - assert CookieType.access_token.name in login_resp.cookies - assert CookieType.refresh_token.name in login_resp.cookies + assert TT.access_token.name in login_resp.cookies + assert TT.refresh_token.name in login_resp.cookies # copy authorization cookies into the http-client for further requests client.cookies = Cookies(dict(login_resp.cookies.items())) auth_resp = await client.get("/auth") @@ -230,7 +230,7 @@ async def test_expired_refresh_token_and_cookie( data={"username": user.username, "password": user.password}, ) assert login_resp.status_code == status.HTTP_200_OK - assert CookieType.access_token.name not in login_resp.cookies + assert TT.access_token.name not in login_resp.cookies # copy authorization cookies into the http-client for further requests client.cookies = Cookies(dict(login_resp.cookies.items())) auth_resp = await client.get("/auth") @@ -241,8 +241,8 @@ async def test_expired_refresh_token_and_cookie( async def test_logout_user(authorized_client: "AsyncClient") -> None: resp = await authorized_client.post("/logout") assert resp.status_code == status.HTTP_205_RESET_CONTENT - assert f'{CookieType.access_token.name}=""' in resp.headers["set-cookie"] - assert f'{CookieType.refresh_token.name}=""' in resp.headers["set-cookie"] + assert f'{TT.access_token.name}=""' in resp.headers["set-cookie"] + assert f'{TT.refresh_token.name}=""' in resp.headers["set-cookie"] assert not resp.cookies From bb7b3c35459a703c6e490237c1b3a3489f73b585 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 13:58:05 +0300 Subject: [PATCH 51/84] Update steps to login the user --- src/app/user/routes.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/app/user/routes.py b/src/app/user/routes.py index 46287bd..8911b64 100644 --- a/src/app/user/routes.py +++ b/src/app/user/routes.py @@ -11,7 +11,7 @@ decode_token, verify_token_against_blacklist, ) -from .exceptions import InactiveUserError, PermissionDenied +from .exceptions import PermissionDenied from .models import User as DBUser from .schemas import NewUser, Token, TokenPayload, User, UserCookie, UserInDB from .security.rbac import verify_access @@ -32,25 +32,23 @@ async def logout(response: Response) -> None: Once the user is logged out, the authorization cookies are deleted and the tokens are blacklisted. """ - for cookie in TT: - response.delete_cookie(cookie.name) + for token_type in TT: + response.delete_cookie(token_type.name) -@router.post("/login") +@router.post("/login", response_model=Token) async def login( response: Response, user: Annotated[UserInDB, Depends(authenticate_user)], -) -> Token: +) -> dict[str, str]: """Authenticate the user generating authorization cookies.""" - if not user.is_active: - raise InactiveUserError - access_token = generate_token(TT.access_token, user.username, user.role) - refresh_token = generate_token(TT.refresh_token, user.username, user.role) - access_token_cookie = UserCookie(key=TT.access_token, value=access_token) - refresh_token_cookie = UserCookie(key=TT.refresh_token, value=refresh_token) - response.set_cookie(**access_token_cookie.model_dump()) - response.set_cookie(**refresh_token_cookie.model_dump()) - return Token.model_construct(access_token=access_token, refresh_token=refresh_token) + tokens: dict[str, str] = {} + for token_type in TT: + token = generate_token(token_type, user.username, user.role) + cookie = UserCookie(key=token_type, value=token) + response.set_cookie(**cookie.model_dump()) + tokens[token_type.name] = token + return tokens @router.post("/signup", response_model=User) From b2c2f4adca0be87f9ccbdae7d1b5389b6e61e283 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 13:58:33 +0300 Subject: [PATCH 52/84] Add step to check if the user is active or not in authenticate_user dependency --- src/app/user/dependencies.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/user/dependencies.py b/src/app/user/dependencies.py index 85aa3a2..466772c 100644 --- a/src/app/user/dependencies.py +++ b/src/app/user/dependencies.py @@ -7,7 +7,7 @@ from app.main.settings import settings from .constants import TT -from .exceptions import ExpiredTokenError, IncorrectPasswordError +from .exceptions import ExpiredTokenError, InactiveUserError, IncorrectPasswordError from .middlewares import OAuth2PasswordBearerWithCookie from .models import User from .schemas import TokenPayload, UserInDB @@ -25,12 +25,15 @@ async def authenticate_user( :param form: input info about user from the form :param db: DB session - :raises IncorrectPasswordError: if the password isn't valid + :raises InactiveUserError: unless used is active + :raises IncorrectPasswordError: unless password is valid :return: info about user from the database """ user = await User.get(db, form.username) if not verify_password(form.password, user.password): raise IncorrectPasswordError + if not user.is_active: + raise InactiveUserError return user From 5b927a29f07bf41c331bde9bfe854671a1e95eb9 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 14:02:22 +0300 Subject: [PATCH 53/84] Update returned http status code of /signup route --- src/tests/app/user/test_routes.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/tests/app/user/test_routes.py b/src/tests/app/user/test_routes.py index f437642..e257e5d 100644 --- a/src/tests/app/user/test_routes.py +++ b/src/tests/app/user/test_routes.py @@ -22,7 +22,7 @@ async def test_signup_user_already_exists( ) -> None: user_dict = user.model_dump() resp = await client.post("/signup", json=user_dict) - assert resp.status_code == status.HTTP_200_OK + assert resp.status_code == status.HTTP_201_CREATED resp = await client.post("/signup", json=user_dict) assert resp.status_code == status.HTTP_409_CONFLICT assert "already exists" in resp.json()["detail"] @@ -65,7 +65,7 @@ async def test_user_login_wrong_username( user: "NewUser", ) -> None: signup_resp = await client.post("/signup", json=user.model_dump()) - assert signup_resp.status_code == status.HTTP_200_OK + assert signup_resp.status_code == status.HTTP_201_CREATED wrong_username = user.username + " " login_resp = await client.post( "/login", @@ -80,7 +80,7 @@ async def test_user_login_wrong_password( user: "NewUser", ) -> None: signup_resp = await client.post("/signup", json=user.model_dump()) - assert signup_resp.status_code == status.HTTP_200_OK + assert signup_resp.status_code == status.HTTP_201_CREATED wrong_password = user.password + " " login_resp = await client.post( "/login", @@ -92,7 +92,7 @@ async def test_user_login_wrong_password( async def test_user_login_success(client: "AsyncClient", user: "NewUser") -> None: signup_resp = await client.post("/signup", json=user.model_dump()) - assert signup_resp.status_code == status.HTTP_200_OK + assert signup_resp.status_code == status.HTTP_201_CREATED login_resp = await client.post( "/login", data={"username": user.username, "password": user.password}, @@ -111,7 +111,7 @@ async def test_inactive_user_login( deactivate_user: None, # noqa: ARG001 ) -> None: signup_resp = await client.post("/signup", json=user.model_dump()) - assert signup_resp.status_code == status.HTTP_200_OK + assert signup_resp.status_code == status.HTTP_201_CREATED login_resp = await client.post( "/login", data={"username": user.username, "password": user.password}, @@ -143,7 +143,7 @@ async def test_expired_access_token( ) -> None: """Test case when the access token (but not cookie) is expired.""" signup_resp = await client.post("/signup", json=user.model_dump()) - assert signup_resp.status_code == status.HTTP_200_OK + assert signup_resp.status_code == status.HTTP_201_CREATED # expire access token (not the cookie) monkeypatch.setattr("app.user.schemas.settings.access_token_expiration_time", -1) login_resp = await client.post( @@ -166,7 +166,7 @@ async def test_expired_access_token_cookie( ) -> None: """Test case when the cookie containing access token is expired.""" signup_resp = await client.post("/signup", json=user.model_dump()) - assert signup_resp.status_code == status.HTTP_200_OK + assert signup_resp.status_code == status.HTTP_201_CREATED # expire cookie containing access token monkeypatch.setattr( "app.user.schemas.settings.access_token_cookie_expiration_time", -1 @@ -191,7 +191,7 @@ async def test_expired_refresh_token( ) -> None: """Test case when the refresh token (but not cookie) is expired.""" signup_resp = await client.post("/signup", json=user.model_dump()) - assert signup_resp.status_code == status.HTTP_200_OK + assert signup_resp.status_code == status.HTTP_201_CREATED # expire both access and refresh tokens (not the cookies) monkeypatch.setattr("app.user.schemas.settings.access_token_expiration_time", -1) monkeypatch.setattr("app.user.schemas.settings.refresh_token_expiration_time", -1) @@ -216,7 +216,7 @@ async def test_expired_refresh_token_and_cookie( ) -> None: """Test case when the cookies containing access and refresh tokens are expired.""" signup_resp = await client.post("/signup", json=user.model_dump()) - assert signup_resp.status_code == status.HTTP_200_OK + assert signup_resp.status_code == status.HTTP_201_CREATED # expire cookies containing access and refresh tokens monkeypatch.setattr( From 868fee16e39e74ea77ee7407622630fa6b3a39b7 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 14:02:25 +0300 Subject: [PATCH 54/84] Update returned http status code of /signup route --- src/app/user/routes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/user/routes.py b/src/app/user/routes.py index 8911b64..eca989d 100644 --- a/src/app/user/routes.py +++ b/src/app/user/routes.py @@ -51,7 +51,11 @@ async def login( return tokens -@router.post("/signup", response_model=User) +@router.post( + "/signup", + response_model=User, + status_code=status.HTTP_201_CREATED, +) async def signup(user: NewUser, db: DbSession) -> NewUser: """Sign up a user.""" return await DBUser.create(db, user) From 95ba66b5c730c30576359331ef8e50a7e016cb7c Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 14:44:27 +0300 Subject: [PATCH 55/84] Update method to retrieve info about user from db --- src/app/user/models.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/app/user/models.py b/src/app/user/models.py index 473381b..7be78a7 100644 --- a/src/app/user/models.py +++ b/src/app/user/models.py @@ -45,18 +45,9 @@ async def get(cls, db: "AsyncSession", username: str) -> UserInDB: :raises UserNotFoundError: if the user doesn't exist in the database :return: info about user """ - cols = ( - cls.first_name, - cls.last_name, - cls.username, - cls.email, - cls.password, - cls.is_active, - cls.role, - ) - query = select(*cols).where(cls.username == username) - if user := (await db.execute(query)).one_or_none(): - return UserInDB.model_construct(**user._asdict()) # pyright: ignore[reportPrivateUsage] + stmt = select(cls).where(cls.username == username) + if user := (await db.execute(stmt)).scalar(): + return UserInDB(**user.__dict__) raise UserNotFoundError @classmethod From 1f77b7e43f302a9c3056e74b29817892aaae7789 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 19:55:02 +0300 Subject: [PATCH 56/84] Update headers --- src/app/main/exceptions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/main/exceptions.py b/src/app/main/exceptions.py index 9b3d213..86facec 100644 --- a/src/app/main/exceptions.py +++ b/src/app/main/exceptions.py @@ -1,7 +1,5 @@ from fastapi import HTTPException, status -from app.main.settings import settings - class BaseHTTPException(HTTPException): """Base HTTP exception class.""" @@ -19,5 +17,5 @@ def __init__( super().__init__( status_code or self.status_code, detail or self.detail, - headers or {"WWW-Authenticate": settings.token_scheme}, + headers or {"WWW-Authenticate": "Bearer"}, ) From 4c9138102e98f8c30b3712e80226e68475b155a8 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 19:55:18 +0300 Subject: [PATCH 57/84] Remove param to specify JWT scheme --- src/app/main/settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index e08093e..dd74086 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -102,7 +102,6 @@ class Settings(BaseSettings): refresh_token_cookie_expiration_time: int = ( refresh_token_expiration_time * 60 ) # seconds - token_scheme: str = "Bearer" # noqa: S105 # size of the cache to store payload of the corresponding tokens token_payload_max_cache_hits: int = 10_000 From 2bd1cb53dcb4075e76b43fa3ca96b1fec9a64091 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 19:56:02 +0300 Subject: [PATCH 58/84] Remove scheme from JWT generated in generate_token function --- src/app/user/security/tokens.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/user/security/tokens.py b/src/app/user/security/tokens.py index 1ace46d..780cfcd 100644 --- a/src/app/user/security/tokens.py +++ b/src/app/user/security/tokens.py @@ -20,12 +20,11 @@ def generate_token(token_type: "TT", username: str, role: str) -> str: :return: JWT token """ payload = TokenPayload(sub=username, typ=token_type, role=role) - token = jwt.encode( + return jwt.encode( claims=payload.model_dump(), key=settings.jwt_secret_key, algorithm=settings.jwt_algorithm, ) - return f"{settings.token_scheme} {token}" @lru_cache(maxsize=settings.token_payload_max_cache_hits) From 80474bfd26951c57c7197a5aca08532c4ab9b345 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 19:56:30 +0300 Subject: [PATCH 59/84] Modify dependency to decode JWT --- src/app/user/dependencies.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/app/user/dependencies.py b/src/app/user/dependencies.py index 466772c..bc61b28 100644 --- a/src/app/user/dependencies.py +++ b/src/app/user/dependencies.py @@ -7,15 +7,17 @@ from app.main.settings import settings from .constants import TT -from .exceptions import ExpiredTokenError, InactiveUserError, IncorrectPasswordError -from .middlewares import OAuth2PasswordBearerWithCookie +from .exceptions import ( + AuthenticationError, + ExpiredTokenError, + InactiveUserError, + IncorrectPasswordError, +) from .models import User from .schemas import TokenPayload, UserInDB from .security.passwords import verify_password from .security.tokens import get_token_payload -oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="user/login") - async def authenticate_user( form: Annotated[OAuth2PasswordRequestForm, Depends()], @@ -37,13 +39,18 @@ async def authenticate_user( return user -async def decode_token(token: Annotated[str, Depends(oauth2_scheme)]) -> TokenPayload: +async def decode_token(request: Request) -> TokenPayload: """Decode JWT token and return its payload. :param token: user's JWT token + :raises AuthenticationError: if both auth cookies are missing :return: payload of the token """ - return get_token_payload(token) + if access_token := request.cookies.get(TT.access_token.name): + return get_token_payload(access_token) + if refresh_token := request.cookies.get(TT.refresh_token.name): + return get_token_payload(refresh_token) + raise AuthenticationError async def verify_token_against_blacklist(request: Request, redis: RedisT) -> None: From e231438c7a34360bf85442db3eb1d9b44dce4bc0 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sat, 18 Oct 2025 19:56:37 +0300 Subject: [PATCH 60/84] Remove unused file --- src/app/user/middlewares.py | 55 ------------------------------------- 1 file changed, 55 deletions(-) delete mode 100644 src/app/user/middlewares.py diff --git a/src/app/user/middlewares.py b/src/app/user/middlewares.py deleted file mode 100644 index 35f084d..0000000 --- a/src/app/user/middlewares.py +++ /dev/null @@ -1,55 +0,0 @@ -from fastapi.openapi.models import OAuthFlowPassword, OAuthFlows -from fastapi.security import OAuth2 -from fastapi.security.utils import get_authorization_scheme_param -from starlette.requests import Request - -from app.main.settings import settings - -from .constants import TT -from .exceptions import AuthenticationError - - -class OAuth2PasswordBearerWithCookie(OAuth2): - """OAuth2 flow for authentication based on cookies.""" - - def __init__( - self, - tokenUrl: str, # noqa: N803 - scheme_name: str | None = None, - scopes: dict[str, str] | None = None, - auto_error: bool = True, - ) -> None: - """Init OAuth flow.""" - flows = OAuthFlows( - password=OAuthFlowPassword(tokenUrl=tokenUrl, scopes=scopes or {}) - ) - super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error) - - async def __call__(self, request: Request) -> str | None: - """Validate JWT token taken from the access or refresh token. - - If access token is missing (e.g. cookie is expired), then - refresh token will be validated. - - if both tokens are missing, 'AuthenticationError' will be raised - (user must re-login). - - :param request: request object providing access to cookies - :raises AuthenticationError: if tokens are invalid or corrupted - :return: access or refresh token - """ - # Check if access token is valid - access_token_cookie = request.cookies.get(TT.access_token.value) - access_token_scheme, access_token = get_authorization_scheme_param( - access_token_cookie - ) - if access_token or access_token_scheme == settings.token_scheme: - return access_token - # Check if refresh token is valid - refresh_token_cookie = request.cookies.get(TT.refresh_token.value) - refresh_token_scheme, refresh_token = get_authorization_scheme_param( - refresh_token_cookie - ) - if refresh_token or refresh_token_scheme == settings.token_scheme: - return refresh_token - raise AuthenticationError From 2ca2380307f4d7ae304ba32bddb7530a54e892f4 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 01:39:47 +0300 Subject: [PATCH 61/84] Add dependency to get instance of UsersDbService --- src/app/user/dependencies.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/app/user/dependencies.py b/src/app/user/dependencies.py index bc61b28..d26d9ee 100644 --- a/src/app/user/dependencies.py +++ b/src/app/user/dependencies.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, TypeAlias from fastapi import Depends, Request from fastapi.security import OAuth2PasswordRequestForm @@ -13,15 +13,23 @@ InactiveUserError, IncorrectPasswordError, ) -from .models import User from .schemas import TokenPayload, UserInDB from .security.passwords import verify_password from .security.tokens import get_token_payload +from .service import UsersDbService + + +async def get_user_service(session: DbSession) -> UsersDbService: + """Return instance of the UserService class using database session.""" + return UsersDbService(session) + + +UserServiceT: TypeAlias = Annotated[UsersDbService, Depends(get_user_service)] async def authenticate_user( form: Annotated[OAuth2PasswordRequestForm, Depends()], - db: DbSession, + service: UserServiceT, ) -> UserInDB: """Return info about user from the database verifying one's password. @@ -31,7 +39,7 @@ async def authenticate_user( :raises IncorrectPasswordError: unless password is valid :return: info about user from the database """ - user = await User.get(db, form.username) + user = await service.get_user(form.username) if not verify_password(form.password, user.password): raise IncorrectPasswordError if not user.is_active: From 3bc00504d2789bd2de1bee17653810ed4a6591d9 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 01:39:58 +0300 Subject: [PATCH 62/84] Move methods to get and create user to UsersDbService --- src/app/user/models.py | 49 +----------------------------------------- 1 file changed, 1 insertion(+), 48 deletions(-) diff --git a/src/app/user/models.py b/src/app/user/models.py index 7be78a7..55319c4 100644 --- a/src/app/user/models.py +++ b/src/app/user/models.py @@ -1,20 +1,11 @@ from datetime import datetime -from typing import TYPE_CHECKING -from sqlalchemy import Boolean, func, select +from sqlalchemy import Boolean, func from sqlalchemy.orm import Mapped, mapped_column from app.main.db import Base from .constants import UserRole -from .exceptions import UserNotFoundError -from .schemas import UserInDB -from .security.passwords import generate_password_hash - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - - from .schemas import NewUser class User(Base): @@ -35,41 +26,3 @@ class User(Base): server_default=func.now(), onupdate=func.now(), ) - - @classmethod - async def get(cls, db: "AsyncSession", username: str) -> UserInDB: - """Return info about user. - - :param db: DB session - :param username: username of the user - :raises UserNotFoundError: if the user doesn't exist in the database - :return: info about user - """ - stmt = select(cls).where(cls.username == username) - if user := (await db.execute(stmt)).scalar(): - return UserInDB(**user.__dict__) - raise UserNotFoundError - - @classmethod - async def create( - cls, - db: "AsyncSession", - user: "NewUser", - **kwargs: str, - ) -> "NewUser": - """Save info about new user into the database. - - :param db: DB session - :param user: info about new user - :raises UserAlreadyExistsError: if such user already exists - :return: info about user from the database - """ - db.add( - cls( - **user.model_dump(exclude={"password"}), - password=generate_password_hash(user.password), - **kwargs, - ) - ) - await db.commit() - return user From 61b8325bfa0313c2bd7fa9b443a30872b93edd72 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 01:40:11 +0300 Subject: [PATCH 63/84] Integrate UsersDbService to signup route --- src/app/user/routes.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/user/routes.py b/src/app/user/routes.py index eca989d..f14f9d3 100644 --- a/src/app/user/routes.py +++ b/src/app/user/routes.py @@ -2,17 +2,15 @@ from fastapi import APIRouter, Depends, Header, Response, status -from app.main.dependencies import DbSession - from .constants import TT from .dependencies import ( + UserServiceT, add_tokens_to_blacklist, authenticate_user, decode_token, verify_token_against_blacklist, ) from .exceptions import PermissionDenied -from .models import User as DBUser from .schemas import NewUser, Token, TokenPayload, User, UserCookie, UserInDB from .security.rbac import verify_access from .security.tokens import generate_token @@ -56,9 +54,9 @@ async def login( response_model=User, status_code=status.HTTP_201_CREATED, ) -async def signup(user: NewUser, db: DbSession) -> NewUser: +async def signup(user: NewUser, service: UserServiceT) -> NewUser: """Sign up a user.""" - return await DBUser.create(db, user) + return await service.create_user(user) @router.get( From 3ac714d52239a57d1d1f030aa7329f2f96f6a717 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 01:40:27 +0300 Subject: [PATCH 64/84] Implement separate service to manage users in the database --- src/app/user/service.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/app/user/service.py diff --git a/src/app/user/service.py b/src/app/user/service.py new file mode 100644 index 0000000..6730665 --- /dev/null +++ b/src/app/user/service.py @@ -0,0 +1,39 @@ +from typing import TYPE_CHECKING + +from sqlmodel import select + +from .exceptions import UserNotFoundError +from .models import User +from .schemas import UserInDB +from .security.passwords import generate_password_hash + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from .schemas import NewUser + + +class UsersDbService: + """Service to manage users in the database.""" + + def __init__(self, session: "AsyncSession") -> None: + """Define session object to perform database operations.""" + self.session = session + + async def get_user(self, username: str) -> UserInDB: + """Retrieve a user by username.""" + stmt = select(User).where(User.username == username) + if user := (await self.session.execute(stmt)).scalar(): + return UserInDB(**user.__dict__) + raise UserNotFoundError + + async def create_user(self, user: "NewUser", **kwargs: str) -> "NewUser": + """Save info about user into the database.""" + db_user = User( + **user.model_dump(exclude={"password"}), + password=generate_password_hash(user.password), + **kwargs, + ) + self.session.add(db_user) + await self.session.commit() + return user From 824ab787d8fac880684446d41f1b515c43f8e6c7 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:51:27 +0300 Subject: [PATCH 65/84] Move service for managing users in db to .services.users.py --- src/app/user/service.py | 39 --------------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 src/app/user/service.py diff --git a/src/app/user/service.py b/src/app/user/service.py deleted file mode 100644 index 6730665..0000000 --- a/src/app/user/service.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import TYPE_CHECKING - -from sqlmodel import select - -from .exceptions import UserNotFoundError -from .models import User -from .schemas import UserInDB -from .security.passwords import generate_password_hash - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - - from .schemas import NewUser - - -class UsersDbService: - """Service to manage users in the database.""" - - def __init__(self, session: "AsyncSession") -> None: - """Define session object to perform database operations.""" - self.session = session - - async def get_user(self, username: str) -> UserInDB: - """Retrieve a user by username.""" - stmt = select(User).where(User.username == username) - if user := (await self.session.execute(stmt)).scalar(): - return UserInDB(**user.__dict__) - raise UserNotFoundError - - async def create_user(self, user: "NewUser", **kwargs: str) -> "NewUser": - """Save info about user into the database.""" - db_user = User( - **user.model_dump(exclude={"password"}), - password=generate_password_hash(user.password), - **kwargs, - ) - self.session.add(db_user) - await self.session.commit() - return user From c967cfc20e21893ab7eddff0e4734b41663bb8f9 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:51:45 +0300 Subject: [PATCH 66/84] Add fixture to provide a db session object. --- src/tests/conftest.py | 111 +++++++++++++++++------------------------- 1 file changed, 45 insertions(+), 66 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 99a9875..58d7952 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -5,8 +5,7 @@ import pytest from asgi_lifespan import LifespanManager from fakeredis import FakeAsyncRedis -from fastapi import status -from httpx import ASGITransport, AsyncClient, Cookies +from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, @@ -20,12 +19,14 @@ from app.user.constants import UserRole from app.user.dependencies import authenticate_user from app.user.exceptions import InactiveUserError -from app.user.models import User from app.user.schemas import NewUser +from .common import add_user, authorize_client + if TYPE_CHECKING: from faker import Faker + engine = create_async_engine(settings.test_database_url) async_session = async_sessionmaker( engine, @@ -34,12 +35,6 @@ ) -# Globally override state objects of the main app -app.state.logger = logging.getLogger(settings.test_log_name) -app.state.engine = engine -app.state.redis = FakeAsyncRedis() - - @pytest.fixture(scope="session") def anyio_backend() -> str: """Backend (asyncio) for pytest to run async tests.""" @@ -56,8 +51,9 @@ async def migrate_db() -> AsyncIterator[None]: await conn.run_sync(Base.metadata.drop_all) -async def override_get_db() -> AsyncIterator[AsyncSession]: - """Override dependency for API routes to interact with db.""" +@pytest.fixture +async def session() -> AsyncIterator[AsyncSession]: + """Fixture to provide a database session object.""" async with async_session() as session: yield session @@ -68,13 +64,23 @@ async def client() -> AsyncIterator[AsyncClient]: :yield: async HTTP-client. """ + + async def override_get_db() -> AsyncIterator[AsyncSession]: + """Override dependency for API routes to interact with db.""" + async with async_session() as session: + yield session + + # Override state objects of the main app + app.state.logger = logging.getLogger(settings.test_log_name) + app.state.engine = engine + app.state.redis = FakeAsyncRedis() + + # Override dependencies of the main app app.dependency_overrides[get_db] = override_get_db async with LifespanManager(app) as manager: transport = ASGITransport(manager.app) - async with AsyncClient( - base_url=settings.test_client_base_url, - transport=transport, - ) as ac: + base_url = settings.test_client_base_url + async with AsyncClient(base_url=base_url, transport=transport) as ac: yield ac @@ -116,72 +122,45 @@ async def _deactivate_user() -> None: del app.dependency_overrides[authenticate_user] -async def create_user_by_role(user: NewUser, role: UserRole) -> NewUser: - """Create a user with the specified role for further testing. - - :param user: info about user - :param role: role of the user - :return: original info about user - """ - del user.repeat_password - async with async_session() as db: - await User.create(db, user, role=role.name) - return user - - @pytest.fixture -async def db_user(user: NewUser) -> NewUser: - """Save info about regular user into db. +async def db_user(session: AsyncSession, user: NewUser) -> NewUser: + """Save info about basic user into the database. :param user: info about user :return: info about user """ - return await create_user_by_role(user, role=UserRole.user) + return await add_user(session, user, role=UserRole.user) @pytest.fixture -async def db_moderator(user: NewUser) -> NewUser: - """Save info about moderator into db. +async def db_moderator(session: AsyncSession, user: NewUser) -> NewUser: + """Save info about moderator into the database.. :param user: info about moderator :return: info about moderator """ - return await create_user_by_role(user, role=UserRole.moderator) + return await add_user(session, user, role=UserRole.moderator) @pytest.fixture -async def db_admin(user: NewUser) -> NewUser: - """Save info about admin into db. +async def db_admin(session: AsyncSession, user: NewUser) -> NewUser: + """Save info about admin into the database.. :param user: info about admin :return: info about admin """ - return await create_user_by_role(user, role=UserRole.admin) - - -async def authorize_client(client: AsyncClient, user: NewUser) -> AsyncClient: - """Authorize a user, setting authorization cookies into the client. - - :param client: async HTTP-client - :param user: info about user - :return: async HTTP-client with authorization cookies - """ - credentials = {"username": user.username, "password": user.password} - login_resp = await client.post("/login", data=credentials) - assert login_resp.status_code == status.HTTP_200_OK - client.cookies = Cookies(dict(login_resp.cookies.items())) - return client + return await add_user(session, user, role=UserRole.admin) @pytest.fixture async def authorized_client(client: AsyncClient, db_user: NewUser) -> AsyncClient: - """Async HTTP-client with authorization cookies for a regular user. + """HTTP-client with authorization cookies for a basic user. NOTE: it's used for integration tests. - :param client: async HTTP-client for end-to-end tests - :param created_user: info about user saved into db beforehand - :return: async HTTP-client with authorization cookies for a regular user. + :param client: HTTP-client for end-to-end tests + :param db_user: info about user saved into db beforehand + :return: HTTP-client with authorization cookies for a regular user. """ return await authorize_client(client, db_user) @@ -191,13 +170,13 @@ async def authorized_user_client( e2e_client: AsyncClient, db_user: NewUser, ) -> AsyncClient: - """Async HTTP-client with authorization cookies for a regular user. + """HTTP-client with authorization cookies for a basic user. NOTE: it's used for end-to-end tests. - :param client: async HTTP-client for end-to-end tests - :param created_user: info about user saved into db beforehand - :return: async HTTP-client with authorization cookies for a regular user. + :param client: HTTP-client for end-to-end tests + :param db_user: info about user saved into db beforehand + :return: HTTP-client with authorization cookies for a regular user. """ return await authorize_client(e2e_client, db_user) @@ -207,13 +186,13 @@ async def authorized_moderator_client( e2e_client: AsyncClient, db_moderator: NewUser, ) -> AsyncClient: - """Async HTTP-client with authorization cookies for a moderator. + """HTTP-client with authorization cookies for a moderator. NOTE: it's used for end-to-end tests. - :param client: async HTTP-client for end-to-end tests + :param client: HTTP-client for end-to-end tests :param db_moderator: info about moderator saved into db beforehand - :return: async HTTP-client with authorization cookies for a moderator. + :return: HTTP-client with authorization cookies for a moderator. """ return await authorize_client(e2e_client, db_moderator) @@ -223,12 +202,12 @@ async def authorized_admin_client( e2e_client: AsyncClient, db_admin: NewUser, ) -> AsyncClient: - """Async HTTP-client with authorization cookies for an admin. + """HTTP-client with authorization cookies for an admin. NOTE: it's used for end-to-end tests. - :param client: async HTTP-client for end-to-end tests - :param db_moderator: info about admin saved into db beforehand - :return: async HTTP-client with authorization cookies for an admin. + :param client: HTTP-client for end-to-end tests + :param db_admin: info about admin saved into db beforehand + :return: HTTP-client with authorization cookies for an admin. """ return await authorize_client(e2e_client, db_admin) From 3fe1e143a5eb051719aedea02c3e92792f2ef928 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:51:55 +0300 Subject: [PATCH 67/84] Add comments --- src/app/main/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/main/app.py b/src/app/main/app.py index 643adbf..a9ac384 100644 --- a/src/app/main/app.py +++ b/src/app/main/app.py @@ -37,8 +37,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: logger = getattr(app.state, "logger", configure_logging()) redis = getattr(app.state, "redis", Redis(**settings.redis_kwargs)) + # Init admin app based on the provided database engine init_admin_app(app, db) + # Init state objects of the app app.state.logger = logger app.state.redis = redis From bc26fdac38e046c3a14b2b548662cc2cdebe7bd1 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:52:23 +0300 Subject: [PATCH 68/84] Add cached property to return content of policy.json file --- src/app/main/settings.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index dd74086..b9072f3 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -1,11 +1,15 @@ +import json import logging -from functools import lru_cache +from functools import cached_property, lru_cache from pathlib import Path -from typing import TypedDict +from typing import TypedDict, cast from pydantic_settings import BaseSettings from redis.asyncio import ConnectionPool +# type to describe content of the RBAC policy file +type RBACPolicyT = dict[str, dict[str, list[str]]] + class FastAPIKwargs(TypedDict): """Kwargs for FastAPI app.""" @@ -110,7 +114,13 @@ class Settings(BaseSettings): min_password_length: int = 5 # RBAC settings - user_policy_file: Path = Path(__file__).parents[2] / "policy.json" + rbac_policy_fp: Path = Path(__file__).parents[2] / "policy.json" + + @cached_property + def rbac_policy(self) -> RBACPolicyT: + """Read and return content of the policy.json file.""" + with self.rbac_policy_fp.open() as f: + return cast("RBACPolicyT", json.load(f)) @property def fastapi_kwargs(self) -> FastAPIKwargs: From a41586281db604670b1ac4a4e91cf7fb237a2cb8 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:52:46 +0300 Subject: [PATCH 69/84] Add type aliases for some dependencies --- src/app/user/dependencies.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/app/user/dependencies.py b/src/app/user/dependencies.py index d26d9ee..772a5fc 100644 --- a/src/app/user/dependencies.py +++ b/src/app/user/dependencies.py @@ -16,11 +16,15 @@ from .schemas import TokenPayload, UserInDB from .security.passwords import verify_password from .security.tokens import get_token_payload -from .service import UsersDbService +from .services.users import UsersDbService async def get_user_service(session: DbSession) -> UsersDbService: - """Return instance of the UserService class using database session.""" + """Return instance of the UserService class using database session. + + :param session: database session object + :return: instance of UsersDbService class + """ return UsersDbService(session) @@ -34,12 +38,12 @@ async def authenticate_user( """Return info about user from the database verifying one's password. :param form: input info about user from the form - :param db: DB session + :param service: instance of UsersDbService object :raises InactiveUserError: unless used is active :raises IncorrectPasswordError: unless password is valid :return: info about user from the database """ - user = await service.get_user(form.username) + user = await service.get(form.username) if not verify_password(form.password, user.password): raise IncorrectPasswordError if not user.is_active: @@ -47,6 +51,9 @@ async def authenticate_user( return user +UserT: TypeAlias = Annotated[UserInDB, Depends(authenticate_user)] + + async def decode_token(request: Request) -> TokenPayload: """Decode JWT token and return its payload. @@ -71,9 +78,9 @@ async def verify_token_against_blacklist(request: Request, redis: RedisT) -> Non if not request.cookies: return async with redis.pipeline() as pipe: - if access_token := request.cookies.get(TT.access_token.value): + if access_token := request.cookies.get(TT.access_token.name): pipe.exists(access_token) - if refresh_token := request.cookies.get(TT.refresh_token.value): + if refresh_token := request.cookies.get(TT.refresh_token.name): pipe.exists(refresh_token) if any(await pipe.execute()): raise ExpiredTokenError @@ -93,11 +100,11 @@ async def add_tokens_to_blacklist(request: Request, redis: RedisT) -> None: if not request.cookies: return async with redis.pipeline() as pipe: - if access_token := request.cookies.get(TT.access_token.value): + if access_token := request.cookies.get(TT.access_token.name): pipe.setex( access_token, settings.access_token_cookie_expiration_time, "blacklist" ) - if refresh_token := request.cookies.get(TT.refresh_token.value): + if refresh_token := request.cookies.get(TT.refresh_token.name): pipe.setex( refresh_token, settings.refresh_token_cookie_expiration_time, "blacklist" ) From 54f9d4bda13024b93342ac3a7812a663c8fd0ce7 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:53:56 +0300 Subject: [PATCH 70/84] Add 'credentials' property to 'NewUser' schema --- src/app/user/schemas.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/user/schemas.py b/src/app/user/schemas.py index ec9f0e0..626e565 100644 --- a/src/app/user/schemas.py +++ b/src/app/user/schemas.py @@ -66,7 +66,7 @@ def set_exp(cls, now: datetime, info: ValidationInfo) -> datetime: class UserCookie(BaseModel): - """Schema to set a cookie to store a JWT token.""" + """Schema to set a cookie to store a JWT.""" key: TT | str = Field(description="Key (name) of the cookie") value: str = Field(description="Value of the cookie (JWT token)") @@ -145,6 +145,12 @@ class NewUser(BaseUser): password: str = PasswordField repeat_password: str = PasswordField + @property + def credentials(self) -> dict[str, str]: + """Read-only property returning user credentials for login.""" + return {"username": self.username, "password": self.password} + + # del user.repeat_password @model_validator(mode="after") def check_passwords_match(self) -> Self: """Check if the original and repeated passwords match.""" From 8acc81fe084a6caa285f6481e072535c60c0e725 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:54:37 +0300 Subject: [PATCH 71/84] Move 'UsersDbService' into a 'services' module --- src/app/user/services/users.py | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/app/user/services/users.py diff --git a/src/app/user/services/users.py b/src/app/user/services/users.py new file mode 100644 index 0000000..3b45dbf --- /dev/null +++ b/src/app/user/services/users.py @@ -0,0 +1,39 @@ +from typing import TYPE_CHECKING + +from sqlmodel import select + +from app.user.exceptions import UserNotFoundError +from app.user.models import User +from app.user.schemas import UserInDB +from app.user.security.passwords import generate_password_hash + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from app.user.schemas import NewUser + + +class UsersDbService: + """Service for managing users in the database.""" + + def __init__(self, session: "AsyncSession") -> None: + """Define session object to perform database operations.""" + self.session = session + + async def get(self, username: str) -> UserInDB: + """Retrieve a user by username.""" + stmt = select(User).where(User.username == username) + if user := (await self.session.execute(stmt)).scalar(): + return UserInDB(**user.__dict__) + raise UserNotFoundError + + async def create(self, user: "NewUser", **kwargs: str) -> "NewUser": + """Save info about user into the database.""" + db_user = User( + **user.model_dump(exclude={"password"}), + password=generate_password_hash(user.password), + **kwargs, + ) + self.session.add(db_user) + await self.session.commit() + return user From 57e26ff0b5b967b0331f0c076f58d33dd33a7e10 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:55:33 +0300 Subject: [PATCH 72/84] Use aliases for dependencies --- src/app/user/routes.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/app/user/routes.py b/src/app/user/routes.py index f14f9d3..257917e 100644 --- a/src/app/user/routes.py +++ b/src/app/user/routes.py @@ -5,13 +5,13 @@ from .constants import TT from .dependencies import ( UserServiceT, + UserT, add_tokens_to_blacklist, - authenticate_user, decode_token, verify_token_against_blacklist, ) from .exceptions import PermissionDenied -from .schemas import NewUser, Token, TokenPayload, User, UserCookie, UserInDB +from .schemas import NewUser, Token, TokenPayload, User, UserCookie from .security.rbac import verify_access from .security.tokens import generate_token @@ -25,7 +25,7 @@ dependencies=[Depends(add_tokens_to_blacklist)], ) async def logout(response: Response) -> None: - """Log out the authenticated user. + """Log out authenticated user. Once the user is logged out, the authorization cookies are deleted and the tokens are blacklisted. @@ -35,11 +35,8 @@ async def logout(response: Response) -> None: @router.post("/login", response_model=Token) -async def login( - response: Response, - user: Annotated[UserInDB, Depends(authenticate_user)], -) -> dict[str, str]: - """Authenticate the user generating authorization cookies.""" +async def login(response: Response, user: UserT) -> dict[str, str]: + """Authenticate user generating authorization cookies.""" tokens: dict[str, str] = {} for token_type in TT: token = generate_token(token_type, user.username, user.role) @@ -56,7 +53,7 @@ async def login( ) async def signup(user: NewUser, service: UserServiceT) -> NewUser: """Sign up a user.""" - return await service.create_user(user) + return await service.create(user) @router.get( @@ -70,7 +67,7 @@ async def auth( payload: Annotated[TokenPayload, Depends(decode_token)], x_original_uri: Annotated[str | None, Header()] = None, ) -> None: - """Verify authorization tokens (access or refresh). + """Verify authorization cookies. Note: 1) it's used by Nginx Subrequest module to allow/disallow @@ -84,6 +81,6 @@ async def auth( if x_original_uri and not verify_access(payload.role, x_original_uri): raise PermissionDenied if payload.typ == TT.refresh_token: - access_token = generate_token(TT.access_token, payload.sub, payload.role) - access_token_cookie = UserCookie(key=TT.access_token, value=access_token) - response.set_cookie(**access_token_cookie.model_dump()) + token = generate_token(TT.access_token, payload.sub, payload.role) + cookie = UserCookie(key=TT.access_token, value=token) + response.set_cookie(**cookie.model_dump()) From 5e48da2722808e5fadff6bf25026a4075c9b137b Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:55:39 +0300 Subject: [PATCH 73/84] Init module --- src/app/user/services/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/app/user/services/__init__.py diff --git a/src/app/user/services/__init__.py b/src/app/user/services/__init__.py new file mode 100644 index 0000000..e69de29 From eaaba142de83715200a46bc6a28f7b54aefdce42 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:59:10 +0300 Subject: [PATCH 74/84] Add common function to add a user and authorize http client --- src/tests/common.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/tests/common.py diff --git a/src/tests/common.py b/src/tests/common.py new file mode 100644 index 0000000..c2e665e --- /dev/null +++ b/src/tests/common.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING + +from fastapi import status +from httpx import Cookies + +from app.user.services.users import UsersDbService + +if TYPE_CHECKING: + from httpx import AsyncClient + from sqlalchemy.ext.asyncio import AsyncSession + + from app.user.constants import UserRole + from app.user.schemas import NewUser + + +async def add_user( + session: "AsyncSession", + user: "NewUser", + role: "UserRole", +) -> "NewUser": + """Save user with the specified role into the database. + + :param user: info about user + :param role: role of the user + :return: original info about user + """ + del user.repeat_password + service = UsersDbService(session) + return await service.create(user, role=role.name) + + +async def authorize_client(client: "AsyncClient", user: "NewUser") -> "AsyncClient": + """Authorize the user, adding authorization cookies into the client. + + :param client: async HTTP-client + :param user: info about user + :return: async HTTP-client with authorization cookies + """ + resp = await client.post("/login", data=user.credentials) + assert resp.status_code == status.HTTP_200_OK + client.cookies = Cookies(dict(resp.cookies.items())) + return client From e291ff66adef91040ff70afa86b1ee2cf36b6341 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Sun, 19 Oct 2025 23:59:42 +0300 Subject: [PATCH 75/84] Move json policy declaration to settings.py --- src/app/user/security/rbac.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/app/user/security/rbac.py b/src/app/user/security/rbac.py index 076aff8..4862f33 100644 --- a/src/app/user/security/rbac.py +++ b/src/app/user/security/rbac.py @@ -1,15 +1,12 @@ -import json import re from functools import lru_cache -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from app.main.settings import settings if TYPE_CHECKING: from app.user.constants import UserRole -type PolicyT = dict[str, dict[str, list[str]]] - @lru_cache def verify_access(role: "UserRole", url: str) -> bool: @@ -19,15 +16,6 @@ def verify_access(role: "UserRole", url: str) -> bool: :param url: target URL to check access to :return: boolean result of the verification """ - policy = read_user_policy() - return any(re.match(pattern, url) for pattern in policy[role]["locations"]) - - -@lru_cache -def read_user_policy() -> PolicyT: - """Read user policy file and return its content as JSON. - - :return: user policy as JSON - """ - with settings.user_policy_file.open() as f: - return cast("PolicyT", json.load(f)) + return any( + re.match(pattern, url) for pattern in settings.rbac_policy[role]["locations"] + ) From fc379b552504600fabe152d5e4d336aa71176bc0 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Mon, 20 Oct 2025 14:17:51 +0300 Subject: [PATCH 76/84] Update docstrings --- src/app/main/app.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/main/app.py b/src/app/main/app.py index a9ac384..b04167f 100644 --- a/src/app/main/app.py +++ b/src/app/main/app.py @@ -21,17 +21,16 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Init and release objects on app startup and shutdown. On startup: - - init application logger object; - - init Redis client with connection pool; - - init Admin app with the specified database engine; + - add logger object to the application state; + - add Redis client object to the application state; + - init Admin app with the provided database engine; On shutdown: - - dispose Redis client with connection pool; + - dispose Redis client including its connection pool; - dispose database engine. NOTE: during testing state objects are redeclared to adapt the application to the testing environment. - See: tests/conftest.py """ db = getattr(app.state, "engine", engine) logger = getattr(app.state, "logger", configure_logging()) From bfd79e030446ba55bff026add39cc701242e7a91 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Mon, 20 Oct 2025 15:08:43 +0300 Subject: [PATCH 77/84] Update docstrings and descriptions --- src/app/user/schemas.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/app/user/schemas.py b/src/app/user/schemas.py index 626e565..1a106ed 100644 --- a/src/app/user/schemas.py +++ b/src/app/user/schemas.py @@ -26,36 +26,36 @@ class BaseCustomModel(BaseModel): class Token(BaseCustomModel): - """Schema to provide info about access and refresh JWT tokens.""" + """Schema to provide info about access and refresh JWTs.""" - access_token: str = Field(description="JWT access token") - refresh_token: str = Field(description="JWT refresh token") + access_token: str = Field(description="access JWT") + refresh_token: str = Field(description="refresh JWT") class TokenPayload(BaseModel): - """Schema to provide info about payload of a JWT token.""" + """Schema to provide info about payload of a JWT.""" - sub: str = Field(description="Subject of the token (user's username)") - typ: TT = Field(description="Type of the token (access or refresh)") + sub: str = Field(description="Subject of the JWT (user's username)") + typ: TT = Field(description="Type of the JWT (access or refresh)") role: str = Field(description="Role of the user (admin, user, etc.)") jti: str = Field( default_factory=lambda: str(uuid4()), - description="JWT ID (unique identifier of the token)", + description="JWT ID (unique identifier of the JWT)", ) exp: datetime = Field( default_factory=lambda: datetime.now(UTC), validate_default=True, - description="Expiration date and time of the token", + description="Expiration date and time of the JWT", ) @field_validator("exp") @classmethod def set_exp(cls, now: datetime, info: ValidationInfo) -> datetime: - """Set expiration date of the token based on its type. + """Set expiration date and time of the JWT based on its type. :param now: value of the attribute :param info: all schema values - :return: expiration date of the token based on its type + :return: expiration date and time of the JWT based on its type """ exp = ( settings.access_token_expiration_time @@ -69,7 +69,7 @@ class UserCookie(BaseModel): """Schema to set a cookie to store a JWT.""" key: TT | str = Field(description="Key (name) of the cookie") - value: str = Field(description="Value of the cookie (JWT token)") + value: str = Field(description="Value of the cookie (JWT)") domain: str = Field( default=settings.host_server_domain, description="Domain of the server the cookie is associated with", @@ -100,11 +100,11 @@ def key_to_str(cls, key: TT) -> str: @field_validator("expires", "max_age", mode="before") @classmethod def set_cookie_expiration_time(cls, _: None, info: ValidationInfo) -> int: - """Set expiration time of the cookie (in seconds) based on its type. + """Set expiration date and time of the cookie (in seconds) based on its type. :param _: value of the attribute :param info: all schema values - :return: expiration time of the cookie based on its type + :return: expiration date and time of the cookie based on its type """ return ( settings.access_token_cookie_expiration_time From bac5f00cbcb919210ec462be30a24ba8dccc20b0 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Mon, 20 Oct 2025 15:08:59 +0300 Subject: [PATCH 78/84] Update steps to define the logger --- src/app/main/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/main/app.py b/src/app/main/app.py index b04167f..a30738a 100644 --- a/src/app/main/app.py +++ b/src/app/main/app.py @@ -33,7 +33,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: application to the testing environment. """ db = getattr(app.state, "engine", engine) - logger = getattr(app.state, "logger", configure_logging()) + logger = getattr( + app.state, + "logger", + configure_logging(settings.log_name, options=settings.logging_kwargs), + ) redis = getattr(app.state, "redis", Redis(**settings.redis_kwargs)) # Init admin app based on the provided database engine From d211007209bfc1ceb843b978335ec15fff948892 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Mon, 20 Oct 2025 15:10:03 +0300 Subject: [PATCH 79/84] Replace static logger configuration with input args --- src/app/main/logger.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app/main/logger.py b/src/app/main/logger.py index 9c51d94..016a38a 100644 --- a/src/app/main/logger.py +++ b/src/app/main/logger.py @@ -1,13 +1,14 @@ import logging from typing import TYPE_CHECKING -from .settings import settings - if TYPE_CHECKING: from logging import Logger + from .settings import LoggingKwargs + -def configure_logging() -> "Logger": +def configure_logging(name: str, options: "LoggingKwargs | None" = None) -> "Logger": """Configure app logging and return logger object.""" - logging.basicConfig(**settings.logging_kwargs) - return logging.getLogger(settings.log_name) + if options is not None: + logging.basicConfig(**options) + return logging.getLogger(name) From b9f6aca74a4096d7a1ed128fb59ebf5c9f554a3a Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Mon, 20 Oct 2025 15:10:19 +0300 Subject: [PATCH 80/84] Update the way to define logger for testing --- src/tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 58d7952..7dc0edd 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -1,4 +1,3 @@ -import logging from collections.abc import AsyncIterator from typing import TYPE_CHECKING @@ -15,6 +14,7 @@ from app.main.app import app from app.main.db import Base from app.main.dependencies import get_db +from app.main.logger import configure_logging from app.main.settings import settings from app.user.constants import UserRole from app.user.dependencies import authenticate_user @@ -71,7 +71,7 @@ async def override_get_db() -> AsyncIterator[AsyncSession]: yield session # Override state objects of the main app - app.state.logger = logging.getLogger(settings.test_log_name) + app.state.logger = configure_logging(settings.test_log_name) app.state.engine = engine app.state.redis = FakeAsyncRedis() From 83f02c9e40c0f988e57db6246499de0a4ac93fbc Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Mon, 20 Oct 2025 15:10:35 +0300 Subject: [PATCH 81/84] Add new JWT settings --- src/app/main/settings.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/main/settings.py b/src/app/main/settings.py index b9072f3..f1f4b61 100644 --- a/src/app/main/settings.py +++ b/src/app/main/settings.py @@ -73,7 +73,7 @@ class Settings(BaseSettings): log_level: int = logging.INFO log_format: str = "%(levelname)s - %(name)s - %(asctime)s - %(message)s" log_datefmt: str = "%Y-%m-%d %H:%M:%S" - # name of the logger used in testing + # name of the logger used when running tests test_log_name: str = "test" host_server_domain: str = "localhost" @@ -96,8 +96,6 @@ class Settings(BaseSettings): # JWT settings jwt_algorithm: str = "HS256" jwt_secret_key: str = "" - - # JWT Tokens settings access_token_expiration_time: int = 5 # minutes access_token_cookie_expiration_time: int = ( access_token_expiration_time * 60 @@ -106,8 +104,10 @@ class Settings(BaseSettings): refresh_token_cookie_expiration_time: int = ( refresh_token_expiration_time * 60 ) # seconds - # size of the cache to store payload of the corresponding tokens + # size of the cache to store payload of JWTs token_payload_max_cache_hits: int = 10_000 + # specifies value of JWTs in Redis to avoid their reuse + jwt_blacklist_name: str = "blacklist" # Passwords settings max_password_length: int = 50 @@ -165,9 +165,8 @@ def cors_kwargs(self) -> CORSMiddlewareKwargs: @property def redis_kwargs(self) -> RedisKwargs: """Kwargs for Redis client.""" - return RedisKwargs( - connection_pool=ConnectionPool.from_url(self.redis_url, decode_responses=True), - ) + connection_pool = ConnectionPool.from_url(self.redis_url, decode_responses=True) + return RedisKwargs(connection_pool=connection_pool) @lru_cache From 58c338357cf0b2ba8988a7c905f8d97abe224b1b Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Mon, 20 Oct 2025 15:10:51 +0300 Subject: [PATCH 82/84] Rename dependencies. Update docstrings --- src/app/user/dependencies.py | 43 ++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/app/user/dependencies.py b/src/app/user/dependencies.py index 772a5fc..d3d9e7b 100644 --- a/src/app/user/dependencies.py +++ b/src/app/user/dependencies.py @@ -20,7 +20,7 @@ async def get_user_service(session: DbSession) -> UsersDbService: - """Return instance of the UserService class using database session. + """Return instance of the UserService class. :param session: database session object :return: instance of UsersDbService class @@ -39,8 +39,8 @@ async def authenticate_user( :param form: input info about user from the form :param service: instance of UsersDbService object - :raises InactiveUserError: unless used is active - :raises IncorrectPasswordError: unless password is valid + :raises InactiveUserError: unless the user is active + :raises IncorrectPasswordError: unless the password is verified :return: info about user from the database """ user = await service.get(form.username) @@ -54,12 +54,12 @@ async def authenticate_user( UserT: TypeAlias = Annotated[UserInDB, Depends(authenticate_user)] -async def decode_token(request: Request) -> TokenPayload: - """Decode JWT token and return its payload. +async def decode_jwt(request: Request) -> TokenPayload: + """Decode JWT and return its payload. - :param token: user's JWT token - :raises AuthenticationError: if both auth cookies are missing - :return: payload of the token + :param request: FastAPI's Request object + :raises AuthenticationError: if auth cookies are missing + :return: payload of the JWT """ if access_token := request.cookies.get(TT.access_token.name): return get_token_payload(access_token) @@ -68,12 +68,12 @@ async def decode_token(request: Request) -> TokenPayload: raise AuthenticationError -async def verify_token_against_blacklist(request: Request, redis: RedisT) -> None: - """Check if JWT tokens aren't blacklisted. +async def is_jwt_blacklisted(request: Request, redis: RedisT) -> None: + """Check if JWTs are blacklisted. :param request: FastAPI's Request object - :raises ExpiredTokenError: if any token is blacklisted - :param redis: Redis client + :raises ExpiredTokenError: if any JWT is blacklisted + :param redis: client for Redis """ if not request.cookies: return @@ -86,26 +86,27 @@ async def verify_token_against_blacklist(request: Request, redis: RedisT) -> Non raise ExpiredTokenError -async def add_tokens_to_blacklist(request: Request, redis: RedisT) -> None: - """Add access and/or refresh tokens into black list. - - Tokens are blacklisted to avoid their reuse. +async def blacklist_jwt(request: Request, redis: RedisT) -> None: + """Add access and/or refresh JWT into the black list to avoid their reuse. - NOTE (1): tokens are blacklisted until their corresponding - cookies aren't expired. + NOTE (1): tokens are blacklisted until their corresponding cookies are expired. :param request: FastAPI's Request object - :param redis: Redis client + :param redis: client for Redis """ if not request.cookies: return async with redis.pipeline() as pipe: if access_token := request.cookies.get(TT.access_token.name): pipe.setex( - access_token, settings.access_token_cookie_expiration_time, "blacklist" + access_token, + settings.access_token_cookie_expiration_time, + settings.jwt_blacklist_name, ) if refresh_token := request.cookies.get(TT.refresh_token.name): pipe.setex( - refresh_token, settings.refresh_token_cookie_expiration_time, "blacklist" + refresh_token, + settings.refresh_token_cookie_expiration_time, + settings.jwt_blacklist_name, ) await pipe.execute() From df03ee234f1a423561e39d58da61bd129000e4c8 Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Mon, 20 Oct 2025 15:11:06 +0300 Subject: [PATCH 83/84] Sync names of the renamed dependencies --- src/app/user/routes.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/user/routes.py b/src/app/user/routes.py index 257917e..e1a831a 100644 --- a/src/app/user/routes.py +++ b/src/app/user/routes.py @@ -6,9 +6,9 @@ from .dependencies import ( UserServiceT, UserT, - add_tokens_to_blacklist, - decode_token, - verify_token_against_blacklist, + blacklist_jwt, + decode_jwt, + is_jwt_blacklisted, ) from .exceptions import PermissionDenied from .schemas import NewUser, Token, TokenPayload, User, UserCookie @@ -22,7 +22,7 @@ "/logout", status_code=status.HTTP_205_RESET_CONTENT, response_class=Response, - dependencies=[Depends(add_tokens_to_blacklist)], + dependencies=[Depends(blacklist_jwt)], ) async def logout(response: Response) -> None: """Log out authenticated user. @@ -58,13 +58,13 @@ async def signup(user: NewUser, service: UserServiceT) -> NewUser: @router.get( "/auth", - dependencies=[Depends(verify_token_against_blacklist)], + dependencies=[Depends(is_jwt_blacklisted)], status_code=status.HTTP_204_NO_CONTENT, include_in_schema=False, ) async def auth( response: Response, - payload: Annotated[TokenPayload, Depends(decode_token)], + payload: Annotated[TokenPayload, Depends(decode_jwt)], x_original_uri: Annotated[str | None, Header()] = None, ) -> None: """Verify authorization cookies. From 118b887e9d0906cfc90c3e410e1bf307a0b462bc Mon Sep 17 00:00:00 2001 From: prathamlahoti123 Date: Mon, 20 Oct 2025 15:12:18 +0300 Subject: [PATCH 84/84] Fix the wrong way to save a user into db --- src/scripts/create_user.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/scripts/create_user.py b/src/scripts/create_user.py index 4ffaf01..d1df9ae 100644 --- a/src/scripts/create_user.py +++ b/src/scripts/create_user.py @@ -4,8 +4,8 @@ from app.main.db import async_session from app.user.constants import UserRole -from app.user.models import User from app.user.schemas import NewUser +from app.user.services.users import UsersDbService app = typer.Typer() @@ -36,8 +36,9 @@ async def save_user(user: NewUser, role: UserRole) -> None: :param user: info about user to be saved :return: None """ - async with async_session() as db: - await User.create(db, user, role=role.name) + async with async_session() as session: + service = UsersDbService(session) + await service.create(user, role=role.name) if __name__ == "__main__":