diff --git a/docs/installation.rst b/docs/installation.rst index dc677ab9..aef99d7a 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -144,7 +144,7 @@ Next, edit your ``urls.py`` and add the following: .. code-block:: python from django.urls import path - + urlpatterns = [ # ... path('oidc/', include('mozilla_django_oidc.urls')), @@ -220,8 +220,50 @@ check to see if the user's id token has expired and if so, redirect to the OIDC provider's authentication endpoint for a silent re-auth. That will redirect back to the page the user was going to. -The length of time it takes for an id token to expire is set in -``settings.OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS`` which defaults to 15 minutes. +The length of time it takes for a token to expire is set in +``settings.OIDC_RENEW_TOKEN_EXPIRY_SECONDS``, which defaults to 15 minutes. + + +Getting a new access token using the refresh token +-------------------------------------------------- + +Alternatively, if the OIDC Provider supplies a refresh token during the +authorization phase, it can be stored in the session by setting +``settings.OIDC_STORE_REFRESH_TOKEN`` to `True`. +It will be then used by the +:py:class:`mozilla_django_oidc.middleware.RefreshOIDCAccessToken` middleware. + +The middleware will check if the user's access token has expired with the same +logic of :py:class:`mozilla_django_oidc.middleware.SessionRefresh` but, instead +of taking the user through a browser-based authentication flow, it will request +a new access token from the OP in the background. + +.. warning:: + + Using this middleware will effectively cause ID tokens to no longer be stored + in the request session, e.g., ``oidc_id_token`` will no longer be available + to Django. This is due to the fact that secure verification of the ID token + is currently not possible in the refresh flow due to not enough information + about the initial authentication being preserved in the session backend. + + If you rely on ID tokens, do not use this middleware. It is only useful if + you are relying instead on access tokens. + +To add it to your site, put it in the settings:: + + MIDDLEWARE_CLASSES = [ + # middleware involving session and authentication must come first + # ... + 'mozilla_django_oidc.middleware.RefreshOIDCAccessToken', + # ... + ] + +The length of time it takes for a token to expire is set in +``settings.OIDC_RENEW_TOKEN_EXPIRY_SECONDS``, which defaults to 15 minutes. + +.. seealso:: + + https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens Connecting OIDC user identities to Django users diff --git a/docs/settings.rst b/docs/settings.rst index 6f1e54da..0f12410d 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -79,7 +79,8 @@ of ``mozilla-django-oidc``. This is a list of absolute url paths, regular expressions for url paths, or Django view names. This plus the mozilla-django-oidc urls are exempted from - the session renewal by the ``SessionRefresh`` middleware. + the session renewal by the ``SessionRefresh`` or ``RefreshOIDCAccessToken`` + middlewares. .. py:attribute:: OIDC_CREATE_USER @@ -168,6 +169,13 @@ of ``mozilla-django-oidc``. Controls whether the OpenID Connect client stores the OIDC ``id_token`` in the user session. The session key used to store the data is ``oidc_id_token``. +.. py:attribute:: OIDC_STORE_REFRESH_TOKEN + + :default: ``False`` + + Controls whether the OpenID Connect client stores the OIDC ``refresh_token`` in the user session. + The session key used to store the data is ``oidc_refresh_token``. + .. py:attribute:: OIDC_AUTH_REQUEST_EXTRA_PARAMS :default: `{}` diff --git a/mozilla_django_oidc/auth.py b/mozilla_django_oidc/auth.py index b211571a..92f73436 100644 --- a/mozilla_django_oidc/auth.py +++ b/mozilla_django_oidc/auth.py @@ -42,6 +42,17 @@ def default_username_algo(email): return smart_text(username) +def store_tokens(session, access_token, id_token, refresh_token): + if import_from_settings('OIDC_STORE_ACCESS_TOKEN', False): + session['oidc_access_token'] = access_token + + if import_from_settings('OIDC_STORE_ID_TOKEN', False): + session['oidc_id_token'] = id_token + + if import_from_settings('OIDC_STORE_REFRESH_TOKEN', False): + session['oidc_refresh_token'] = refresh_token + + class OIDCAuthenticationBackend(ModelBackend): """Override Django's authentication.""" @@ -280,12 +291,12 @@ def authenticate(self, request, **kwargs): token_info = self.get_token(token_payload) id_token = token_info.get('id_token') access_token = token_info.get('access_token') + refresh_token = token_info.get('refresh_token') # Validate the token payload = self.verify_token(id_token, nonce=nonce) - if payload: - self.store_tokens(access_token, id_token) + self.store_tokens(access_token, id_token, refresh_token) try: return self.get_or_create_user(access_token, id_token, payload) except SuspiciousOperation as exc: @@ -294,15 +305,14 @@ def authenticate(self, request, **kwargs): return None - def store_tokens(self, access_token, id_token): + def store_tokens(self, access_token, id_token, refresh_token): """Store OIDC tokens.""" - session = self.request.session - - if self.get_settings('OIDC_STORE_ACCESS_TOKEN', False): - session['oidc_access_token'] = access_token - - if self.get_settings('OIDC_STORE_ID_TOKEN', False): - session['oidc_id_token'] = id_token + return store_tokens( + self.request.session, + access_token, + id_token, + refresh_token + ) def get_or_create_user(self, access_token, id_token, payload): """Returns a User instance if 1 user is found. Creates a user if not found diff --git a/mozilla_django_oidc/middleware.py b/mozilla_django_oidc/middleware.py index ae530f02..38c145f2 100644 --- a/mozilla_django_oidc/middleware.py +++ b/mozilla_django_oidc/middleware.py @@ -8,8 +8,9 @@ from django.utils.deprecation import MiddlewareMixin from django.utils.functional import cached_property from django.utils.module_loading import import_string +import requests -from mozilla_django_oidc.auth import OIDCAuthenticationBackend +from mozilla_django_oidc.auth import OIDCAuthenticationBackend, store_tokens from mozilla_django_oidc.utils import (absolutify, add_state_and_nonce_to_session, import_from_settings) @@ -122,16 +123,24 @@ def is_refreshable_url(self, request): not any(pat.match(request.path) for pat in self.exempt_url_patterns) ) - def process_request(self, request): + + def is_expired(self, request): if not self.is_refreshable_url(request): LOGGER.debug('request is not refreshable') - return + return False - expiration = request.session.get('oidc_id_token_expiration', 0) + expiration = request.session.get('oidc_token_expiration', 0) now = time.time() if expiration > now: # The id_token is still valid, so we don't have to do anything. LOGGER.debug('id token is still valid (%s > %s)', expiration, now) + return False + + return True + + def process_request(self, request): + + if not self.is_expired(request): return LOGGER.debug('id token has expired') @@ -179,3 +188,42 @@ def process_request(self, request): response['refresh_url'] = redirect_url return response return HttpResponseRedirect(redirect_url) + + +class RefreshOIDCAccessToken(SessionRefresh): + """ + A middleware that will refresh the access token following proper OIDC protocol: + https://auth0.com/docs/tokens/refresh-token/current + """ + def process_request(self, request): + if not self.is_expired(request): + return + + token_url = import_from_settings('OIDC_OP_TOKEN_ENDPOINT') + client_id = import_from_settings('OIDC_RP_CLIENT_ID') + client_secret = import_from_settings('OIDC_RP_CLIENT_SECRET') + refresh_token = request.session.get('oidc_refresh_token') + if not refresh_token: + LOGGER.debug('no refresh token stored') + return + + token_payload = { + 'grant_type': 'refresh_token', + 'client_id': client_id, + 'client_secret': client_secret, + 'refresh_token': refresh_token, + } + + response = requests.post(token_url, + data=token_payload, + verify=import_from_settings('OIDC_VERIFY_SSL', True)) + response.raise_for_status() + + token_info = response.json() + # Until we can properly validate an ID token on the refresh response + # per the spec[1], we intentionally drop the id_token. + # [1]: https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse + id_token = None + access_token = token_info.get('access_token') + refresh_token = token_info.get('refresh_token') + store_tokens(request.session, access_token, id_token, refresh_token) diff --git a/mozilla_django_oidc/views.py b/mozilla_django_oidc/views.py index f1d6a3a5..731ba41b 100644 --- a/mozilla_django_oidc/views.py +++ b/mozilla_django_oidc/views.py @@ -47,9 +47,13 @@ def login_success(self): auth.login(self.request, self.user) # Figure out when this id_token will expire. This is ignored unless you're - # using the RenewIDToken middleware. - expiration_interval = self.get_settings('OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS', 60 * 15) - self.request.session['oidc_id_token_expiration'] = time.time() + expiration_interval + # using the SessionRefresh or RefreshOIDCAccessToken middlewares. + expiration_interval = self.get_settings( + 'OIDC_RENEW_TOKEN_EXPIRY_SECONDS', + # Handle old configuration value + self.get_settings('OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS', 60 * 15) + ) + self.request.session['oidc_token_expiration'] = time.time() + expiration_interval return HttpResponseRedirect(self.success_url) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 70ce92d8..a71848fa 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -8,7 +8,7 @@ # Python < 3 from urlparse import parse_qs -from mock import patch +from mock import Mock, patch from django.conf.urls import url from django.contrib.auth import get_user_model @@ -20,7 +20,7 @@ from django.test import Client, RequestFactory, TestCase, override_settings from django.test.client import ClientHandler -from mozilla_django_oidc.middleware import SessionRefresh +from mozilla_django_oidc.middleware import SessionRefresh, RefreshOIDCAccessToken from mozilla_django_oidc.urls import urlpatterns as orig_urlpatterns @@ -29,7 +29,7 @@ @override_settings(OIDC_OP_AUTHORIZATION_ENDPOINT='http://example.com/authorize') @override_settings(OIDC_RP_CLIENT_ID='foo') -@override_settings(OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS=120) +@override_settings(OIDC_RENEW_TOKEN_EXPIRY_SECONDS=120) @patch('mozilla_django_oidc.middleware.get_random_string') class SessionRefreshTokenMiddlewareTestCase(TestCase): def setUp(self): @@ -117,7 +117,7 @@ def test_expired_token_forces_renewal(self, mock_middleware_random): request = self.factory.get('/foo') request.user = self.user request.session = { - 'oidc_id_token_expiration': time.time() - 10 + 'oidc_token_expiration': time.time() - 10 } response = self.middleware.process_request(request) @@ -137,6 +137,49 @@ def test_expired_token_forces_renewal(self, mock_middleware_random): self.assertEqual(expected_query, parse_qs(qs)) +class RefreshOIDCAccessTokenMiddlewareTestCase(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.middleware = RefreshOIDCAccessToken() + self.user = User.objects.create_user('example_username') + + def test_anonymous(self): + request = self.factory.get('/foo') + request.session = {} + request.user = AnonymousUser() + response = self.middleware.process_request(request) + self.assertTrue(not response) + + def test_is_oidc_path(self): + request = self.factory.get('/oidc/callback/') + request.user = AnonymousUser() + request.session = {} + response = self.middleware.process_request(request) + self.assertTrue(not response) + + def test_is_POST(self): + request = self.factory.post('/foo') + request.user = AnonymousUser() + request.session = {} + response = self.middleware.process_request(request) + self.assertTrue(not response) + + @override_settings(OIDC_OP_TOKEN_ENDPOINT='https://server.example.com/token') + @override_settings(OIDC_RP_CLIENT_ID='foo') + @override_settings(OIDC_RP_CLIENT_SECRET='client_secret') + @override_settings(OIDC_RENEW_TOKEN_EXPIRY_SECONDS=120) + @patch('mozilla_django_oidc.middleware.get_random_string') + def test_no_refresh_token_expiration_forces_renewal(self, mock_random_string): + mock_random_string.return_value = 'examplestring' + + request = self.factory.get('/foo') + request.user = self.user + request.session = {} + + response = self.middleware.process_request(request) + self.assertTrue(not response) + + # This adds a "home page" we can test against. def fakeview(req): return HttpResponse('Win!') @@ -147,12 +190,14 @@ def fakeview(req): ] -def override_middleware(fun): - classes = [ - 'django.contrib.sessions.middleware.SessionMiddleware', - 'mozilla_django_oidc.middleware.SessionRefresh', - ] - return override_settings(MIDDLEWARE=classes)(fun) +def override_middleware(middleware): + def wrap(fun): + classes = [ + 'django.contrib.sessions.middleware.SessionMiddleware', + middleware, + ] + return override_settings(MIDDLEWARE=classes)(fun) + return wrap class UserifiedClientHandler(ClientHandler): @@ -201,11 +246,11 @@ def login(self, **credentials): @override_settings(OIDC_RP_CLIENT_ID='foo') -@override_settings(OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS=120) +@override_settings(OIDC_RENEW_TOKEN_EXPIRY_SECONDS=120) @override_settings(OIDC_OP_AUTHORIZATION_ENDPOINT='http://example.com/authorize') @override_settings(ROOT_URLCONF='tests.test_middleware') -@override_middleware -class MiddlewareTestCase(TestCase): +@override_middleware('mozilla_django_oidc.middleware.SessionRefresh') +class SessionRefreshMiddlewareTestCase(TestCase): """These tests test the middleware as part of the request/response cycle""" def setUp(self): self.factory = RequestFactory() @@ -266,14 +311,14 @@ def test_anonymous(self): @override_settings(OIDC_OP_AUTHORIZATION_ENDPOINT='http://example.com/authorize') @override_settings(OIDC_RP_CLIENT_ID='foo') - @override_settings(OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS=120) + @override_settings(OIDC_RENEW_TOKEN_EXPIRY_SECONDS=120) def test_authenticated_user(self): client = ClientWithUser() client.login(username=self.user.username, password='password') # Set the expiration to some time in the future so this user is valid session = client.session - session['oidc_id_token_expiration'] = time.time() + 100 + session['oidc_token_expiration'] = time.time() + 100 session.save() resp = client.get('/mdo_fake_view/') @@ -281,7 +326,7 @@ def test_authenticated_user(self): @override_settings(OIDC_OP_AUTHORIZATION_ENDPOINT='http://example.com/authorize') @override_settings(OIDC_RP_CLIENT_ID='foo') - @override_settings(OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS=120) + @override_settings(OIDC_RENEW_TOKEN_EXPIRY_SECONDS=120) @patch('mozilla_django_oidc.middleware.get_random_string') def test_expired_token_redirects_to_sso(self, mock_middleware_random): mock_middleware_random.return_value = 'examplestring' @@ -291,7 +336,7 @@ def test_expired_token_redirects_to_sso(self, mock_middleware_random): # Set expiration to some time in the past session = client.session - session['oidc_id_token_expiration'] = time.time() - 100 + session['oidc_token_expiration'] = time.time() - 100 session['_auth_user_backend'] = 'mozilla_django_oidc.auth.OIDCAuthenticationBackend' session.save() @@ -313,7 +358,7 @@ def test_expired_token_redirects_to_sso(self, mock_middleware_random): @override_settings(OIDC_OP_AUTHORIZATION_ENDPOINT='http://example.com/authorize') @override_settings(OIDC_RP_CLIENT_ID='foo') - @override_settings(OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS=120) + @override_settings(OIDC_RENEW_TOKEN_EXPIRY_SECONDS=120) @patch('mozilla_django_oidc.middleware.get_random_string') def test_refresh_fails_for_already_signed_in_user(self, mock_random_string): mock_random_string.return_value = 'examplestring' @@ -340,7 +385,7 @@ def logged_out(sender, user=None, **kwargs): # Set expiration to some time in the past session = client.session - session['oidc_id_token_expiration'] = time.time() - 100 + session['oidc_token_expiration'] = time.time() - 100 session['_auth_user_backend'] = 'mozilla_django_oidc.auth.OIDCAuthenticationBackend' session.save() @@ -371,3 +416,53 @@ def logged_out(sender, user=None, **kwargs): # The signal we registered should have fired for this user. self.assertEqual(client.user, logged_out_users[0]) + + +@override_settings(ROOT_URLCONF='tests.test_middleware') +@override_middleware('mozilla_django_oidc.middleware.RefreshOIDCAccessToken') +class RefreshOIDCAccessTokenTestCase(TestCase): + + def setUp(self): + self.user = User.objects.create_user(username='example_username', password='password') + cache.clear() + + @override_settings(OIDC_OP_TOKEN_ENDPOINT='https://server.example.com/token') + @override_settings(OIDC_RP_CLIENT_ID='foo') + @override_settings(OIDC_RP_CLIENT_SECRET='client_secret') + @override_settings(OIDC_RENEW_TOKEN_EXPIRY_SECONDS=120) + @override_settings(OIDC_STORE_REFRESH_TOKEN=True) + @patch('mozilla_django_oidc.middleware.get_random_string') + @patch('mozilla_django_oidc.middleware.requests') + def test_refresh_token_forces_renewal(self, request_mock, mock_random_string): + mock_random_string.return_value = 'examplestring' + + post_json_mock = Mock() + post_json_mock.json.return_value = { + 'id_token': 'id_token', + 'accesss_token': 'access_token', + 'refresh_token': 'new_refresh_token' + } + request_mock.post.return_value = post_json_mock + + client = ClientWithUser() + # First confirm that the home page is a public page. + resp = client.get('/') + # At least security doesn't kick you out. + self.assertEqual(resp.status_code, 404) + # Also check that this page doesn't force you to redirect + # to authenticate. + resp = client.get('/mdo_fake_view/') + self.assertEqual(resp.status_code, 200) + client.login(username=self.user.username, password='password') + + # Set expiration to some time in the past + session = client.session + session['oidc_token_expiration'] = time.time() - 100 + session['oidc_refresh_token'] = 'examplerefreshtoken' + session['_auth_user_backend'] = 'mozilla_django_oidc.auth.OIDCAuthenticationBackend' + session.save() + + # Confirm that the session value has been refreshed. + resp = client.get('/mdo_fake_view/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(client.session['oidc_refresh_token'], 'new_refresh_token')