Skip to content

Commit

Permalink
Merge pull request #231 from alprs/impl-drf
Browse files Browse the repository at this point in the history
implement authentication class for DRF
  • Loading branch information
johngian committed Jun 19, 2018
2 parents 6fe5244 + 5a5b885 commit eb7ca6b
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 6 deletions.
29 changes: 29 additions & 0 deletions docs/drf.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
=======================================
DRF (Django REST Framework) integration
=======================================

If you want DRF to authenticate users based on an OAuth access token provided in
the ``Authorization`` header, you can use the DRF-specific authentication class
which ships with the package.

Add this to your settings:

.. code-block:: python
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'mozilla_django_oidc.contrib.drf.OIDCAuthentication',
# other authentication classes, if needed
],
}
Note that this only takes care of authenticating against an access token, and
provides no options to create or renew tokens.

If you've created a custom Django ``OIDCAuthenticationBackend`` and added that
to your ``AUTHENTICATION_BACKENDS``, the DRF class should be smart enough to
figure that out. Alternatively, you can manually set the OIDC backend to use:

.. code-block:: python
OIDC_DRF_AUTH_BACKEND = 'mozilla_django_oidc.OIDCAuthenticationBackend'
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Contents:
installation
settings
xhr
drf
contributing
authors
history
Expand Down
16 changes: 10 additions & 6 deletions mozilla_django_oidc/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,11 @@ def authenticate(self, **kwargs):

if verified_id:
self.store_tokens(access_token, id_token)
return self.get_or_create_user(access_token, id_token, verified_id)
try:
return self.get_or_create_user(access_token, id_token, verified_id)
except SuspiciousOperation as exc:
LOGGER.warning('failed to get or create user: %s', exc)
return None

return None

Expand All @@ -269,8 +273,8 @@ def get_or_create_user(self, access_token, id_token, verified_id):

claims_verified = self.verify_claims(user_info)
if not claims_verified:
LOGGER.debug('Login failed: Claims verification for %s failed.', email)
return None
msg = 'Claims verification failed'
raise SuspiciousOperation(msg)

# email based filtering
users = self.filter_users_by_claims(user_info)
Expand All @@ -279,9 +283,9 @@ def get_or_create_user(self, access_token, id_token, verified_id):
return self.update_user(users[0], user_info)
elif len(users) > 1:
# In the rare case that two user accounts have the same email address,
# log and bail. Randomly selecting one seems really wrong.
LOGGER.warn('Multiple users with email address %s.', email)
return None
# bail. Randomly selecting one seems really wrong.
msg = 'Multiple users returned'
raise SuspiciousOperation(msg)
elif import_from_settings('OIDC_CREATE_USER', True):
user = self.create_user(user_info)
return user
Expand Down
Empty file.
130 changes: 130 additions & 0 deletions mozilla_django_oidc/contrib/drf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""
Classes/functions for integrating with Django REST Framework.
http://www.django-rest-framework.org/api-guide/authentication/#custom-authentication
"""

import logging

from django.contrib.auth import get_backends
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.utils.module_loading import import_string
from rest_framework import authentication, exceptions
from requests.exceptions import HTTPError

from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from mozilla_django_oidc.utils import import_from_settings, parse_www_authenticate_header

LOGGER = logging.getLogger(__name__)


def get_oidc_backend():
"""
Get the Django auth backend that uses OIDC.
"""

# allow the user to force which back backend to use. this is mostly
# convenient if you want to use OIDC with DRF but don't want to configure
# OIDC for the "normal" Django auth.
backend_setting = import_from_settings('OIDC_DRF_AUTH_BACKEND', None)
if backend_setting:
backend = import_string(backend_setting)()
if not isinstance(backend, OIDCAuthenticationBackend):
msg = 'Class configured in OIDC_DRF_AUTH_BACKEND ' \
'does not extend OIDCAuthenticationBackend!'
raise ImproperlyConfigured(msg)
return backend

# if the backend setting is not set, look through the list of configured
# backends for one that is an OIDCAuthenticationBackend.
backends = [b for b in get_backends() if isinstance(b, OIDCAuthenticationBackend)]

if not backends:
msg = 'No backends extending OIDCAuthenticationBackend found - ' \
'add one to AUTHENTICATION_BACKENDS or set OIDC_DRF_AUTH_BACKEND!'
raise ImproperlyConfigured(msg)
if len(backends) > 1:
raise ImproperlyConfigured('More than one OIDCAuthenticationBackend found!')
return backends[0]


class OIDCAuthentication(authentication.BaseAuthentication):
"""
Provide OpenID authentication for DRF.
"""

# used by the authenticate_header method.
www_authenticate_realm = 'api'

def __init__(self, backend=None):
self.backend = backend or get_oidc_backend()

def authenticate(self, request):
"""
Authenticate the request and return a tuple of (user, token) or None
if there was no authentication attempt.
"""
access_token = self.get_access_token(request)

if not access_token:
return None

try:
user = self.backend.get_or_create_user(access_token, None, None)
except HTTPError as exc:
resp = exc.response

# if the oidc provider returns 401, it means the token is invalid.
# in that case, we want to return the upstream error message (which
# we can get from the www-authentication header) in the response.
if resp.status_code == 401 and 'www-authenticate' in resp.headers:
data = parse_www_authenticate_header(resp.headers['www-authenticate'])
raise exceptions.AuthenticationFailed(data['error_description'])

# for all other http errors, just re-raise the exception.
raise
except SuspiciousOperation as exc:
LOGGER.info('Login failed: %s', exc)
raise exceptions.AuthenticationFailed('Login failed')

if not user:
msg = 'Login failed: No user found for the given access token.'
raise exceptions.AuthenticationFailed(msg)

return user, access_token

def get_access_token(self, request):
"""
Get the access token based on a request.
Returns None if no authentication details were provided. Raises
AuthenticationFailed if the token is incorrect.
"""
header = authentication.get_authorization_header(request)
if not header:
return None
header = header.decode(authentication.HTTP_HEADER_ENCODING)

auth = header.split()

if auth[0].lower() != 'bearer':
return None

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

return auth[1]

def authenticate_header(self, request):
"""
If this method returns None, a generic HTTP 403 forbidden response is
returned by DRF when authentication fails.
By making the method return a string, a 401 is returned instead. The
return value will be used as the WWW-Authenticate header.
"""
return 'Bearer realm="%s"' % self.www_authenticate_realm
15 changes: 15 additions & 0 deletions mozilla_django_oidc/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
try:
from urllib.request import parse_http_list, parse_keqv_list
except ImportError:
# python < 3
from urllib2 import parse_http_list, parse_keqv_list

from django import VERSION
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured


def parse_www_authenticate_header(header):
"""
Convert a WWW-Authentication header into a dict that can be used
in a JSON response.
"""
items = parse_http_list(header)
return parse_keqv_list(items)


def import_from_settings(attr, *args):
"""
Load an attribute from the django settings.
Expand Down
39 changes: 39 additions & 0 deletions tests/test_contrib_drf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import mock

