From 4e01a9981719dc5ab62a28aaf7d162ac35a2f471 Mon Sep 17 00:00:00 2001 From: Moritz Ulmer Date: Thu, 3 Feb 2022 10:23:25 +0100 Subject: [PATCH 1/5] Add support for JWT decoding Why: - Extract microprofile from JWT instead of querying Keycloak - Retain KeycloakUser model - Enable Keycloak integration in tests - Add compatibility with async web servers - Add support for generating users with passwords - Fix misc. bugs This change addresses the need by: - Add urls to delete users and fetch keys to decode JWTs - Keep KeycloakUser as first class model - Add TestMixin to help with cleanup of integration tests - Mark middleware as sync-only compatible to trigger wrapper - Fix GraphQL url check - Fix user not being added to requests - Add password parameter for creating users --- django_keycloak/backends.py | 40 +++--- django_keycloak/keycloak.py | 246 ++++++++++++++++++++++------------ django_keycloak/managers.py | 21 +-- django_keycloak/middleware.py | 21 ++- django_keycloak/mixins.py | 27 ++++ django_keycloak/models.py | 41 +++--- django_keycloak/urls.py | 2 + 7 files changed, 259 insertions(+), 139 deletions(-) create mode 100644 django_keycloak/mixins.py diff --git a/django_keycloak/backends.py b/django_keycloak/backends.py index e9352e8..9c12159 100644 --- a/django_keycloak/backends.py +++ b/django_keycloak/backends.py @@ -1,6 +1,7 @@ from django.contrib.auth.backends import RemoteUserBackend from django.contrib.auth import get_user_model from django_keycloak.keycloak import Connect +from django_keycloak.models import KeycloakUserAutoId class KeycloakAuthenticationBackend(RemoteUserBackend): @@ -8,40 +9,37 @@ class KeycloakAuthenticationBackend(RemoteUserBackend): def authenticate(self, request, username=None, password=None): keycloak = Connect() token = keycloak.get_token_from_credentials(username, password).get("access_token") - user = get_user_model() + User = get_user_model() if not keycloak.is_token_active(token): return try: - user = user.objects.get(username=username) - # Get user info based on token - user_info = keycloak.get_user_info(token) - - # Get user info and update - user.first_name = user_info.get('given_name') - user.last_name = user_info.get('family_name') - user.email = user_info.get('email') - user.save() - - except user.DoesNotExist: - user = user.objects.create_user(username, password) - + user = User.objects.get(username=username) + if isinstance(user, KeycloakUserAutoId): + # Get user info based on token + user_info = keycloak.get_user_info(token) + + # Get user info and update + user.first_name = user_info.get('given_name') + user.last_name = user_info.get('family_name') + user.email = user_info.get('email') + except User.DoesNotExist: + user = User.objects.create_user(username, password) if keycloak.has_superuser_perm(token): user.is_staff = True user.is_superuser = True - user.save() else: user.is_staff = False user.is_superuser = False - user.save() + user.save() return user def get_user(self, user_identifier): - user = get_user_model() + User = get_user_model() try: - return user.objects.get(username=user_identifier) - except user.DoesNotExist: + return User.objects.get(username=user_identifier) + except User.DoesNotExist: try: - return user.objects.get(id=user_identifier) - except user.DoesNotExist: + return User.objects.get(id=user_identifier) + except User.DoesNotExist: return None diff --git a/django_keycloak/keycloak.py b/django_keycloak/keycloak.py index e35a18e..a64619c 100644 --- a/django_keycloak/keycloak.py +++ b/django_keycloak/keycloak.py @@ -1,15 +1,24 @@ +from datetime import datetime, timezone +from functools import cached_property +from typing import Dict, List, Optional, Union from urllib.parse import urlparse +import jwt import requests from django.conf import settings +from jwt import PyJWKClient +from jwt.exceptions import InvalidTokenError from .decorators import keycloak_api_error_handler from .urls import ( + KEYCLOAK_CREATE_USER, + KEYCLOAK_DELETE_USER, KEYCLOAK_GET_TOKEN, KEYCLOAK_GET_USER_BY_ID, KEYCLOAK_GET_USERS, KEYCLOAK_INTROSPECT_TOKEN, KEYCLOAK_USER_INFO, + KEYCLOAK_OPENID_CONFIG, KEYCLOAK_UPDATE_USER, KEYCLOAK_CREATE_USER, KEYCLOAK_GET_USER_CLIENT_ROLES_BY_ID, @@ -48,9 +57,23 @@ def __init__( self.realm_admin_role = self.config.get("REALM_ADMIN_ROLE", "admin") self.graphql_endpoint = self.config.get("GRAPHQL_ENDPOINT", None) self.exempt_uris = self.config.get("EXEMPT_URIS", []) + # Flag if the token should be introspected or decoded + self.enable_token_decoding = self.config.get("DECODE_TOKEN", False) + # The percentage of a tokens valid duration until a new token is requested + self.token_timeout_factor = self.config.get("TOKEN_TIMEOUT_FACTOR", 0.9) + # A token belonging to a user and the respective introspection + self.cached_token = None + self.cached_introspect = None + # A token belonging to the client to perform user management + self._client_token = None + # Audiences for the different JWT uses + self.client_jwt_audience = self.config.get( + "CLIENT_JWT_AUDIENCE", "realm-management" + ) + self.user_jwt_audience = self.config.get("USER_JWT_AUDIENCE", "account") - except KeyError: - raise Exception("KEYCLOAK configuration is not defined.") + except KeyError as err: + raise Exception("KEYCLOAK configuration is not defined.") from err if not self.server_url: raise Exception("SERVER_URL is not defined.") @@ -64,6 +87,16 @@ def __init__( if not self.client_secret_key: raise Exception("CLIENT_SECRET_KEY is not defined.") + @cached_property + def openid_config(self): + """Collects the identity provider's configuration""" + return self._get_openid_config() + + @cached_property + def jwks_client(self): + """Collects the identity provider's public keys""" + return PyJWKClient(self.openid_config["jwks_uri"]) + def introspect(self, token): """ @param token: request token @@ -79,14 +112,7 @@ def introspect(self, token): "grant_type": "client_credentials", "client_secret": self.client_secret_key, } - headers = { - "Content-Type": "application/x-www-form-urlencoded", - } - - server_url = self.server_url - if self.internal_url: - server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + server_url, headers = self._make_form_request_config() response = requests.request( "POST", @@ -110,14 +136,7 @@ def get_token_from_credentials(self, username, password): "password": password, "scope": "openid", } - headers = { - "Content-Type": "application/x-www-form-urlencoded", - } - - server_url = self.server_url - if self.internal_url: - server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + server_url, headers = self._make_form_request_config() response = requests.request( "POST", @@ -137,14 +156,7 @@ def refresh_token_from_credentials(self, refresh_token): "client_secret": self.client_secret_key, "refresh_token": refresh_token, } - headers = { - "Content-Type": "application/x-www-form-urlencoded", - } - - server_url = self.server_url - if self.internal_url: - server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + server_url, headers = self._make_form_request_config() response = requests.request( "POST", @@ -156,23 +168,19 @@ def refresh_token_from_credentials(self, refresh_token): def get_token(self): """ - Get Token based on client credentials + Get a client token based on client credentials @return: """ + if not self._is_client_token_timed_out(): + return self._client_token + payload = { "grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.client_secret_key, } - headers = { - "Content-Type": "application/x-www-form-urlencoded", - } - - server_url = self.server_url - if self.internal_url: - server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + server_url, headers = self._make_form_request_config() response = requests.request( "POST", @@ -180,22 +188,15 @@ def get_token(self): data=payload, headers=headers, ) - return response.json().get("access_token") + self._client_token = response.json().get("access_token") + return self._client_token def get_users(self, **params): """ Get users for realm @return: """ - token = self.get_token() - headers = { - "Content-Type": "application/json", - "Authorization": "Bearer {}".format(token), - } - server_url = self.server_url - if self.internal_url: - server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + server_url, headers = self._make_secure_json_request_config() response = requests.request( "GET", @@ -209,12 +210,10 @@ def get_user_info(self, token): """ Get user information token """ - headers = {"authorization": "Bearer " + token} - server_url = self.server_url - if self.internal_url: - server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + if self.enable_token_decoding: + return self._decode_token(token, self.user_jwt_audience) + server_url, headers = self._make_secure_request_config(token) response = requests.request( "GET", KEYCLOAK_USER_INFO.format(server_url, self.realm), headers=headers ) @@ -224,13 +223,18 @@ def get_user_id(self, token): """ Verify if introspect token is active. """ - introspect_token = self.introspect(token) - return introspect_token.get("sub", None) + if self.enable_token_decoding: + token_data = self._decode_token(token, self.user_jwt_audience) + else: + token_data = self.introspect(token) + return token_data.get("sub", None) def is_token_active(self, token): """ Verify if introspect token is active. """ + if self.enable_token_decoding: + return bool(self._decode_token(token, audience=self.user_jwt_audience)) introspect_token = self.introspect(token) return introspect_token.get("active", False) @@ -238,14 +242,22 @@ def client_roles(self, token): """ Get client roles from token """ - client_id = self.introspect(token).get("resource_access").get(self.client_id) - return client_id.get("roles", []) if client_id else [] + if self.enable_token_decoding: + token_data = self._decode_token(token, self.user_jwt_audience) + else: + token_data = self.introspect(token) + client_resources = token_data.get("resource_access").get(self.client_id) + return client_resources.get("roles", []) if client_resources else [] def realm_roles(self, token): """ Get realm roles from token """ - return self.introspect(token).get("realm_access").get("roles", None) + if self.enable_token_decoding: + token_data = self._decode_token(token, self.user_jwt_audience) + else: + token_data = self.introspect(token) + return token_data.get("realm_access").get("roles", None) def client_scope(self, token): """ @@ -267,17 +279,7 @@ def get_user_info_by_id(self, user_id): """ Get user info from the id """ - - token = self.get_token() - headers = { - "Content-Type": "application/json", - "Authorization": "Bearer {}".format(token), - } - - server_url = self.server_url - if self.internal_url: - server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + server_url, headers = self._make_secure_json_request_config() response = requests.request( "GET", @@ -314,16 +316,7 @@ def update_user(self, user_id, **values): """ Update user with values """ - token = self.get_token() - headers = { - "Content-Type": "application/json", - "Authorization": "Bearer {}".format(token), - } - - server_url = self.server_url - if self.internal_url: - server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + server_url, headers = self._make_secure_json_request_config() current_user = self.get_user_info_by_id(user_id) data = { @@ -338,17 +331,102 @@ def update_user(self, user_id, **values): @keycloak_api_error_handler def create_user(self, **values): - token = self.get_token() - headers = { - "Content-Type": "application/json", - "Authorization": "Bearer {}".format(token), - } + server_url, headers = self._make_secure_json_request_config() + + url = KEYCLOAK_CREATE_USER.format(server_url, self.realm) + res = requests.post(url, headers=headers, json=values) + res.raise_for_status() + return res.headers["Location"].split("/")[-1] + @keycloak_api_error_handler + def delete_user(self, user_id): + server_url, headers = self._make_secure_json_request_config() + + url = KEYCLOAK_DELETE_USER.format(server_url, self.realm, user_id) + res = requests.delete(url, headers=headers) + res.raise_for_status() + + def _is_client_token_timed_out(self): + """ + Checks if the cached token is timed out as given by the timeout factor + + If there is no cached token, returns true. Timeout is true if the current time is + later than (expiration time - issue time) * timeout factor + issue time. + """ + if not self._client_token: + return True + + # Get the public key from the identity provider, i.e., the keycloak server + decoded = self._decode_token(self._client_token, self.client_jwt_audience) + exp = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) + iat = datetime.fromtimestamp(decoded["iat"], tz=timezone.utc) + now = datetime.now(tz=timezone.utc) + + is_timed_out = (exp - iat) * self.token_timeout_factor + iat < now + return is_timed_out + + def _get_openid_config(self): + server_url, headers = self._make_json_request_config() + + response = requests.request( + "GET", + KEYCLOAK_OPENID_CONFIG.format(server_url, self.realm), + headers=headers, + ) + return response.json() + + def _make_secure_request_config(self, token): + server_url, headers = self._make_request_config() + if not token: + token = self.get_token() + headers["Authorization"] = f"Bearer {token}" + return [server_url, headers] + + def _make_secure_json_request_config(self, token=None): + server_url, headers = self._make_request_config() + if not token: + token = self.get_token() + headers["Content-Type"] = "application/json" + headers["Authorization"] = f"Bearer {token}" + return [server_url, headers] + + def _make_form_request_config(self): + server_url, headers = self._make_request_config() + headers["Content-Type"] = "application/x-www-form-urlencoded" + return [server_url, headers] + + def _make_json_request_config(self): + server_url, headers = self._make_request_config() + headers["Content-Type"] = "application/json" + return [server_url, headers] + + def _make_request_config(self): server_url = self.server_url + headers = {} if self.internal_url: server_url = self.internal_url headers["HOST"] = urlparse(self.server_url).netloc + return [server_url, headers] - url = KEYCLOAK_CREATE_USER.format(server_url, self.realm) - res = requests.post(url, headers=headers, json=values) - res.raise_for_status() + def _decode_token( + self, token: str, audience: Union[str, List[str]], raise_error=False + ) -> Optional[Dict]: + """ + Attempts to decode the token. Returns a Dict on success or none on failure + """ + try: + # Get the public key from the identity provider, i.e., the keycloak server + public_key = self.jwks_client.get_signing_key_from_jwt(token).key + # Decode and verify the JWT with the server's public key specified in the + # JWT's header, the required audience and the allowed crypto algorithms it + # published + return jwt.decode( + token, + public_key, + audience=audience, + algorithms=self.openid_config["id_token_signing_alg_values_supported"], + ) + except InvalidTokenError as err: + if raise_error: + raise err + return None diff --git a/django_keycloak/managers.py b/django_keycloak/managers.py index 632980f..38f1f2c 100644 --- a/django_keycloak/managers.py +++ b/django_keycloak/managers.py @@ -10,11 +10,15 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def _create_user_on_keycloak( - self, username, email, first_name=None, last_name=None, enabled=True, actions=None + self, username, email, password=None, first_name=None, last_name=None, enabled=True, actions=None ): """Creates user on keycloak server, No state is changed on local db""" keycloak = Connect() values = {"username": username, "email": email, "enabled": enabled} + if password is not None: + values["credentials"] = [ + {"type": "password", "value": password, "temporary": False} + ] if first_name is not None: values["firstName"] = first_name if last_name is not None: @@ -55,7 +59,7 @@ def create_from_token(self, token, password=None, **kwargs): if not self.keycloak.is_token_active(token): raise ValueError("Invalid token") - user_info = self.keycloak.introspect(token) + user_info = self.keycloak.get_user_info(token) # set admin permissions if user is admin is_staff = False @@ -66,10 +70,7 @@ def create_from_token(self, token, password=None, **kwargs): user = self.model( id=user_info.get("sub"), - username=user_info.get("username"), - first_name=user_info.get("given_name"), - last_name=user_info.get("family_ame"), - email=user_info.get("email"), + username=user_info.get("preferred_username"), is_staff=is_staff, is_superuser=is_superuser, date_joined=timezone.now(), @@ -84,7 +85,7 @@ def get_by_keycloak_id(self, keycloak_id): def create_keycloak_user(self, *args, **kwargs): keycloak_user = self._create_user_on_keycloak(*args, **kwargs) - self.create( + return self.create( id=keycloak_user.get('id'), username=keycloak_user.get('username'), ) @@ -98,7 +99,7 @@ def create_from_token(self, token, password=None, **kwargs): if not self.keycloak.is_token_active(token): raise ValueError("Invalid token") - user_info = self.keycloak.introspect(token) + user_info = self.keycloak.get_user_info(token) # set admin permissions if user is admin is_staff = False @@ -109,7 +110,7 @@ def create_from_token(self, token, password=None, **kwargs): user = self.model( keycloak_id=user_info.get("sub"), - username=user_info.get("username"), + username=user_info.get("preferred_username"), first_name=user_info.get("given_name"), last_name=user_info.get("family_name"), email=user_info.get("email"), @@ -126,7 +127,7 @@ def get_by_keycloak_id(self, keycloak_id): def create_keycloak_user(self, *args, **kwargs): keycloak_user = self._create_user_on_keycloak(*args, **kwargs) - self.create( + return self.create( username=keycloak_user.get('username'), keycloak_id=keycloak_user.get('id'), ) diff --git a/django_keycloak/middleware.py b/django_keycloak/middleware.py index 205aa3b..3a22ee8 100644 --- a/django_keycloak/middleware.py +++ b/django_keycloak/middleware.py @@ -5,6 +5,7 @@ from django.utils.deprecation import MiddlewareMixin from django_keycloak.keycloak import Connect +from django_keycloak.models import KeycloakUserAutoId class KeycloakMiddlewareMixin: @@ -32,14 +33,16 @@ def append_user_info_to_request(self, request, token): try: user = get_user_model().objects.get_by_keycloak_id(self.keycloak.get_user_id(token)) - # If user already exists update - user.first_name = user_info.get('given_name') - user.last_name = user_info.get('family_name') - user.email = user_info.get('email') - user.save() + # Only KeycloakUserAutoId stores the user details locally + if isinstance(user, KeycloakUserAutoId): + user.first_name = user_info.get('given_name') + user.last_name = user_info.get('family_name') + user.email = user_info.get('email') + user.save() except get_user_model().DoesNotExist: - get_user_model().objects.create_from_token(token) + user = get_user_model().objects.create_from_token(token) + request.user = user return request @@ -92,6 +95,9 @@ class KeycloakMiddleware(KeycloakMiddlewareMixin, MiddlewareMixin): Middleware to validate Keycloak access based on REST validations """ + sync_capable = True + async_capable = False + def __init__(self, get_response): self.keycloak = Connect() @@ -137,7 +143,8 @@ def is_graphql_endpoint(self, request): return False path = request.path_info.lstrip('/') - if re.match(path, self.keycloak.graphql_endpoint): + is_graphql_endpoint = re.match(self.keycloak.graphql_endpoint, path) + if is_graphql_endpoint and request.method != 'GET': return True return False diff --git a/django_keycloak/mixins.py b/django_keycloak/mixins.py new file mode 100644 index 0000000..c3c783b --- /dev/null +++ b/django_keycloak/mixins.py @@ -0,0 +1,27 @@ +from django_keycloak.keycloak import Connect + + +class KeycloakTestMixin: + """ + Cleans up the users created on the Keycloak server as test side-effects. + + Stores all Keycloak users at the start of a test and compares them to those at the + end. Removes all new users. + + Usage: Add the mixin before the TestCase class and call super().setUp()/tearDown() if + adding your own setUp and tearDown functions. + + class LoginTests(KeycloakTestMixin, TestCase): + def setUp(self): + super().setUp() + ... + """ + def setUp(self): #pylint: disable=invalid-name + self.keycloak = Connect() + self._start_users = {user.get("id") for user in self.keycloak.get_users()} + + def tearDown(self): #pylint: disable=invalid-name + new_users = {user.get("id") for user in self.keycloak.get_users()} + users_to_remove = new_users.difference(self._start_users) + for user_id in users_to_remove: + self.keycloak.delete_user(user_id) diff --git a/django_keycloak/models.py b/django_keycloak/models.py index 745ee2d..11ea987 100644 --- a/django_keycloak/models.py +++ b/django_keycloak/models.py @@ -9,6 +9,10 @@ class AbstractKeycloakUser(AbstractBaseUser, PermissionsMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._cached_user_info = None + id = models.UUIDField(_("keycloak_id"), unique=True, primary_key=True) username = models.CharField(_("username"), unique=True, max_length=20) is_staff = models.BooleanField(default=False) @@ -24,27 +28,12 @@ class AbstractKeycloakUser(AbstractBaseUser, PermissionsMixin): def keycloak_identifier(self): return self.id - # @property - # def email(self): - # self._confirm_cache() - # return self._cached_user_info.get("email") - # - # @property - # def first_name(self): - # self._confirm_cache() - # return self._cached_user_info.get("firstName") - # - # @property - # def last_name(self): - # self._confirm_cache() - # return self._cached_user_info.get("lastName") - @property def full_name(self): return f"{self.first_name} {self.last_name}" def _confirm_cache(self): - if not hasattr(self, "_cached_user_info"): + if not self._cached_user_info: keycloak = Connect() self._cached_user_info = keycloak.get_user_info_by_id(self.id) @@ -62,13 +51,31 @@ def update_keycloak(self, email=None, first_name=None, last_name=None): values["lastName"] = last_name return keycloak.update_user(self.keycloak_identifier, **values) + def delete_keycloak(self): + keycloak = Connect() + keycloak.delete_user(self.keycloak_identifier) + -# Here just for compatibility issues class KeycloakUser(AbstractKeycloakUser): class Meta: swappable = "AUTH_USER_MODEL" verbose_name = _("User") verbose_name_plural = _("Users") + + @property + def email(self): + self._confirm_cache() + return self._cached_user_info.get("email") + + @property + def first_name(self): + self._confirm_cache() + return self._cached_user_info.get("firstName") + + @property + def last_name(self): + self._confirm_cache() + return self._cached_user_info.get("lastName") class AbstractKeycloakUserAutoId(AbstractKeycloakUser): diff --git a/django_keycloak/urls.py b/django_keycloak/urls.py index 6e7851f..66596af 100644 --- a/django_keycloak/urls.py +++ b/django_keycloak/urls.py @@ -8,6 +8,8 @@ KEYCLOAK_UPDATE_USER = "{}/auth/admin/realms/{}/users/{}" KEYCLOAK_CREATE_USER = "{}/auth/admin/realms/{}/users" KEYCLOAK_SEND_ACTIONS_EMAIL = "{}/auth/admin/realms/{}/users/{}/execute-actions-email" +KEYCLOAK_DELETE_USER = "{}/auth/admin/realms/{}/users/{}" +KEYCLOAK_OPENID_CONFIG = "{}/auth/realms/{}/.well-known/openid-configuration" # ADMIN CONSOLE From fd738a534e174f78946fde4bd04dc5dad99d8889 Mon Sep 17 00:00:00 2001 From: Moritz Ulmer Date: Thu, 3 Feb 2022 10:43:57 +0100 Subject: [PATCH 2/5] Fix cache checks Why: - Use a cleaner method to check if a cache has been saved - Better linting and code completion support This change addresses the need by: - Add class property in constructor as None - Instead of using hasattr --- django_keycloak/keycloak.py | 5 ++--- django_keycloak/models.py | 12 ++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/django_keycloak/keycloak.py b/django_keycloak/keycloak.py index a64619c..904ad56 100644 --- a/django_keycloak/keycloak.py +++ b/django_keycloak/keycloak.py @@ -102,9 +102,8 @@ def introspect(self, token): @param token: request token @return: introspected token """ - if hasattr(self, "cached_introspect"): - if self.cached_token == token: - return self.cached_introspect + if self.cached_introspect and self.cached_token == token: + return self.cached_introspect payload = { "token": token, diff --git a/django_keycloak/models.py b/django_keycloak/models.py index 11ea987..eb7af93 100644 --- a/django_keycloak/models.py +++ b/django_keycloak/models.py @@ -32,11 +32,6 @@ def keycloak_identifier(self): def full_name(self): return f"{self.first_name} {self.last_name}" - def _confirm_cache(self): - if not self._cached_user_info: - keycloak = Connect() - self._cached_user_info = keycloak.get_user_info_by_id(self.id) - class Meta(AbstractBaseUser.Meta): abstract = True @@ -76,6 +71,11 @@ def first_name(self): def last_name(self): self._confirm_cache() return self._cached_user_info.get("lastName") + + def _confirm_cache(self): + if not self._cached_user_info: + keycloak = Connect() + self._cached_user_info = keycloak.get_user_info_by_id(self.id) class AbstractKeycloakUserAutoId(AbstractKeycloakUser): @@ -102,7 +102,7 @@ def keycloak_identifier(self): return self.keycloak_id def _confirm_cache(self): - if not hasattr(self, "_cached_user_info"): + if not self._cached_user_info: keycloak = Connect() self._cached_user_info = keycloak.get_user_info_by_id(self.keycloak_id) From e2236ea9ae1d42bb0f12a19530b6bd1edc72c5c1 Mon Sep 17 00:00:00 2001 From: Moritz Ulmer Date: Thu, 3 Feb 2022 11:04:29 +0100 Subject: [PATCH 3/5] Refactor Keycloak method Why: - Use common request config pattern This change addresses the need by: - Refactoring out generation of headers and server url --- django_keycloak/keycloak.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/django_keycloak/keycloak.py b/django_keycloak/keycloak.py index 904ad56..c7141cd 100644 --- a/django_keycloak/keycloak.py +++ b/django_keycloak/keycloak.py @@ -291,17 +291,7 @@ def get_user_client_roles_by_id(self, user_id): """ Get user client roles from the id """ - - token = self.get_token() - headers = { - "Content-Type": "application/json", - "Authorization": "Bearer {}".format(token), - } - - server_url = self.server_url - if self.internal_url: - server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + server_url, headers = self._make_secure_json_request_config() response = requests.request( "GET", From bded4faf99bf11358c2189efeb1a85af05ea7ec0 Mon Sep 17 00:00:00 2001 From: Moritz Ulmer Date: Thu, 3 Feb 2022 11:27:30 +0100 Subject: [PATCH 4/5] Explicitly add library dependency Why: - Uses the following libraries - rest_framework - dry-rest-permissions - jwt - requests This change addresses the need by: - Adding requirements in the setup.cfg file --- setup.cfg | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.cfg b/setup.cfg index db2f9db..b79e531 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,4 +19,8 @@ include_package_data = true packages = find: install_requires = Django >= "2.2" + rest_framework ~= "3.0" + dry-rest-permissions ~= "0.1" + jwt ~= "1.0" + requests ~= "2.0" From 6387f23033acf3f2ddd1f9eea24337b706d81a82 Mon Sep 17 00:00:00 2001 From: Moritz Ulmer Date: Wed, 16 Feb 2022 11:23:18 +0100 Subject: [PATCH 5/5] Fix HOST header for internal URL Why: - HOST header must match domain used in URL This change addresses the need by: - Changing the internal and server URL in _make_request_config() --- django_keycloak/keycloak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_keycloak/keycloak.py b/django_keycloak/keycloak.py index c7141cd..159b650 100644 --- a/django_keycloak/keycloak.py +++ b/django_keycloak/keycloak.py @@ -394,7 +394,7 @@ def _make_request_config(self): headers = {} if self.internal_url: server_url = self.internal_url - headers["HOST"] = urlparse(self.server_url).netloc + headers["HOST"] = urlparse(self.internal_url).netloc return [server_url, headers] def _decode_token(