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/pyproject.toml b/pyproject.toml index b27b43d..16a9db9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,8 @@ dependencies = [ "pyjwt>=2.10.1", "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/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 e786320..5b46b3f 100644 --- a/src/api/routes/__init__.py +++ b/src/api/routes/__init__.py @@ -1,14 +1,56 @@ from ninja import NinjaAPI from django.http import HttpRequest +from ninja.openapi.schema import OpenAPISchema from src.env import app +from src.api.middlewares.GateWayMiddleware import authentication, add_global_headers 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", + }, + } + + schema["security"] = [ + { + "Gateway Key": [], + "API Timestamp": [], + "API Signature": [], + } + ] + + 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 607ddb5..6034740 100644 --- a/src/api/services/AuthService.py +++ b/src/api/services/AuthService.py @@ -1,8 +1,10 @@ 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.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 @@ -60,6 +62,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 = QUEUE_NAMES["USER_REGISTRATION"] + + 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 +127,11 @@ 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 = QUEUE_NAMES["EMAIL_VALIDATION"] + await broker.publish(message=user_data, queue=queue, persist=True) + return True async def login(self, req: AuthenticateUserRequest) -> UserSuccess: diff --git a/src/api/services/UtilityService.py b/src/api/services/UtilityService.py index 2c77c16..2026c5b 100644 --- a/src/api/services/UtilityService.py +++ b/src/api/services/UtilityService.py @@ -1,13 +1,18 @@ +import hmac +import hashlib from uuid import uuid4 -from datetime import timedelta +from typing import TypedDict +from datetime import datetime, timedelta import jwt import bcrypt from faker import Faker from django.utils import timezone +from ninja.errors import AuthenticationError from src.env import jwt_config 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 @@ -17,6 +22,14 @@ fake = Faker() +class SignatureData(TypedDict): + title: str + signature: str + timestamp: str + key: str + ttl: int | float + + @Service() class UtilityService: @staticmethod @@ -95,3 +108,52 @@ 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/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/src/config/asgi.py b/src/config/asgi.py index 00c0ac7..1ff8214 100644 --- a/src/config/asgi.py +++ b/src/config/asgi.py @@ -21,9 +21,20 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "src.config.settings") + +def setup_broker_middlewares() -> None: + 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], ) diff --git a/src/config/settings.py b/src/config/settings.py index fb8cb6e..e5fcd92 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -18,3 +18,11 @@ STATIC_URL = "static/" MEDIA_URL = "media/" + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True diff --git a/src/env.py b/src/env.py index a89b07a..be71795 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.utils.env import get_env_int, get_env_str, get_env_list, get_env_float class Env: @@ -54,6 +54,16 @@ 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 +112,29 @@ 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__ = [ + "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 c231c7c..9f765cb 100644 --- a/src/utils/env/__init__.py +++ b/src/utils/env/__init__.py @@ -1,6 +1,7 @@ -from .env import get_env_int, get_env_str, get_env_list +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", diff --git a/src/utils/env/env.py b/src/utils/env/env.py index a4a9fee..d337aee 100644 --- a/src/utils/env/env.py +++ b/src/utils/env/env.py @@ -26,6 +26,10 @@ 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( diff --git a/uv.lock b/uv.lock index 059cef9..7ff5048 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" }, @@ -187,6 +188,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" }, @@ -200,7 +202,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] @@ -248,6 +250,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" @@ -959,15 +973,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]]