from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory, TestCase, override_settings
from rest_framework import exceptions

from mozilla_django_oidc.contrib.drf import OIDCAuthentication


class TestDRF(TestCase):
@override_settings(OIDC_OP_TOKEN_ENDPOINT='https://server.example.com/token')
@override_settings(OIDC_OP_USER_ENDPOINT='https://server.example.com/user')
@override_settings(OIDC_RP_CLIENT_ID='example_id')
@override_settings(OIDC_RP_CLIENT_SECRET='client_secret')
def setUp(self):
self.auth = OIDCAuthentication(backend=mock.Mock())
self.request = RequestFactory().get('/', HTTP_AUTHORIZATION='Bearer faketoken')

def test_authenticate_returns_none_if_no_access_token(self):
with mock.patch.object(self.auth, 'get_access_token', return_value=None):
ret = self.auth.authenticate(self.request)
self.assertEqual(ret, None)

def test_authenticate_raises_authenticationfailed_if_backend_returns_no_user(self):
self.auth.backend.get_or_create_user.return_value = None
with self.assertRaises(exceptions.AuthenticationFailed):
self.auth.authenticate(self.request)

def test_authenticate_raises_authenticationfailed_on_suspiciousoperation(self):
self.auth.backend.get_or_create_user.side_effect = SuspiciousOperation
with self.assertRaises(exceptions.AuthenticationFailed):
self.auth.authenticate(self.request)

def test_returns_user_and_token_if_backend_returns_user(self):
user = mock.Mock()
self.auth.backend.get_or_create_user.return_value = user
ret = self.auth.authenticate(self.request)
self.assertEqual(ret[0], user)
self.assertEqual(ret[1], 'faketoken')
3 changes: 3 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ setenv =
deps =
-r{toxinidir}/tests/requirements.txt
django111: Django>=1.11,<2.0.0
django111: djangorestframework>=3.4
django200: Django>=2.0.0
django200: djangorestframework>=3.7

[testenv:coverage]
commands =
Expand All @@ -28,6 +30,7 @@ deps =
coverage
-r{toxinidir}/tests/requirements.txt
Django>=1.11
djangorestframework>=3.4

[testenv:lint]
deps =
Expand Down

0 comments on commit eb7ca6b

Please sign in to comment.