diff --git a/authentication/api.py b/authentication/api.py index 690a42b6..6a3135e5 100644 --- a/authentication/api.py +++ b/authentication/api.py @@ -46,7 +46,7 @@ def keycloak_session_init(url, **kwargs): def update_token(token): log_str = f"Refreshing Keycloak token {token}" - log.warning(log_str) + log.debug(log_str) KeycloakAdminToken.objects.all().delete() KeycloakAdminToken.objects.create( authorization_token=token.get("access_token"), @@ -70,7 +70,7 @@ def check_for_token(): token = check_for_token() log_str = f"Trying to start up a session with token {token.token_formatted}" - log.warning(log_str) + log.debug(log_str) session = OAuth2Session( client=client, @@ -80,10 +80,10 @@ def check_for_token(): token_updater=update_token, ) - keycloak_info = session.get(url, **kwargs).json() + keycloak_response = session.get(url, **kwargs).json() except (InvalidGrantError, TokenExpiredError) as ige: log_str = f"Token error, trying to get a new token: {ige}" - log.warning(log_str) + log.debug(log_str) session = OAuth2Session(client=client) token = session.fetch_token( @@ -95,12 +95,12 @@ def check_for_token(): update_token(token) session = OAuth2Session(client=client, token=token) - keycloak_info = session.get(url, **kwargs).json() + keycloak_response = session.get(url, **kwargs).json() - log_str = f"Keycloak info returned: {keycloak_info}" - log.warning(log_str) + log_str = f"Keycloak response: {keycloak_response}" + log.debug(log_str) - return keycloak_info + return keycloak_response def keycloak_get_user(user: User): @@ -112,7 +112,7 @@ def keycloak_get_user(user: User): ) log_str = f"Trying to get user info for {user.username}" - log.warning(log_str) + log.debug(log_str) if user.keycloak_user_tokens.exists(): params = {"id": user.keycloak_user_tokens.first().keycloak_id} @@ -122,17 +122,17 @@ def keycloak_get_user(user: User): userinfo = keycloak_session_init(userinfo_url, verify=False, params=params) if len(userinfo) == 0: - log.warning("Keycloak didn't return anything") + log.debug("Keycloak didn't return anything") return None return userinfo[0] @app.task -def keycloak_update_user_account(user: int): +def keycloak_update_user_account(user_id: int): """Update the user account using info from Keycloak asynchronously.""" - user = User.objects.get(id=user) + user = User.objects.get(id=user_id) keycloak_user = keycloak_get_user(user) diff --git a/authentication/models.py b/authentication/models.py index e952d653..64add99b 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -31,8 +31,12 @@ class KeycloakAdminToken(TimestampedModel): authorization_token = models.TextField() refresh_token = models.TextField(blank=True) - authorization_token_expires_in = models.IntegerField() - refresh_token_expires_in = models.IntegerField(null=True) + authorization_token_expires_in = models.IntegerField( + help_text="Seconds until authentication token expires" + ) + refresh_token_expires_in = models.IntegerField( + null=True, help_text="Seconds until refresh token expires" + ) def calculate_token_expiration(self) -> (int, int): """Calculate the expiration time of the tokens.""" diff --git a/openapi.yaml b/openapi.yaml index f4f99336..629bf406 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3,26 +3,6 @@ info: title: '' version: 0.0.0 paths: - /apisix_test_request/: - get: - operationId: apisix_test_request_retrieve - description: Test API request so we can see how the APISIX integration works. - tags: - - apisix_test_request - responses: - '200': - description: No response body - /authed_traefik_test_request/: - get: - operationId: authed_traefik_test_request_retrieve - description: Test API request so we can see how the Traefik integration works. - tags: - - authed_traefik_test_request - security: - - cookieAuth: [] - responses: - '200': - description: No response body /integrated_system/: get: operationId: integrated_system_list @@ -339,17 +319,6 @@ paths: responses: '204': description: No response body - /traefik_test_request/: - get: - operationId: traefik_test_request_retrieve - description: Test API request so we can see how the Traefik integration works. - tags: - - traefik_test_request - security: - - cookieAuth: [] - responses: - '200': - description: No response body components: schemas: IntegratedSystem: diff --git a/system_meta/private_urls.py b/system_meta/private_urls.py new file mode 100644 index 00000000..2e240c9a --- /dev/null +++ b/system_meta/private_urls.py @@ -0,0 +1,27 @@ +"""Private URLs for the system_meta app.""" + +from django.urls import re_path + +from system_meta.views import ( + apisix_test_request, + authed_traefik_test_request, + traefik_test_request, +) + +urlpatterns = [ + re_path( + r"^apisix_test_request/$", + apisix_test_request, + name="apisix_test_request", + ), + re_path( + r"^traefik_test_request/$", + traefik_test_request, + name="traefik_test_request", + ), + re_path( + r"^authed_traefik_test_request/$", + authed_traefik_test_request, + name="authed_traefik_test_request", + ), +] diff --git a/system_meta/urls.py b/system_meta/urls.py index 7f0b1bde..d486c020 100644 --- a/system_meta/urls.py +++ b/system_meta/urls.py @@ -6,9 +6,6 @@ from system_meta.views import ( IntegratedSystemViewSet, ProductViewSet, - apisix_test_request, - authed_traefik_test_request, - traefik_test_request, ) router = routers.DefaultRouter() @@ -18,19 +15,4 @@ urlpatterns = [ re_path("^", include(router.urls)), - re_path( - r"^apisix_test_request/$", - apisix_test_request, - name="apisix_test_request", - ), - re_path( - r"^traefik_test_request/$", - traefik_test_request, - name="traefik_test_request", - ), - re_path( - r"^authed_traefik_test_request/$", - authed_traefik_test_request, - name="authed_traefik_test_request", - ), ] diff --git a/unified_ecommerce/authentication.py b/unified_ecommerce/authentication.py index 849c4deb..6f907ace 100644 --- a/unified_ecommerce/authentication.py +++ b/unified_ecommerce/authentication.py @@ -1,14 +1,11 @@ """Custom authentication for DRF""" -import base64 -import json import logging import random import string import jwt from django.contrib.auth import get_user_model -from django.contrib.auth.middleware import RemoteUserMiddleware from rest_framework.authentication import BaseAuthentication from rest_framework_jwt.authentication import JSONWebTokenAuthentication @@ -17,7 +14,7 @@ HEADER_PREFIX = "Token " HEADER_PREFIX_LENGTH = len(HEADER_PREFIX) -logger = logging.getLogger() +log = logging.getLogger() class IgnoreExpiredJwtAuthentication(JSONWebTokenAuthentication): @@ -36,7 +33,7 @@ def get_token_from_request(cls, request): jwt_decode_handler(value) except jwt.ExpiredSignatureError: # if it is expired, treat it as if the user never passed a token - logger.debug("Ignoring expired JWT") + log.debug("Ignoring expired JWT") return None except: # pylint: disable=bare-except # noqa: E722, S110 # we're only interested in jwt.ExpiredSignature above @@ -88,52 +85,51 @@ class ApiGatewayAuthentication(BaseAuthentication): Handles authentication when behind an API gateway. If the app is sitting in front of something like APISIX, the app will get - authentication information in the HTTP_X_USERINFO header. So, this decodes that, - and then attempts to authenticate the user or creates them if they don't exist. + authentication information through some sort of channel. A middleware can + take care of decoding that and placing the decoded data into the request, + and this backend will handle the authentication based on that data. """ def authenticate(self, request): - """Authenticate the user based on HTTP_X_USERINFO.""" + """Authenticate the user based on request.api_gateway_userdata.""" - user_info = request.META.get("HTTP_X_USERINFO", False) - - if not user_info: + if ( + not request + or not request.api_gateway_userdata + ): return None - decoded_user_info = json.loads(base64.b64decode(user_info)) - - logger.info( - "ApiGatewayAuthentication: Checking for existing user for %s", - decoded_user_info["preferred_username"], - ) + ( + email, + preferred_username, + given_name, + family_name, + ) = request.api_gateway_userdata try: - user = User.objects.filter(email=decoded_user_info["email"]).get() + user = User.objects.filter(email=email).get() - logger.info( + log.debug( "ApiGatewayAuthentication: Found existing user for %s: %s", - decoded_user_info["preferred_username"], + preferred_username, user, ) except User.DoesNotExist: - logger.info("ApiGatewayAuthentication: User not found, creating") + log.debug( + "ApiGatewayAuthentication: User %s not found, creating", + preferred_username, + ) # Create a random password for the user, 32 characters long. # We don't care about the password since APISIX (or whatever) has # bounced the user to an authentication system elsewhere (like Keycloak). user = User.objects.create_user( - decoded_user_info["preferred_username"], - decoded_user_info["email"], + preferred_username, + email, "".join(random.choices(string.ascii_uppercase + string.digits, k=32)), # noqa: S311 ) - user.first_name = decoded_user_info.get("given_name", None) - user.last_name = decoded_user_info.get("family_name", None) + user.first_name = given_name + user.last_name = family_name user.save() return (user, None) - - -class ForwardUserMiddleware(RemoteUserMiddleware): - """RemoteUserMiddleware, but looks at X-Forwarded-User""" - - header = "HTTP_X_FORWARDED_USER" diff --git a/unified_ecommerce/middleware.py b/unified_ecommerce/middleware.py new file mode 100644 index 00000000..a08475e0 --- /dev/null +++ b/unified_ecommerce/middleware.py @@ -0,0 +1,63 @@ +"""Middleware for Unified Ecommerce.""" + +import base64 +import json +import logging + +from django.contrib.auth.middleware import RemoteUserMiddleware + +log = logging.getLogger(__name__) + + +class ApisixUserMiddleware: + """Checks for and processes APISIX-specific headers.""" + + def decode_apisix_headers(self, request): + """Decode the APISIX-specific headers.""" + user_info = request.META.get("HTTP_X_USERINFO", False) + + if not user_info: + return None + + try: + apisix_result = json.loads(base64.b64decode(user_info)) + except json.JSONDecodeError: + log_message = ( + "ApisixUserMiddleware: Result from HTTP_X_USERINFO " + f"is not valid JSON: {user_info}" + ) + log.debug(log_message) + + return None + + log_message = f"ApisixUserMiddleware: Got {apisix_result}" + log.debug(log_message) + + return { + "email": apisix_result["email"], + "preferred_username": apisix_result["preferred_username"], + "given_name": apisix_result["given_name"], + "family_name": apisix_result["family_name"], + } + + def __init__(self, get_response): + """Initialize the middleware.""" + self.get_response = get_response + + def __call__(self, request): + """ + Process any APISIX headers and put them in the request. + + If valid data is found, then it will be put into the "api_gateway_userdata" + attribute of the request. Otherwise, it'll be set to None. + """ + + request.api_gateway_userdata = self.decode_apisix_headers(request) + + return self.get_response(request) + + +class ForwardUserMiddleware(RemoteUserMiddleware): + """RemoteUserMiddleware, but looks at X-Forwarded-User""" + + header = "HTTP_X_FORWARDED_USER" diff --git a/unified_ecommerce/settings.py b/unified_ecommerce/settings.py index e1f86749..91a8dce2 100644 --- a/unified_ecommerce/settings.py +++ b/unified_ecommerce/settings.py @@ -102,7 +102,8 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "unified_ecommerce.authentication.ForwardUserMiddleware", + "unified_ecommerce.middleware.ApisixUserMiddleware", + "unified_ecommerce.middleware.ForwardUserMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "corsheaders.middleware.CorsMiddleware", diff --git a/unified_ecommerce/urls.py b/unified_ecommerce/urls.py index d9ec302f..afa8037c 100644 --- a/unified_ecommerce/urls.py +++ b/unified_ecommerce/urls.py @@ -45,6 +45,8 @@ path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), # App Paths re_path(r"^api/v0/meta/", include("system_meta.urls")), + # Private Paths + re_path(r"^_/v0/meta/", include("system_meta.private_urls")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: