Skip to content

Commit

Permalink
Local Token Decoding (#30)
Browse files Browse the repository at this point in the history
* add cryptography as a dependency

* add setting to use introspection or not

* add imports

* add use introspection to KeycloakConnect

* add use introspection to the middleware

* add keycloak certs endpoint

* add jwks endpoint method

* add jwks endpoint method

* use decode or introspect to determine if token is active

* add token decode method

* add token decode method

* use introspection or token decode to get user info

* fix requires list

* pass options to decoder

* add audience and options to decode parameters

* rename certs to jwks

* add django caching

* use django caching for jwks call

* lower default cache timeout

* update readme

* fix setting cache key name

* add dependency

* set default value for use_introspection

* add local decode to userinfo

* pass raise exception to decode

* bubble up error if token is not able to be decoded

* impl non-api vue handling

* test case for jwks with no exception

* bump version

* pin versions of cryptography and pyJWT

* clarify local_decode option in readme

* make jwks endpoint cache ttl a setting

* wording update
  • Loading branch information
chmoder authored Nov 17, 2023
1 parent f360b5d commit 21e20ff
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 29 deletions.
40 changes: 26 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,22 @@ If you have recognized my effort in this initiative, please buy me a coffee when

### Via Pypi Package:

``` $ pip install django-keycloak-auth ```
`$ pip install django-keycloak-auth`

### Manually

``` $ python setup.py install ```
`$ python setup.py install`

## Dependencies

* [Python 3](https://www.python.org/)
* [requests](https://requests.readthedocs.io/en/master/)
* [Django](https://www.djangoproject.com/)
* [Django Rest Framework](https://www.django-rest-framework.org/)
- [Python 3](https://www.python.org/)
- [requests](https://requests.readthedocs.io/en/master/)
- [Django](https://www.djangoproject.com/)
- [Django Rest Framework](https://www.django-rest-framework.org/)

## Test dependences

* [unittest](https://docs.python.org/3/library/unittest.html)
- [unittest](https://docs.python.org/3/library/unittest.html)

## How to contribute

Expand All @@ -104,6 +104,10 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI

Lead Developer - Marcelo Vinicius

Co-authored-by:

- [chmoder](https://github.com/chmoder)

## Usage

1. In your application `settings.py` file, add following Middleware:
Expand All @@ -120,13 +124,21 @@ MIDDLEWARE = [
# Exempt URIS
# For example: ['core/banks', 'swagger']
KEYCLOAK_EXEMPT_URIS = []

# LOCAL_DECODE: is optional and False by default. If True
# tokens will be decoded locally. Instead of on the keycloak
# server using the introspection endpoint.

# KEYCLOAK_CACHE_TTL: number of seconds to cache keyclaok public
# keys
KEYCLOAK_CONFIG = {
'KEYCLOAK_SERVER_URL': 'http://localhost:8080/auth',
'KEYCLOAK_REALM': 'TEST',
'KEYCLOAK_CLIENT_ID': 'client-backend',
'KEYCLOAK_CLIENT_SECRET_KEY': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
'KEYCLOAK_CACHE_TTL': 60,
'LOCAL_DECODE': False
}

```

## How to apply on your Views
Expand Down Expand Up @@ -237,7 +249,7 @@ from django_keycloak_auth.decorators import keycloak_roles
def loans(request):
"""
List loan endpoint
This endpoint has configured keycloak roles only
This endpoint has configured keycloak roles only
especific GET method will be accepted in api_view.
"""
return JsonResponse({"message": request.roles})
Expand Down Expand Up @@ -296,13 +308,13 @@ $ docker-compose up
```

2. Open http://localhost:8080/ in your web browser
3. Create the following steps:
3. Create the following steps:

1. `realm`, `client` (as confidential) and your `client secret` according the [settings.py](/django-keycloak-auth/settings.py#L148) file
2. Client Roles: `director`, `judge`, `employee`
2. Client Roles: `director`, `judge`, `employee`
3. Create a new user account
4. Vinculate Client Roles into above user account


4. Run following command on another terminal:

```bash
Expand All @@ -312,7 +324,7 @@ $ python3 -m venv env && source env/bin/activate
# Install dependences for this library
$ python -m pip install --upgrade -r requirements.txt

# Generate a local distribution for django-keyclaok-auth
# Generate a local distribution for django-keyclaok-auth
# Change the version of this library if necessary
$ python setup.py sdist

Expand All @@ -327,4 +339,4 @@ $ python manage.py makemigrations && \
```

5. Starting development server at http://127.0.0.1:8000/
6. Use [Insonmina](https://insomnia.rest/) or [Postman](https://www.postman.com/) to test API's endpoints using Oauth2 as authentication mode.
6. Use [Insonmina](https://insomnia.rest/) or [Postman](https://www.postman.com/) to test API's endpoints using Oauth2 as authentication mode.
114 changes: 105 additions & 9 deletions django_keycloak_auth/keycloak.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,22 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import json
import jwt
import requests
import logging

from base64 import b64decode
from cryptography.hazmat.primitives import serialization
from django.core.cache import cache
from jwt.exceptions import DecodeError, ExpiredSignatureError
from requests import HTTPError

LOGGER = logging.getLogger(__name__)


class KeycloakConnect:
def __init__(self, server_url, realm_name, client_id, client_secret_key=None):
def __init__(self, server_url, realm_name, client_id, local_decode=False, client_secret_key=None, ):
"""Create Keycloak Instance.
Args:
Expand All @@ -37,11 +43,11 @@ def __init__(self, server_url, realm_name, client_id, client_secret_key=None):
client_id (str):
Client ID
client_secret_key (str, optional):
Client secret credencials.
Client secret credentials.
For each 'access type':
- bearer-only -> Optional
- public -> Mandatory
- confidencial -> Mandatory
- confidential -> Mandatory
Returns:
object: Keycloak object
Expand All @@ -51,6 +57,7 @@ def __init__(self, server_url, realm_name, client_id, client_secret_key=None):
self.realm_name = realm_name
self.client_id = client_id
self.client_secret_key = client_secret_key
self.local_decode = local_decode

# Keycloak useful Urls
self.well_known_endpoint = (
Expand All @@ -71,6 +78,12 @@ def __init__(self, server_url, realm_name, client_id, client_secret_key=None):
+ self.realm_name
+ "/protocol/openid-connect/userinfo"
)
self.jwks_endpoint = (
self.server_url
+ "/realms/"
+ self.realm_name
+ "/protocol/openid-connect/certs"
)

@staticmethod
def _send_request(method, url, **kwargs):
Expand Down Expand Up @@ -105,6 +118,32 @@ def well_known(self, raise_exception=True):
raise
return {}
return response

def jwks(self, raise_exception=True):
"""Dictionary of the OpenID Connect keys in Keycloak.
Args:
raise_exception: Raise exception if the request ended with a status >= 400.
Returns:
[type]: [Dictionary of keycloak keys]
"""
response = cache.get('jwks')

try:
if response is None:
response = self._send_request("GET", self.jwks_endpoint)
cache.set('jwks', response)
except HTTPError as ex:
LOGGER.error(
"Error obtaining dictionary of keys from endpoint: "
f"{self.jwks_endpoint}, response error {ex}"
)
if raise_exception:
raise
return {}

return response

def introspect(self, token, token_type_hint=None, raise_exception=True):
"""
Expand Down Expand Up @@ -165,10 +204,19 @@ def is_token_active(self, token, raise_exception=True):
raise_exception: Raise exception if the request ended with a status >= 400.
Returns:
bollean: Token valid (True) or invalid (False)
boolean: Token valid (True) or invalid (False)
"""
introspect_token = self.introspect(token, raise_exception)
is_active = introspect_token.get("active", None)

if self.local_decode:
try:
self.decode(token, options={"verify_exp": True}, raise_exception=raise_exception)
is_active = True
except ExpiredSignatureError as e:
is_active = False
else:
introspect_token = self.introspect(token, raise_exception)
is_active = introspect_token.get("active", None)

return True if is_active else False

def roles_from_token(self, token, raise_exception=True):
Expand All @@ -182,7 +230,10 @@ def roles_from_token(self, token, raise_exception=True):
Returns:
list: List of roles.
"""
token_decoded = self.introspect(token, raise_exception)
if self.local_decode:
token_decoded = self.decode(token, raise_exception=raise_exception)
else:
token_decoded = self.introspect(token, raise_exception)

realm_access = token_decoded.get("realm_access", None)
resource_access = token_decoded.get("resource_access", None)
Expand Down Expand Up @@ -217,8 +268,11 @@ def userinfo(self, token, raise_exception=True):
"""
headers = {"authorization": "Bearer " + token}
try:
response = self._send_request(
"GET", self.userinfo_endpoint, headers=headers)
if self.local_decode:
response = self.decode(token, raise_exception=raise_exception)
else:
response = self._send_request(
"GET", self.userinfo_endpoint, headers=headers)
except HTTPError as ex:
LOGGER.error(
"Error obtaining userinfo token from endpoint: "
Expand All @@ -228,4 +282,46 @@ def userinfo(self, token, raise_exception=True):
if raise_exception:
raise
return {}

return response

def decode(self, token, audience=None, options=None, raise_exception=True):
"""Decodes token.
Args:
token (str): The string value of the token
audience (str | List[str] | None): The audience to validate
options (dict): The options for jwt.decode https://pyjwt.readthedocs.io/en/stable/api.html?highlight=options
raise_exception: Raise exception the token cannot be decoded or validated
Returns:
json: decoded token
"""

if audience is None:
audience = self.client_id

jwks = self.jwks()
keys = jwks.get('keys', [])

public_keys = {}
for jwk in keys:
kid = jwk.get('kid')
if kid:
public_keys[kid] = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))

kid = jwt.get_unverified_header(token).get('kid', '')
key = public_keys.get(kid, '')

try:
payload = jwt.decode(token, key=key, algorithms=['RS256'], audience=audience, options=options)
except Exception as ex:
LOGGER.error(
f"Error decoding token {ex}"
)
if raise_exception:
raise
return {}

return payload

25 changes: 23 additions & 2 deletions django_keycloak_auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import logging
import re
from .keycloak import KeycloakConnect
from django.conf import settings
from django.http.response import JsonResponse
from rest_framework.exceptions import PermissionDenied, AuthenticationFailed, NotAuthenticated

LOGGER = logging.getLogger(__name__)

class KeycloakConfig:

Expand Down Expand Up @@ -50,7 +52,14 @@ def __init__(self):
raise ValueError("The mandatory KEYCLOAK_CLIENT_ID configuration variables has not defined.")

if config['KEYCLOAK_CLIENT_SECRET_KEY'] is None:
raise ValueError("The mandatory KEYCLOAK_CLIENT_SECRET_KEY configuration variables has not defined.")
raise ValueError("The mandatory KEYCLOAK_CLIENT_SECRET_KEY configuration variables has not defined.")

if config.get('LOCAL_DECODE') is None:
self.local_decode = False
elif not isinstance(config.get('LOCAL_DECODE'), bool):
raise ValueError("The LOCAL_DECODE configuration variable must be True or False.")
else:
self.local_decode = config.get('LOCAL_DECODE')


class KeycloakMiddleware:
Expand All @@ -67,6 +76,7 @@ def __init__(self, get_response):
self.keycloak = KeycloakConnect(server_url=self.keycloak_config.server_url,
realm_name=self.keycloak_config.realm,
client_id=self.keycloak_config.client_id,
local_decode=self.keycloak_config.local_decode,
client_secret_key=self.keycloak_config.client_secret_key)

def __call__(self, request):
Expand Down Expand Up @@ -111,7 +121,18 @@ def process_view(self, request, view_func, view_args, view_kwargs):
# Get access token from the http request header
auth_header = request.META.get('HTTP_AUTHORIZATION').split()
token = auth_header[1] if len(auth_header) == 2 else auth_header[0]


# Checks if the token is able to be decoded
try:
if self.keycloak_config.local_decode:
self.keycloak.decode(token, options={'verify_signature': False})
except Exception as ex:
LOGGER.error(f'Error in django_keycloak_auth middleware: {ex}')
return JsonResponse(
{"detail": "Invalid or expired token. Verify your Keycloak configuration."},
status=AuthenticationFailed.status_code
)

# Checks token is active
if not self.keycloak.is_token_active(token):
return JsonResponse(
Expand Down
22 changes: 20 additions & 2 deletions django_keycloak_auth/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,29 @@
# Roles registered within the client to be used in the Views of any application.
# Its use is to deal with very specific business rules for each method of a view.

# KEYCLOAK_CACHE_TIMEOUT - time to expire cache in seconds for public keys (jwks_endpoint)
# LOCAL_DECODE - decode tokens locally or use keycloak introspection endpoint

# For example: ['core/banks', 'swagger']
KEYCLOAK_EXEMPT_URIS = []
KEYCLOAK_CONFIG = {
'KEYCLOAK_SERVER_URL': 'http://localhost:8080/auth',
'KEYCLOAK_REALM': 'TEST',
'KEYCLOAK_CLIENT_ID': 'client-backend',
'KEYCLOAK_CLIENT_SECRET_KEY': 'E2n41fJgl9BPIS3nDk1DQQ7BIPf6PauH'
}
'KEYCLOAK_CLIENT_SECRET_KEY': 'E2n41fJgl9BPIS3nDk1DQQ7BIPf6PauH',
'KEYCLOAK_CACHE_TTL': 60,
'LOCAL_DECODE': False,
}


# Cache
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-CACHES
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'django_keycloak_auth',
'TIMEOUT': KEYCLOAK_CONFIG['KEYCLOAK_CACHE_TTL'],
'KEY_PREFIX': 'django_keycloak_auth_'
}
}
CACHE_MIDDLEWARE_KEY_PREFIX = 'django_keycloak_auth_'
Loading

0 comments on commit 21e20ff

Please sign in to comment.