From 82e50b5f2837aaf7be848ccf69335826558fcfa5 Mon Sep 17 00:00:00 2001 From: CaptainAril Date: Tue, 22 Apr 2025 00:49:32 +0100 Subject: [PATCH 1/3] queue implementation for user registration and email verification update to user-service --- pyproject.toml | 3 ++- src/api/services/AuthService.py | 10 ++++++++++ src/config/apps.py | 1 + uv.lock | 24 +++++++++++++++++++----- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 16087cd..5182c82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,8 @@ dependencies = [ "pika>=1.3.2", "faststream[rabbit]>=0.5.39", "starlette>=0.46.2", - "uvicorn>=0.34.1", + "django-extensions>=4.1", + "uvicorn>=0.34.2", ] [dependency-groups] diff --git a/src/api/services/AuthService.py b/src/api/services/AuthService.py index 607ddb5..e6018ec 100644 --- a/src/api/services/AuthService.py +++ b/src/api/services/AuthService.py @@ -1,6 +1,7 @@ from typing import Annotated from src.utils.svcs import Service +from src.config.asgi import broker from src.utils.logger import Logger from src.api.typing.JWT import JWTSuccess from src.api.typing.UserExists import UserExists @@ -60,6 +61,11 @@ async def register(self, req: CreateUserRequest) -> UserExists: created_user = await UserRepository.add(req) + user_data = {"id": created_user.id, "email": created_user.email} + queue = "create-user" + + await broker.publish(message=user_data, queue=queue, persist=True) + await self.otp_service.send_otp(created_user.id) user = self.utility_service.sanitize_user_object(created_user) @@ -120,6 +126,10 @@ async def validate_email(self, req: AuthenticateUserOtp) -> bool: await UserRepository.update_by_user( user, {"is_active": True, "is_enabled": True, "is_validated": True} ) + user_data = {"id": user.id, "email": user.email} + queue = "validate-user" + await broker.publish(message=user_data, queue=queue, persist=True) + return True async def login(self, req: AuthenticateUserRequest) -> UserSuccess: diff --git a/src/config/apps.py b/src/config/apps.py index 963d990..be8962e 100644 --- a/src/config/apps.py +++ b/src/config/apps.py @@ -13,6 +13,7 @@ THIRD_PARTY_APPS = [ "corsheaders", + "django_extensions", ] INSTALLED_APPS = [ diff --git a/uv.lock b/uv.lock index f3e71bb..a1670f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.12" [[package]] @@ -151,12 +152,12 @@ wheels = [ [[package]] name = "df-wallet-auth-service" -version = "1.0.0" source = { virtual = "." } dependencies = [ { name = "bcrypt" }, { name = "django" }, { name = "django-cors-headers" }, + { name = "django-extensions" }, { name = "django-mongodb-backend" }, { name = "django-ninja" }, { name = "faker" }, @@ -188,6 +189,7 @@ requires-dist = [ { name = "bcrypt", specifier = ">=4.3.0" }, { name = "django", specifier = ">=5.1.6" }, { name = "django-cors-headers", specifier = ">=4.7.0" }, + { name = "django-extensions", specifier = ">=4.1" }, { name = "django-mongodb-backend", specifier = ">=5.1.0b0" }, { name = "django-ninja", specifier = ">=1.3.0" }, { name = "faker", specifier = ">=36.1.1" }, @@ -202,7 +204,7 @@ requires-dist = [ { name = "redis", specifier = ">=5.2.1" }, { name = "starlette", specifier = ">=0.46.2" }, { name = "svcs", specifier = ">=25.1.0" }, - { name = "uvicorn", specifier = ">=0.34.1" }, + { name = "uvicorn", specifier = ">=0.34.2" }, ] [package.metadata.requires-dev] @@ -250,6 +252,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/a2/7bcfff86314bd9dd698180e31ba00604001606efb518a06cca6833a54285/django_cors_headers-4.7.0-py3-none-any.whl", hash = "sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070", size = 12794 }, ] +[[package]] +name = "django-extensions" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980 }, +] + [[package]] name = "django-mongodb-backend" version = "5.1.0b1" @@ -970,15 +984,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.34.1" +version = "0.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/37/dd92f1f9cedb5eaf74d9999044306e06abe65344ff197864175dbbd91871/uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc", size = 76755 } +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/38/a5801450940a858c102a7ad9e6150146a25406a119851c993148d56ab041/uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065", size = 62404 }, + { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 }, ] [[package]] From adcc92b11b0d9475c497999fa692fddfc8922087 Mon Sep 17 00:00:00 2001 From: CaptainAril Date: Mon, 28 Apr 2025 14:22:24 +0100 Subject: [PATCH 2/3] mearge: feat/user-registration --- .env.example | 11 ++++- src/api/routes/__init__.py | 57 +++++++++++++++++++++- src/api/services/AuthService.py | 37 +++++++------- src/api/services/UtilityService.py | 77 +++++++++++++++++++++++++++--- src/config/asgi.py | 16 +++++-- src/config/settings.py | 14 ++++-- src/env.py | 24 ++++++++-- src/utils/env/__init__.py | 3 +- src/utils/env/env.py | 6 ++- 9 files changed, 206 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index ab9e29b..af0fcd5 100644 --- a/.env.example +++ b/.env.example @@ -28,5 +28,14 @@ JWT_ISSUER=xxx # time in minutes OTP_LIFETIME=10 +GATEWAY_PUBLIC_KEY='XXXXXXXXXXXXXXXXXXX' +GATEWAY_KEY_TTL=2 + +GATEWAY_PUBLIC_KEY='XXXXXXXXXXXXXXXXXXX' +GATEWAY_KEY_TTL=2 + # amqp://username:password@host[:port]/ -RABBITMQ_URL=amqp://guest:guest@localhost:5672/ \ No newline at end of file +RABBITMQ_URL=amqp://guest:guest@localhost:5672/ + +QUEUE_SECRECT_KEY='XXXXXXXXXXXXXXXXX' +QUEUE_SECRECT_KEY_TTL=0.5 \ No newline at end of file diff --git a/src/api/routes/__init__.py b/src/api/routes/__init__.py index e786320..0f62ab9 100644 --- a/src/api/routes/__init__.py +++ b/src/api/routes/__init__.py @@ -1,14 +1,69 @@ -from ninja import NinjaAPI from django.http import HttpRequest +from ninja import NinjaAPI +from ninja.openapi.schema import OpenAPISchema +from src.api.middlewares.GateWayMiddleware import (add_global_headers, + authentication) from src.env import app api: NinjaAPI = NinjaAPI( version=app["version"], title=app["display_name"], description=app["description"], + auth=authentication, ) + +original_get_openapi_schema = api.get_openapi_schema + + +def custom_openapi_schema(path_params: dict | None = None) -> OpenAPISchema: + schema = original_get_openapi_schema() + + schema["components"]["securitySchemes"] = { + "Gateway Key": { + "type": "apiKey", + "in": "header", + "name": "X-API-GATEWAY-KEY", + }, + "API Timestamp": { + "type": "apiKey", + "in": "header", + "name": "X-API-GATEWAY-TIMESTAMP", + }, + "API Signature": { + "type": "apiKey", + "in": "header", + "name": "X-API-GATEWAY-SIGNATURE", + }, + # "User ID": { + # "type": "apiKey", + # "in": "header", + # "name": "X-USER-ID", + # }, + # "User Email": { + # "type": "apiKey", + # "in": "header", + # "name": "X-USER-EMAIL", + # }, + } + + schema["security"] = [ + { + "Gateway Key": [], + "API Timestamp": [], + "API Signature": [], + # "User ID": [], + # "User Email": [], + } + ] + + schema = add_global_headers(schema) + return schema + + +setattr(api, "get_openapi_schema", custom_openapi_schema) + from src.api.utils import error_handlers # noqa: E402, F401 diff --git a/src/api/services/AuthService.py b/src/api/services/AuthService.py index e6018ec..50120da 100644 --- a/src/api/services/AuthService.py +++ b/src/api/services/AuthService.py @@ -1,24 +1,24 @@ from typing import Annotated -from src.utils.svcs import Service -from src.config.asgi import broker -from src.utils.logger import Logger -from src.api.typing.JWT import JWTSuccess -from src.api.typing.UserExists import UserExists -from src.api.constants.messages import MESSAGES, DYNAMIC_MESSAGES -from src.api.typing.UserSuccess import UserSuccess from src.api.constants.activity_types import ACTIVITY_TYPES +from src.api.constants.messages import DYNAMIC_MESSAGES, MESSAGES +from src.api.constants.queues import QUEUE_NAMES +from src.api.models.payload.requests.AuthenticateUserOtp import \ + AuthenticateUserOtp +from src.api.models.payload.requests.AuthenticateUserRequest import \ + AuthenticateUserRequest +from src.api.models.payload.requests.ChangeUserPasswordRequest import \ + ChangeUserPasswordRequest +from src.api.models.payload.requests.CreateUserRequest import CreateUserRequest from src.api.models.payload.requests.JWT import JWT -from src.api.repositories.UserRepository import UserRepository from src.api.models.payload.requests.ResendUserOtp import ResendUserOtp -from src.api.models.payload.requests.CreateUserRequest import CreateUserRequest -from src.api.models.payload.requests.AuthenticateUserOtp import AuthenticateUserOtp -from src.api.models.payload.requests.AuthenticateUserRequest import ( - AuthenticateUserRequest, -) -from src.api.models.payload.requests.ChangeUserPasswordRequest import ( - ChangeUserPasswordRequest, -) +from src.api.repositories.UserRepository import UserRepository +from src.api.typing.JWT import JWTSuccess +from src.api.typing.UserExists import UserExists +from src.api.typing.UserSuccess import UserSuccess +from src.config.asgi import broker +from src.utils.logger import Logger +from src.utils.svcs import Service from .OtpService import OtpService from .UtilityService import UtilityService @@ -62,7 +62,7 @@ async def register(self, req: CreateUserRequest) -> UserExists: created_user = await UserRepository.add(req) user_data = {"id": created_user.id, "email": created_user.email} - queue = "create-user" + queue = QUEUE_NAMES["USER_REGISTRATION"] await broker.publish(message=user_data, queue=queue, persist=True) @@ -126,8 +126,9 @@ async def validate_email(self, req: AuthenticateUserOtp) -> bool: await UserRepository.update_by_user( user, {"is_active": True, "is_enabled": True, "is_validated": True} ) + user_data = {"id": user.id, "email": user.email} - queue = "validate-user" + queue = QUEUE_NAMES["EMAIL_VALIDATION"] await broker.publish(message=user_data, queue=queue, persist=True) return True diff --git a/src/api/services/UtilityService.py b/src/api/services/UtilityService.py index 2c77c16..9460ed7 100644 --- a/src/api/services/UtilityService.py +++ b/src/api/services/UtilityService.py @@ -1,21 +1,32 @@ +import hashlib +import hmac +from datetime import datetime, timedelta +from typing import TypedDict from uuid import uuid4 -from datetime import timedelta -import jwt import bcrypt -from faker import Faker +import jwt from django.utils import timezone +from faker import Faker +from ninja.errors import AuthenticationError -from src.env import jwt_config -from src.utils.svcs import Service -from src.api.typing.JWT import JWTData +from src.api.enums.CharacterCasing import CharacterCasing from src.api.models.postgres import User from src.api.typing.ExpireUUID import ExpireUUID -from src.api.enums.CharacterCasing import CharacterCasing +from src.api.typing.JWT import JWTData +from src.env import jwt_config +from src.utils.logger import Logger +from src.utils.svcs import Service DEFAULT_CHARACTER_LENGTH = 12 fake = Faker() +class SignatureData(TypedDict): + title: str + signature: str + timestamp: str + key: str + ttl: int | float @Service() class UtilityService: @@ -95,3 +106,55 @@ def generate_uuid() -> ExpireUUID: lifespan = timedelta(hours=24) expires_at = current_time + lifespan return {"uuid": uuid4(), "expires_at": expires_at} + + @staticmethod + def generate_signature(key: str, timestamp: str) -> str: + signature = hmac.new( + key=key.encode(), msg=timestamp.encode(), digestmod=hashlib.sha256 + ).hexdigest() + return signature + + + @staticmethod + def verify_signature(signature_data: SignatureData, logger: Logger) -> bool: + + signature = signature_data["signature"] + timestamp = signature_data["timestamp"] + key = signature_data["key"] + ttl = signature_data["ttl"] + title = signature_data["title"] + + + valid_signature = UtilityService.generate_signature(key, timestamp) + is_valid = hmac.compare_digest(valid_signature, signature) + + if not is_valid: + message = "Invalid signature!" + logger.error( + { + "activity_type": f"Authenticate {title} Request", + "message": message, + "metadata": {"signature": signature}, + } + ) + raise AuthenticationError(message=message) + + initial_time = datetime.fromtimestamp(float(timestamp) / 1000) + valid_window = initial_time + timedelta(minutes=ttl) + if valid_window < datetime.now(): + message = "Signature expired!" + logger.error( + { + "activity_type": f"Authenticate {title} Request", + "message": message, + "metadata": {"timestamp": timestamp}, + } + ) + raise AuthenticationError(message=message) + + return True + + @staticmethod + def get_timestamp() -> str: + current_time = datetime.now().timestamp() * 1000 + return str(current_time) diff --git a/src/config/asgi.py b/src/config/asgi.py index 00c0ac7..2e3a749 100644 --- a/src/config/asgi.py +++ b/src/config/asgi.py @@ -11,8 +11,8 @@ from django.core.asgi import get_asgi_application from faststream.rabbit import RabbitBroker -from starlette.routing import Mount from starlette.applications import Starlette +from starlette.routing import Mount from src.env import rabbitmq_config @@ -21,11 +21,21 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "src.config.settings") + +def setup_broker_middlewares(): + from src.api.middlewares.BrokerMiddleware import (PublishMiddleware, + SubscribeMiddleware) + + broker.add_middleware(SubscribeMiddleware) + broker.add_middleware(PublishMiddleware) + + application = Starlette( routes=[Mount("/", get_asgi_application())], # type: ignore - on_startup=[broker.start], + on_startup=[setup_broker_middlewares, broker.start], on_shutdown=[broker.close], ) -from src.api.services.external import RabbitMQRoutes as RabbitMQRoutes # noqa: E402 +from src.api.services.external import \ + RabbitMQRoutes as RabbitMQRoutes # noqa: E402 diff --git a/src/config/settings.py b/src/config/settings.py index fb8cb6e..daf37f7 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -2,11 +2,11 @@ from .apps import INSTALLED_APPS as INSTALLED_APPS from .caches import CACHES as CACHES -from .logger import LOGGING as LOGGING -from .databases import DATABASES as DATABASES from .databases import DATABASE_ROUTERS as DATABASE_ROUTERS -from .templates import TEMPLATES as TEMPLATES +from .databases import DATABASES as DATABASES +from .logger import LOGGING as LOGGING from .middleware import MIDDLEWARE as MIDDLEWARE +from .templates import TEMPLATES as TEMPLATES SECRET_KEY = app["secret_key"] DEBUG = app["debug"] @@ -18,3 +18,11 @@ STATIC_URL = "static/" MEDIA_URL = "media/" + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True \ No newline at end of file diff --git a/src/env.py b/src/env.py index a89b07a..f760ddb 100644 --- a/src/env.py +++ b/src/env.py @@ -1,7 +1,7 @@ from typing import TypedDict -from src import __name__, __version__, __description__, __display_name__ -from src.utils.env import get_env_int, get_env_str, get_env_list +from src import __description__, __display_name__, __name__, __version__ +from src.utils.env import get_env_float, get_env_int, get_env_list, get_env_str class Env: @@ -54,6 +54,14 @@ class RabbitMQ(TypedDict): url: str +class Gateway(TypedDict): + key: str + ttl: int + +class Queue(TypedDict): + key: str + ttl: float + env = Env() app: App = { @@ -102,8 +110,18 @@ class RabbitMQ(TypedDict): "issuer": get_env_str("JWT_ISSUER"), } +api_gateway: Gateway = { + "key": get_env_str("GATEWAY_PUBLIC_KEY"), + "ttl": get_env_int("GATEWAY_KEY_TTL"), +} + +queue: Queue = { + "key": get_env_str("QUEUE_SECRECT_KEY"), + "ttl": get_env_float("QUEUE_SECRECT_KEY_TTL"), +} + otp: OTP = {"lifetime": get_env_int("OTP_LIFETIME")} rabbitmq_config: RabbitMQ = {"url": get_env_str("RABBITMQ_URL")} -__all__ = ["app", "cache", "db", "env", "jwt_config", "log", "otp", "rabbitmq_config"] +__all__ = ["app", "cache", "db", "env", "jwt_config", "log", "otp", "rabbitmq_config", "queue", "api_gateway"] diff --git a/src/utils/env/__init__.py b/src/utils/env/__init__.py index c231c7c..45de2e4 100644 --- a/src/utils/env/__init__.py +++ b/src/utils/env/__init__.py @@ -1,7 +1,8 @@ -from .env import get_env_int, get_env_str, get_env_list +from .env import get_env_float, get_env_int, get_env_list, get_env_str __all__ = [ "get_env_int", "get_env_list", "get_env_str", + "get_env_float", ] diff --git a/src/utils/env/env.py b/src/utils/env/env.py index a4a9fee..bcaf2d4 100644 --- a/src/utils/env/env.py +++ b/src/utils/env/env.py @@ -1,7 +1,7 @@ -from pathlib import Path from functools import cache +from pathlib import Path -from decouple import Config, AutoConfig, RepositoryEnv +from decouple import AutoConfig, Config, RepositoryEnv from decouple import config as decouple_config BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -25,6 +25,8 @@ def get_env_str(name: str, default: str | None = None) -> str: def get_env_int(name: str, default: str | None = None) -> int: return int(get_env_variable(name, default=default, cast=int)) +def get_env_float(name: str, default: str | None = None) -> float: + return float(get_env_variable(name, default=default, cast=float)) def get_env_list(name: str, sep: str = ",", default: str | None = None) -> list[str]: return list( From ffe8ed4f7ef9c97746aa421f2f0666400e441cfe Mon Sep 17 00:00:00 2001 From: CaptainAril Date: Mon, 28 Apr 2025 15:54:17 +0100 Subject: [PATCH 3/3] gateway and broker auth --- src/api/constants/queues.py | 12 ++++ src/api/constants/signature_sources.py | 12 ++++ src/api/middlewares/BrokerMiddleware.py | 76 +++++++++++++++++++++ src/api/middlewares/GateWayMiddleware.py | 84 ++++++++++++++++++++++++ src/api/routes/__init__.py | 17 +---- src/api/services/AuthService.py | 33 +++++----- src/api/services/UtilityService.py | 25 ++++--- src/config/asgi.py | 13 ++-- src/config/settings.py | 12 ++-- src/env.py | 19 +++++- src/utils/env/__init__.py | 4 +- src/utils/env/env.py | 6 +- 12 files changed, 250 insertions(+), 63 deletions(-) create mode 100644 src/api/constants/queues.py create mode 100644 src/api/constants/signature_sources.py create mode 100644 src/api/middlewares/BrokerMiddleware.py create mode 100644 src/api/middlewares/GateWayMiddleware.py diff --git a/src/api/constants/queues.py b/src/api/constants/queues.py new file mode 100644 index 0000000..b7bb09b --- /dev/null +++ b/src/api/constants/queues.py @@ -0,0 +1,12 @@ +from typing import TypedDict + + +class QueueNames(TypedDict): + USER_REGISTRATION: str + EMAIL_VALIDATION: str + + +QUEUE_NAMES: QueueNames = { + "USER_REGISTRATION": "create_user", + "EMAIL_VALIDATION": "validate_email", +} diff --git a/src/api/constants/signature_sources.py b/src/api/constants/signature_sources.py new file mode 100644 index 0000000..38bb96b --- /dev/null +++ b/src/api/constants/signature_sources.py @@ -0,0 +1,12 @@ +from typing import TypedDict + + +class SignatureSources(TypedDict): + gateway: str + queue: str + + +SIGNATURE_SOURCES: SignatureSources = { + "gateway": "Gateway", + "queue": "Broker Queue", +} diff --git a/src/api/middlewares/BrokerMiddleware.py b/src/api/middlewares/BrokerMiddleware.py new file mode 100644 index 0000000..94756a7 --- /dev/null +++ b/src/api/middlewares/BrokerMiddleware.py @@ -0,0 +1,76 @@ +from types import CoroutineType +from typing import Any +from collections.abc import Callable, Awaitable + +from faststream import BaseMiddleware +from faststream.broker.message import StreamMessage +from faststream.rabbit.message import RabbitMessage + +from src.env import queue +from src.utils.logger import Logger +from src.api.services.UtilityService import UtilityService +from src.api.constants.signature_sources import SIGNATURE_SOURCES + + +class PublishMiddleware(BaseMiddleware): + """ + Middleware to handle subscription messages. + """ + + async def publish_scope( + self, + call_next: Callable[..., Awaitable[Any]], + msg: RabbitMessage, + *args: tuple[Any, ...], + **kwargs: dict[str, Any], + ) -> CoroutineType: + timestamp = UtilityService.get_timestamp() + signature = UtilityService.generate_signature(queue["key"], timestamp) + + headers = {} + + headers["X-BROKER-SIGNATURE"] = signature + headers["X-BROKER-TIMESTAMP"] = timestamp + headers["X-BROKER-KEY"] = queue["key"] + + kwargs["headers"] = headers + return await super().publish_scope(call_next, msg, *args, **kwargs) + + +class SubscribeMiddleware(BaseMiddleware): + async def consume_scope( + self, call_next: Callable[[Any], Awaitable[Any]], msg: StreamMessage + ) -> CoroutineType | None: + logger = Logger(__name__) + try: + UtilityService.verify_signature( + logger=logger, + signature_data={ + "signature": msg.headers["X-BROKER-SIGNATURE"], + "timestamp": msg.headers["X-BROKER-TIMESTAMP"], + "key": queue["key"], + "ttl": queue["ttl"], + "title": SIGNATURE_SOURCES["gateway"], + }, + ) + + return await super().consume_scope(call_next, msg) + except KeyError as e: + message = f"Missing required header: {e}" + logger.error( + { + "activity_type": "Authenticate GatewaBroker Queue Request", + "message": message, + "metadata": {"headers": msg.headers}, + } + ) + except Exception as e: + queue_operation = msg.raw_message.routing_key + message = f"`{queue_operation}` operation failed: {e}" + logger.error( + { + "activity_type": "Authenticate GatewaBroker Queue Request", + "message": message, + "metadata": {"headers": msg.headers, "message": msg._decoded_body}, + } + ) diff --git a/src/api/middlewares/GateWayMiddleware.py b/src/api/middlewares/GateWayMiddleware.py new file mode 100644 index 0000000..b352e47 --- /dev/null +++ b/src/api/middlewares/GateWayMiddleware.py @@ -0,0 +1,84 @@ +from django.http import HttpRequest +from ninja.errors import AuthenticationError +from ninja.security import APIKeyHeader +from ninja.openapi.schema import OpenAPISchema + +from src.env import api_gateway +from src.utils.logger import Logger +from src.api.services.UtilityService import SignatureData, UtilityService +from src.api.constants.signature_sources import SIGNATURE_SOURCES + + +class GateWayAuth(APIKeyHeader): + def __init__(self, logger: Logger) -> None: + self.logger = logger + super().__init__() + + def authenticate(self, request: HttpRequest, key: str | None) -> str | None: + try: + api_key = request.headers["X-API-GATEWAY-KEY"] + api_timestamp = request.headers["X-API-GATEWAY-TIMESTAMP"] + api_signature = request.headers["X-API-GATEWAY-SIGNATURE"] + except KeyError as e: + message = f"Missing required header: {e}" + self.logger.error( + { + "activity_type": "Authenticate Gateway Request", + "message": message, + "metadata": {"headers": request.headers}, + } + ) + raise AuthenticationError(message=message) + + valid_api_key = api_gateway["key"] + if api_key != valid_api_key: + message = "Invalid API key!" + self.logger.error( + { + "activity_type": "Authenticate Gateway Request", + "message": message, + "metadata": {"headers": request.headers}, + } + ) + raise AuthenticationError(message=message) + + signature_data: SignatureData = { + "signature": api_signature, + "timestamp": api_timestamp, + "key": valid_api_key, + "ttl": 5, + "title": SIGNATURE_SOURCES["gateway"], + } + + UtilityService.verify_signature( + logger=self.logger, signature_data=signature_data + ) + + self.logger.debug( + { + "activity_type": "Authenticate Gateway Request", + "message": "Successfully authenticated gateway request", + "metadata": { + "headers": request.headers, + }, + } + ) + + return api_signature + + +def get_authentication() -> GateWayAuth: + gateway_auth = GateWayAuth(Logger("Authentication")) + return gateway_auth + + +def add_global_headers(schema: OpenAPISchema) -> OpenAPISchema: + for path in schema["paths"]: + for method in schema["paths"][path]: + operation = schema["paths"][path][method] + if operation.get("security"): + operation["security"] = schema["security"] + return schema + + +authentication = get_authentication() diff --git a/src/api/routes/__init__.py b/src/api/routes/__init__.py index 0f62ab9..5b46b3f 100644 --- a/src/api/routes/__init__.py +++ b/src/api/routes/__init__.py @@ -1,10 +1,9 @@ -from django.http import HttpRequest from ninja import NinjaAPI +from django.http import HttpRequest from ninja.openapi.schema import OpenAPISchema -from src.api.middlewares.GateWayMiddleware import (add_global_headers, - authentication) from src.env import app +from src.api.middlewares.GateWayMiddleware import authentication, add_global_headers api: NinjaAPI = NinjaAPI( version=app["version"], @@ -36,16 +35,6 @@ def custom_openapi_schema(path_params: dict | None = None) -> OpenAPISchema: "in": "header", "name": "X-API-GATEWAY-SIGNATURE", }, - # "User ID": { - # "type": "apiKey", - # "in": "header", - # "name": "X-USER-ID", - # }, - # "User Email": { - # "type": "apiKey", - # "in": "header", - # "name": "X-USER-EMAIL", - # }, } schema["security"] = [ @@ -53,8 +42,6 @@ def custom_openapi_schema(path_params: dict | None = None) -> OpenAPISchema: "Gateway Key": [], "API Timestamp": [], "API Signature": [], - # "User ID": [], - # "User Email": [], } ] diff --git a/src/api/services/AuthService.py b/src/api/services/AuthService.py index 50120da..6034740 100644 --- a/src/api/services/AuthService.py +++ b/src/api/services/AuthService.py @@ -1,24 +1,25 @@ from typing import Annotated -from src.api.constants.activity_types import ACTIVITY_TYPES -from src.api.constants.messages import DYNAMIC_MESSAGES, MESSAGES -from src.api.constants.queues import QUEUE_NAMES -from src.api.models.payload.requests.AuthenticateUserOtp import \ - AuthenticateUserOtp -from src.api.models.payload.requests.AuthenticateUserRequest import \ - AuthenticateUserRequest -from src.api.models.payload.requests.ChangeUserPasswordRequest import \ - ChangeUserPasswordRequest -from src.api.models.payload.requests.CreateUserRequest import CreateUserRequest -from src.api.models.payload.requests.JWT import JWT -from src.api.models.payload.requests.ResendUserOtp import ResendUserOtp -from src.api.repositories.UserRepository import UserRepository +from src.utils.svcs import Service +from src.config.asgi import broker +from src.utils.logger import Logger from src.api.typing.JWT import JWTSuccess +from src.api.constants.queues import QUEUE_NAMES from src.api.typing.UserExists import UserExists +from src.api.constants.messages import MESSAGES, DYNAMIC_MESSAGES from src.api.typing.UserSuccess import UserSuccess -from src.config.asgi import broker -from src.utils.logger import Logger -from src.utils.svcs import Service +from src.api.constants.activity_types import ACTIVITY_TYPES +from src.api.models.payload.requests.JWT import JWT +from src.api.repositories.UserRepository import UserRepository +from src.api.models.payload.requests.ResendUserOtp import ResendUserOtp +from src.api.models.payload.requests.CreateUserRequest import CreateUserRequest +from src.api.models.payload.requests.AuthenticateUserOtp import AuthenticateUserOtp +from src.api.models.payload.requests.AuthenticateUserRequest import ( + AuthenticateUserRequest, +) +from src.api.models.payload.requests.ChangeUserPasswordRequest import ( + ChangeUserPasswordRequest, +) from .OtpService import OtpService from .UtilityService import UtilityService diff --git a/src/api/services/UtilityService.py b/src/api/services/UtilityService.py index 9460ed7..2026c5b 100644 --- a/src/api/services/UtilityService.py +++ b/src/api/services/UtilityService.py @@ -1,26 +1,27 @@ -import hashlib import hmac -from datetime import datetime, timedelta -from typing import TypedDict +import hashlib from uuid import uuid4 +from typing import TypedDict +from datetime import datetime, timedelta -import bcrypt import jwt -from django.utils import timezone +import bcrypt from faker import Faker +from django.utils import timezone from ninja.errors import AuthenticationError -from src.api.enums.CharacterCasing import CharacterCasing -from src.api.models.postgres import User -from src.api.typing.ExpireUUID import ExpireUUID -from src.api.typing.JWT import JWTData from src.env import jwt_config -from src.utils.logger import Logger from src.utils.svcs import Service +from src.utils.logger import Logger +from src.api.typing.JWT import JWTData +from src.api.models.postgres import User +from src.api.typing.ExpireUUID import ExpireUUID +from src.api.enums.CharacterCasing import CharacterCasing DEFAULT_CHARACTER_LENGTH = 12 fake = Faker() + class SignatureData(TypedDict): title: str signature: str @@ -28,6 +29,7 @@ class SignatureData(TypedDict): key: str ttl: int | float + @Service() class UtilityService: @staticmethod @@ -114,17 +116,14 @@ def generate_signature(key: str, timestamp: str) -> str: ).hexdigest() return signature - @staticmethod def verify_signature(signature_data: SignatureData, logger: Logger) -> bool: - signature = signature_data["signature"] timestamp = signature_data["timestamp"] key = signature_data["key"] ttl = signature_data["ttl"] title = signature_data["title"] - valid_signature = UtilityService.generate_signature(key, timestamp) is_valid = hmac.compare_digest(valid_signature, signature) diff --git a/src/config/asgi.py b/src/config/asgi.py index 2e3a749..1ff8214 100644 --- a/src/config/asgi.py +++ b/src/config/asgi.py @@ -11,8 +11,8 @@ from django.core.asgi import get_asgi_application from faststream.rabbit import RabbitBroker -from starlette.applications import Starlette from starlette.routing import Mount +from starlette.applications import Starlette from src.env import rabbitmq_config @@ -22,9 +22,11 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "src.config.settings") -def setup_broker_middlewares(): - from src.api.middlewares.BrokerMiddleware import (PublishMiddleware, - SubscribeMiddleware) +def setup_broker_middlewares() -> None: + from src.api.middlewares.BrokerMiddleware import ( + PublishMiddleware, + SubscribeMiddleware, + ) broker.add_middleware(SubscribeMiddleware) broker.add_middleware(PublishMiddleware) @@ -37,5 +39,4 @@ def setup_broker_middlewares(): ) -from src.api.services.external import \ - RabbitMQRoutes as RabbitMQRoutes # noqa: E402 +from src.api.services.external import RabbitMQRoutes as RabbitMQRoutes # noqa: E402 diff --git a/src/config/settings.py b/src/config/settings.py index daf37f7..e5fcd92 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -2,11 +2,11 @@ from .apps import INSTALLED_APPS as INSTALLED_APPS from .caches import CACHES as CACHES -from .databases import DATABASE_ROUTERS as DATABASE_ROUTERS -from .databases import DATABASES as DATABASES from .logger import LOGGING as LOGGING -from .middleware import MIDDLEWARE as MIDDLEWARE +from .databases import DATABASES as DATABASES +from .databases import DATABASE_ROUTERS as DATABASE_ROUTERS from .templates import TEMPLATES as TEMPLATES +from .middleware import MIDDLEWARE as MIDDLEWARE SECRET_KEY = app["secret_key"] DEBUG = app["debug"] @@ -19,10 +19,10 @@ STATIC_URL = "static/" MEDIA_URL = "media/" -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True -USE_TZ = True \ No newline at end of file +USE_TZ = True diff --git a/src/env.py b/src/env.py index f760ddb..be71795 100644 --- a/src/env.py +++ b/src/env.py @@ -1,7 +1,7 @@ from typing import TypedDict -from src import __description__, __display_name__, __name__, __version__ -from src.utils.env import get_env_float, get_env_int, get_env_list, get_env_str +from src import __name__, __version__, __description__, __display_name__ +from src.utils.env import get_env_int, get_env_str, get_env_list, get_env_float class Env: @@ -58,10 +58,12 @@ class Gateway(TypedDict): key: str ttl: int + class Queue(TypedDict): key: str ttl: float + env = Env() app: App = { @@ -124,4 +126,15 @@ class Queue(TypedDict): rabbitmq_config: RabbitMQ = {"url": get_env_str("RABBITMQ_URL")} -__all__ = ["app", "cache", "db", "env", "jwt_config", "log", "otp", "rabbitmq_config", "queue", "api_gateway"] +__all__ = [ + "api_gateway", + "app", + "cache", + "db", + "env", + "jwt_config", + "log", + "otp", + "queue", + "rabbitmq_config", +] diff --git a/src/utils/env/__init__.py b/src/utils/env/__init__.py index 45de2e4..9f765cb 100644 --- a/src/utils/env/__init__.py +++ b/src/utils/env/__init__.py @@ -1,8 +1,8 @@ -from .env import get_env_float, get_env_int, get_env_list, get_env_str +from .env import get_env_int, get_env_str, get_env_list, get_env_float __all__ = [ + "get_env_float", "get_env_int", "get_env_list", "get_env_str", - "get_env_float", ] diff --git a/src/utils/env/env.py b/src/utils/env/env.py index bcaf2d4..d337aee 100644 --- a/src/utils/env/env.py +++ b/src/utils/env/env.py @@ -1,7 +1,7 @@ -from functools import cache from pathlib import Path +from functools import cache -from decouple import AutoConfig, Config, RepositoryEnv +from decouple import Config, AutoConfig, RepositoryEnv from decouple import config as decouple_config BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -25,9 +25,11 @@ def get_env_str(name: str, default: str | None = None) -> str: def get_env_int(name: str, default: str | None = None) -> int: return int(get_env_variable(name, default=default, cast=int)) + def get_env_float(name: str, default: str | None = None) -> float: return float(get_env_variable(name, default=default, cast=float)) + def get_env_list(name: str, sep: str = ",", default: str | None = None) -> list[str]: return list( get_env_variable(