diff --git a/.env.example b/.env.example index 36ca934..f97a6d5 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ SECRET_KEY=your_secret_key DEBUG=True +ENVIRONMENT=xxxx ALLOWED_HOSTS=* LOG_LEVEL=debug @@ -26,4 +27,7 @@ JWT_SECRET=xxx JWT_ISSUER=xxx # time in minutes -OTP_LIFETIME=10 \ No newline at end of file +OTP_LIFETIME=10 + +API_GATEWAY_PUBLIC_KEY=xxxxxxxxxxxxxxxxxxxxxxxx +API_KEY_EXPIRES_AT=2 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 599f306..7f3065c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pydantic[email]>=2.10.6", "django-ninja>=1.3.0", "pyjwt>=2.10.1", + "typing>=3.10.0.0", ] [dependency-groups] diff --git a/src/api/middlewares/GateWayMiddleware.py b/src/api/middlewares/GateWayMiddleware.py new file mode 100644 index 0000000..9ce6687 --- /dev/null +++ b/src/api/middlewares/GateWayMiddleware.py @@ -0,0 +1,119 @@ +import hmac +import hashlib +from datetime import datetime, timedelta + +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 + + +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"] + user_id = request.headers["X-USER-ID"] + user_email = request.headers["X-USER-EMAIL"] + except KeyError as e: + message = f"Missing required header: {e}" + self.logger.error( + { + "activity_type": "Authenticate User", + "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 User", + "message": message, + "metadata": {"headers": request.headers}, + } + ) + raise AuthenticationError(message=message) + + self._verify_signature(valid_api_key, api_signature, api_timestamp) + + self.logger.debug( + { + "activity_type": "Authenticate User", + "message": "Successfully authenticated user", + "metadata": { + "user_id": user_id, + "user_email": user_email, + }, + } + ) + setattr(request, "auth_email", user_email) + setattr(request, "auth_id", user_id) + + return api_signature + + def _verify_signature( + self, valid_api_key: str, signature: str, timestamp: str + ) -> bool: + valid_signature = self.generate_signature(valid_api_key, timestamp) + is_valid = hmac.compare_digest(valid_signature, signature) + + if not is_valid: + message = "Invalid signature!" + self.logger.error( + { + "activity_type": "Authenticate User", + "message": message, + "metadata": {"signature": signature}, + } + ) + raise AuthenticationError(message=message) + + initial_time = datetime.fromtimestamp(int(timestamp) / 1000) + valid_window = initial_time + timedelta(minutes=api_gateway["expires_at"]) + if valid_window < datetime.now(): + message = "Signature expired!" + self.logger.error( + { + "activity_type": "Authenticate User", + "message": message, + "metadata": {"timestamp": timestamp}, + } + ) + raise AuthenticationError(message=message) + + return True + + def generate_signature(self, api_key: str, timestamp: str) -> str: + signature = hmac.new( + key=api_key.encode(), msg=timestamp.encode(), digestmod=hashlib.sha256 + ).hexdigest() + return 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 9347391..09f5c9b 100644 --- a/src/api/routes/__init__.py +++ b/src/api/routes/__init__.py @@ -1,38 +1,84 @@ from ninja import NinjaAPI from django.http import HttpRequest +from ninja.openapi.schema import OpenAPISchema from src.env import app -from src.api.middlewares.AppMiddleware import authentication +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", + }, + "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 -@api.get("/") +@api.get("/", auth=None) async def home(request: HttpRequest) -> dict: return {"message": "Hello, World!"} -api.add_router( - "/users", "src.api.routes.User.router", auth=authentication, tags=["User"] -) -api.add_router( - "/kyc", "src.api.routes.UserKYC.router", auth=authentication, tags=["User KYC"] -) +api.add_router("/users", "src.api.routes.User.router", tags=["User"]) +api.add_router("/kyc", "src.api.routes.UserKYC.router", tags=["User KYC"]) api.add_router( "/next-of-kin", "src.api.routes.UserNOK.router", - auth=authentication, tags=["User NOK"], ) api.add_router( "/withdrawal-accounts", "src.api.routes.WithdrawalAccount.router", - auth=authentication, tags=["User"], ) diff --git a/src/env.py b/src/env.py index 4dbdd1f..186dad1 100644 --- a/src/env.py +++ b/src/env.py @@ -98,6 +98,11 @@ class OTP(TypedDict): "issuer": get_env_str("JWT_ISSUER"), } +api_gateway = { + "key": get_env_str("API_GATEWAY_PUBLIC_KEY"), + "expires_at": get_env_int("API_KEY_EXPIRES_AT"), +} + otp: OTP = {"lifetime": get_env_int("OTP_LIFETIME")} -__all__ = ["app", "cache", "db", "env", "jwt_config", "log", "otp"] +__all__ = ["api_gateway", "app", "cache", "db", "env", "jwt_config", "log", "otp"] diff --git a/uv.lock b/uv.lock index 462b666..d0cbf4e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.12" [[package]] @@ -89,7 +90,6 @@ wheels = [ [[package]] name = "df-wallet-user-service" -version = "1.0.0" source = { virtual = "." } dependencies = [ { name = "bcrypt" }, @@ -106,6 +106,7 @@ dependencies = [ { name = "python-decouple" }, { name = "redis" }, { name = "svcs" }, + { name = "typing" }, ] [package.dev-dependencies] @@ -133,6 +134,7 @@ requires-dist = [ { name = "python-decouple", specifier = ">=3.8" }, { name = "redis", specifier = ">=5.2.1" }, { name = "svcs", specifier = ">=25.1.0" }, + { name = "typing", specifier = ">=3.10.0.0" }, ] [package.metadata.requires-dev] @@ -662,6 +664,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/56/1fe61db05685fbb512c07ea9323f06ea727125951f1eb4dff110b3311da3/types_pyyaml-6.0.12.20250402-py3-none-any.whl", hash = "sha256:652348fa9e7a203d4b0d21066dfb00760d3cbd5a15ebb7cf8d33c88a49546681", size = 20329 }, ] +[[package]] +name = "typing" +version = "3.10.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/1b/835d4431805939d2996f8772aca1d2313a57e8860fec0e48e8e7dfe3a477/typing-3.10.0.0.tar.gz", hash = "sha256:13b4ad211f54ddbf93e5901a9967b1e07720c1d1b78d596ac6a439641aa1b130", size = 78962 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/5d/865e17349564eb1772688d8afc5e3081a5964c640d64d1d2880ebaed002d/typing-3.10.0.0-py3-none-any.whl", hash = "sha256:12fbdfbe7d6cca1a42e485229afcb0b0c8259258cfb919b8a5e2a5c953742f89", size = 26320 }, +] + [[package]] name = "typing-extensions" version = "4.13.0"