Skip to content

Commit

Permalink
Merge pull request #42 from Cellebyte/feature/authlib
Browse files Browse the repository at this point in the history
Feature/authlib. Removed jwkest and use authlib instead.
  • Loading branch information
Bono de Visser authored Nov 20, 2020
2 parents de85585 + 23b1798 commit 79ecbbf
Show file tree
Hide file tree
Showing 12 changed files with 539 additions and 106 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ target/

# IDEA
.idea/

# VSCode
.vscode/
18 changes: 18 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]
autopep8 = "*"
flake8 = "*"
tox = "*"

[packages]
django = "*"
djangorestframework = "*"
authlib = "*"
requests = "*"

[requires]
python_version = "3.8"
314 changes: 314 additions & 0 deletions Pipfile.lock

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,16 @@ OIDC_AUTH = {
# at <endpoint>/.well-known/openid-configuration
'OIDC_ENDPOINT': 'https://accounts.google.com',

# Accepted audiences the ID Tokens can be issued to
'OIDC_AUDIENCES': ('myapp',),
# The Claims Options can now be defined by a static string.
# ref: https://docs.authlib.org/en/latest/jose/jwt.html#jwt-payload-claims-validation
# The old OIDC_AUDIENCES option is removed in favor of this new option.
# `aud` is only required, when you set it as an essential claim.
'OIDC_CLAIMS_OPTIONS': {
'aud': {
'values': ['myapp'],
'essential': True,
}
},

# (Optional) Function that resolves id_token into user.
# This function receives a request and an id_token dict and expects to
Expand Down
141 changes: 87 additions & 54 deletions oidc_auth/authentication.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
from calendar import timegm
import datetime
from django.contrib.auth import get_user_model
from django.utils.encoding import smart_text
from django.utils.functional import cached_property
from jwkest import JWKESTException
from jwkest.jwk import KEYS
from jwkest.jws import JWS
import logging
import time

import requests
from authlib.jose import JsonWebKey, jwt
from authlib.jose.errors import (BadSignatureError, DecodeError,
ExpiredTokenError, JoseError)
from authlib.oidc.core.claims import IDToken
from authlib.oidc.discovery import get_well_known_url
from django.contrib.auth import get_user_model
from django.utils.encoding import smart_str
from django.utils.functional import cached_property
from django.utils.translation import ugettext as _
from requests import request
from requests.exceptions import HTTPError
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.authentication import (BaseAuthentication,
get_authorization_header)
from rest_framework.exceptions import AuthenticationFailed
import six
from .util import cache

from .settings import api_settings
from django.utils.translation import ugettext as _
from .util import cache

logger = logging.Logger(__name__)
logging.basicConfig()
logger = logging.getLogger(__name__)


def get_user_by_id(request, id_token):
Expand All @@ -30,11 +34,31 @@ def get_user_by_id(request, id_token):
return user


class DRFIDToken(IDToken):

def validate_exp(self, now, leeway):
super(DRFIDToken, self).validate_exp(now, leeway)
if now > self['exp']:
msg = _('Invalid Authorization header. JWT has expired.')
raise AuthenticationFailed(msg)

def validate_iat(self, now, leeway):
super(DRFIDToken, self).validate_iat(now, leeway)
if self['iat'] < leeway:
msg = _('Invalid Authorization header. JWT too old.')
raise AuthenticationFailed(msg)


class BaseOidcAuthentication(BaseAuthentication):
@property
@cache(ttl=api_settings.OIDC_BEARER_TOKEN_EXPIRATION_TIME)
def oidc_config(self):
return requests.get(api_settings.OIDC_ENDPOINT + '/.well-known/openid-configuration').json()
return requests.get(
get_well_known_url(
api_settings.OIDC_ENDPOINT,
external=True
)
).json()


class BearerTokenAuthentication(BaseOidcAuthentication):
Expand All @@ -58,23 +82,28 @@ def authenticate(self, request):
def get_bearer_token(self, request):
auth = get_authorization_header(request).split()
auth_header_prefix = api_settings.BEARER_AUTH_HEADER_PREFIX.lower()

if not auth or smart_text(auth[0].lower()) != auth_header_prefix:
if not auth or smart_str(auth[0].lower()) != auth_header_prefix:
return None

if len(auth) == 1:
msg = _('Invalid Authorization header. No credentials provided')
raise AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid Authorization header. Credentials string should not contain spaces.')
msg = _(
'Invalid Authorization header. Credentials string should not contain spaces.')
raise AuthenticationFailed(msg)

return auth[1]

@cache(ttl=api_settings.OIDC_BEARER_TOKEN_EXPIRATION_TIME)
def get_userinfo(self, token):
response = requests.get(self.oidc_config['userinfo_endpoint'],
headers={'Authorization': 'Bearer {0}'.format(token.decode('ascii'))})
response = requests.get(
self.oidc_config['userinfo_endpoint'],
headers={'Authorization': 'Bearer {0}'.format(
token.decode('ascii')
)
}
)
response.raise_for_status()

return response.json()
Expand All @@ -85,11 +114,22 @@ class JSONWebTokenAuthentication(BaseOidcAuthentication):

www_authenticate_realm = 'api'

@property
def claims_options(self):
_claims_options = {
'iss': {
'essential': True,
'values': [self.issuer]
}
}
for key, value in api_settings.OIDC_CLAIMS_OPTIONS.items():
_claims_options[key] = value
return _claims_options

def authenticate(self, request):
jwt_value = self.get_jwt_value(request)
if jwt_value is None:
return None

payload = self.decode_jwt(jwt_value)
self.validate_claims(payload)

Expand All @@ -101,71 +141,64 @@ def get_jwt_value(self, request):
auth = get_authorization_header(request).split()
auth_header_prefix = api_settings.JWT_AUTH_HEADER_PREFIX.lower()

if not auth or smart_text(auth[0].lower()) != auth_header_prefix:
if not auth or smart_str(auth[0].lower()) != auth_header_prefix:
return None

if len(auth) == 1:
msg = _('Invalid Authorization header. No credentials provided')
raise AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid Authorization header. Credentials string should not contain spaces.')
msg = _(
'Invalid Authorization header. Credentials string should not contain spaces.')
raise AuthenticationFailed(msg)

return auth[1]

def jwks(self):
keys = KEYS()
keys.load_jwks(self.jwks_data())
return keys
return JsonWebKey.import_key_set(self.jwks_data())

@cache(ttl=api_settings.OIDC_JWKS_EXPIRATION_TIME)
def jwks_data(self):
r = request("GET", self.oidc_config['jwks_uri'], allow_redirects=True)
r.raise_for_status()
return r.text
return r.json()

@cached_property
def issuer(self):
return self.oidc_config['issuer']

def decode_jwt(self, jwt_value):
keys = self.jwks()
try:
id_token = JWS().verify_compact(jwt_value, keys=keys)
except (JWKESTException, ValueError):
msg = _('Invalid Authorization header. JWT Signature verification failed.')
id_token = jwt.decode(
jwt_value.decode('ascii'),
self.jwks(),
claims_cls=DRFIDToken,
claims_options=self.claims_options
)
except (BadSignatureError, DecodeError):
msg = _(
'Invalid Authorization header. JWT Signature verification failed.')
logger.exception(msg)
raise AuthenticationFailed(msg)
except AssertionError:
msg = _(
'Invalid Authorization header. Please provide base64 encoded ID Token'
)
raise AuthenticationFailed(msg)

return id_token

def get_audiences(self, id_token):
return api_settings.OIDC_AUDIENCES

def validate_claims(self, id_token):
if isinstance(id_token.get('aud'), six.string_types):
# Support for multiple audiences
id_token['aud'] = [id_token['aud']]

if id_token.get('iss') != self.issuer:
msg = _('Invalid Authorization header. Invalid JWT issuer.')
raise AuthenticationFailed(msg)
if not any(aud in self.get_audiences(id_token) for aud in id_token.get('aud', [])):
msg = _('Invalid Authorization header. Invalid JWT audience.')
raise AuthenticationFailed(msg)
if len(id_token['aud']) > 1 and 'azp' not in id_token:
msg = _('Invalid Authorization header. Missing JWT authorized party.')
raise AuthenticationFailed(msg)

utc_timestamp = timegm(datetime.datetime.utcnow().utctimetuple())
if utc_timestamp > id_token.get('exp', 0):
try:
id_token.validate(
now=int(time.time()),
leeway=int(time.time()-api_settings.OIDC_LEEWAY)
)
except ExpiredTokenError:
msg = _('Invalid Authorization header. JWT has expired.')
raise AuthenticationFailed(msg)
if 'nbf' in id_token and utc_timestamp < id_token['nbf']:
msg = _('Invalid Authorization header. JWT not yet valid.')
raise AuthenticationFailed(msg)
if utc_timestamp > id_token.get('iat', 0) + api_settings.OIDC_LEEWAY:
msg = _('Invalid Authorization header. JWT too old.')
except JoseError as e:
msg = _(str(type(e)) + str(e))
raise AuthenticationFailed(msg)

def authenticate_header(self, request):
Expand Down
16 changes: 13 additions & 3 deletions oidc_auth/settings.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
from django.conf import settings
from rest_framework.settings import APISettings


USER_SETTINGS = getattr(settings, 'OIDC_AUTH', None)

DEFAULTS = {
'OIDC_ENDPOINT': None,
'OIDC_ENDPOINTS': {},
'OIDC_AUDIENCES': None,

# Currently unimplemented
'OIDC_ENDPOINTS': [],

# The Claims Options can now be defined by a static string.
# ref: https://docs.authlib.org/en/latest/jose/jwt.html#jwt-payload-claims-validation
'OIDC_CLAIMS_OPTIONS': {
'aud': {
'essential': True,
}
},

# Number of seconds in the past valid tokens can be issued
'OIDC_LEEWAY': 600,
Expand All @@ -27,6 +35,8 @@
# The Django cache to use
'OIDC_CACHE_NAME': 'default',
'OIDC_CACHE_PREFIX': 'oidc_auth.'


}

# List of settings that may be in string import notation.
Expand Down
6 changes: 4 additions & 2 deletions oidc_auth/util.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import functools

from django.core.cache import caches

from .settings import api_settings


class cache(object):
""" Cache decorator that memoizes the return value of a method for some time.
Increment the cache_version everytime your method's implementation changes in such a way that it returns values
that are not backwards compatible. For more information, see the Django cache documentation:
Increment the cache_version everytime your method's implementation changes
in such a way that it returns values that are not backwards compatible.
For more information, see the Django cache documentation:
https://docs.djangoproject.com/en/2.2/topics/cache/#cache-versioning
"""

Expand Down
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
[wheel]
universal = 1
[flake8]
max-line-length=100
10 changes: 8 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@

setup(
name='drf-oidc-auth',
version='0.10.0',
version='1.0.0',
packages=['oidc_auth'],
url='https://github.com/ByteInternet/drf-oidc-auth',
license='MIT',
author='Maarten van Schaik',
author_email='support@byte.nl',
description='OpenID Connect authentication for Django Rest Framework',
install_requires=[
'pyjwkest>=1.0.3',
'authlib>=0.15.0',
'cryptography>=2.6',
'django>=1.8.0',
'djangorestframework>=3.0.0',
'requests>=2.20.0'
],
classifiers=[
'Development Status :: 4 - Beta',
Expand All @@ -26,6 +28,10 @@
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Topic :: Internet',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Security',
Expand Down
9 changes: 7 additions & 2 deletions tests/settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
SECRET_KEY='secret'
SECRET_KEY = 'secret'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
Expand All @@ -12,5 +12,10 @@
ROOT_URLCONF = 'tests.test_authentication'
OIDC_AUTH = {
'OIDC_ENDPOINT': 'http://example.com',
'OIDC_AUDIENCES': ('you',),
'OIDC_CLAIMS_OPTIONS': {
'aud': {
'values': ['you'],
'essential': True,
}
},
}
Loading

0 comments on commit 79ecbbf

Please sign in to comment.