From fd0938ffd2299fcba9ec6344bc39fe407aaca1fb Mon Sep 17 00:00:00 2001 From: WinnyTroy Date: Mon, 31 May 2021 13:03:41 +0300 Subject: [PATCH] Use onaio-oidc library to authenticate with open-id --- .../viewsets/test_openid_connect_viewset.py | 212 ------------------ .../api/viewsets/openid_connect_viewset.py | 211 ----------------- onadata/apps/main/urls.py | 21 +- .../tests/utils/test_openid_connect_tools.py | 100 --------- onadata/libs/utils/openid_connect_tools.py | 210 ----------------- onadata/settings/common.py | 29 +++ requirements/base.in | 1 + requirements/base.pip | 14 +- 8 files changed, 44 insertions(+), 754 deletions(-) delete mode 100644 onadata/apps/api/tests/viewsets/test_openid_connect_viewset.py delete mode 100644 onadata/apps/api/viewsets/openid_connect_viewset.py delete mode 100644 onadata/libs/tests/utils/test_openid_connect_tools.py delete mode 100644 onadata/libs/utils/openid_connect_tools.py diff --git a/onadata/apps/api/tests/viewsets/test_openid_connect_viewset.py b/onadata/apps/api/tests/viewsets/test_openid_connect_viewset.py deleted file mode 100644 index 5c5b03a7c1..0000000000 --- a/onadata/apps/api/tests/viewsets/test_openid_connect_viewset.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -Test OpenIDViewset module -""" -from django.contrib.auth.models import User -from django.test.utils import override_settings - -from mock import patch - -from onadata.apps.api.tests.viewsets.test_abstract_viewset import \ - TestAbstractViewSet -from onadata.apps.api.viewsets.openid_connect_viewset import \ - OpenIDConnectViewSet - -OPENID_CONNECT_PROVIDERS = { - 'msft': { - 'authorization_endpoint': 'http://test.msft.oidc.com/authorize', - 'client_id': 'test', - 'client_secret': 'test', - 'jwks_endpoint': 'http://test.msft.oidc.com/jwks', - 'token_endpoint': 'http://test.msft.oidc.com/token', - 'callback_uri': 'http://127.0.0.1:8000/oidc/msft/callback', - 'target_url_after_auth': 'http://localhost:3000', - 'target_url_after_logout': 'http://localhost:3000', - 'domain_cookie': '', - 'claims': {}, - 'end_session_endpoint': 'http://test.msft.oidc.com/oidc/logout', - 'scope': 'openid', - 'response_type': 'idtoken', - 'response_mode': 'form-post', - } -} - - -class TestOpenIDConnectViewSet(TestAbstractViewSet): - """ - Test OpenIDConnectViewSet - """ - - def setUp(self): - """ - Setup function for TestOpenIDConnectViewSet - """ - super(self.__class__, self).setUp() - self.view = OpenIDConnectViewSet.as_view({ - 'get': 'callback', - 'post': 'callback' - }) - - @override_settings(OPENID_CONNECT_PROVIDERS=OPENID_CONNECT_PROVIDERS) - @patch(('onadata.apps.api.viewsets.openid_connect_viewset.' - 'OpenIDHandler.verify_and_decode_id_token')) - def test_redirect_on_successful_authentication(self, - mock_get_decoded_id_token): - """ - Test that user is redirected on successful authentication - """ - mock_get_decoded_id_token.return_value = { - 'given_name': self.user_profile_data().get('first_name'), - 'family_name': self.user_profile_data().get('last_name'), - 'email': self.user_profile_data().get('email') - } - - data = {'id_token': 123456} - request = self.factory.post('/', data=data) - response = self.view(request, openid_connect_provider='msft') - self.assertEqual(response.status_code, 302) - - @override_settings(OPENID_CONNECT_PROVIDERS=OPENID_CONNECT_PROVIDERS) - @patch(('onadata.apps.api.viewsets.openid_connect_viewset.' - 'OpenIDHandler.verify_and_decode_id_token')) - def test_redirect_non_existing_user_to_enter_username( - self, mock_get_decoded_id_token): - """ - Test that a none exisant user is redirected to the username setting - page - """ - mock_get_decoded_id_token.return_value = { - 'given_name': 'john', - 'family_name': 'doe', - 'email': 'john@doe.test' - } - - data = {"id_token": 123456} - request = self.factory.post( - '/', data=data) - response = self.view(request, openid_connect_provider='msft') - self.assertEqual(response.status_code, 200) - self.assertIn("Preferred Username", - response.rendered_content.decode('utf-8')) - - @override_settings(OPENID_CONNECT_PROVIDERS=OPENID_CONNECT_PROVIDERS) - @patch(('onadata.apps.api.viewsets.openid_connect_viewset.' - 'OpenIDHandler.verify_and_decode_id_token')) - def test_trigger_error_on_existing_username(self, - mock_get_decoded_id_token): - """ - Test that an error is displayed on the rendered Username setting page - when a user with the entered username exists - """ - mock_get_decoded_id_token.return_value = { - 'given_name': 'john', - 'family_name': 'doe', - 'email': 'john@doe.test' - } - - data = { - "id_token": 123456, - 'username': self.user_profile_data().get('username')} - request = self.factory.post('/', data=data) - response = self.view(request, openid_connect_provider='msft') - self.assertEqual(response.status_code, 200) - self.assertIn(("The username provided already exists. " - "Please choose a different one"), - response.rendered_content.decode('utf-8')) - - # Should raise error for differently cased versions of the username - data = { - "id_token": 123456, - 'username': self.user_profile_data().get('username').upper()} - request = self.factory.post('/', data=data) - response = self.view(request, openid_connect_provider='msft') - self.assertEqual(response.status_code, 200) - self.assertIn(("The username provided already exists. " - "Please choose a different one"), - response.rendered_content.decode('utf-8')) - - @override_settings(OPENID_CONNECT_PROVIDERS=OPENID_CONNECT_PROVIDERS) - @patch(('onadata.apps.api.viewsets.openid_connect_viewset.' - 'OpenIDHandler.verify_and_decode_id_token')) - def test_create_non_existing_user(self, mock_get_decoded_id_token): - """ - Test that a user is created when the username is available and - redirects to the target url after auth - """ - mock_get_decoded_id_token.return_value = { - 'given_name': 'john', - 'family_name': 'doe', - 'email': 'john@doe.com' - } - data = {'id_token': 124, 'username': 'john'} - user_count = User.objects.filter(username='john').count() - request = self.factory.post('/', data=data) - response = self.view(request, openid_connect_provider='msft') - self.assertEqual( - user_count + 1, User.objects.filter(username='john').count()) - self.assertEqual(response.status_code, 302) - - # Uses last_name as first_name if missing - mock_get_decoded_id_token.return_value = { - 'family_name': 'davis', - 'email': 'davis@justdavis.com' - } - data = {'id_token': 124, 'username': 'davis'} - user_count = User.objects.filter(username='davis').count() - request = self.factory.post('/', data=data) - response = self.view(request, openid_connect_provider='msft') - self.assertEqual( - user_count + 1, User.objects.filter(username='john').count()) - self.assertEqual(response.status_code, 302) - user = User.objects.get(username='davis') - self.assertEqual(user.first_name, 'davis') - - # Returns a 400 response if both family_name and given_name - # are missing - mock_get_decoded_id_token.return_value = { - 'email': 'jake@doe.com' - } - data = {'id_token': 124, 'username': 'jake'} - request = self.factory.post('/', data=data) - response = self.view(request, openid_connect_provider='msft') - self.assertEqual(response.status_code, 400) - - @override_settings(OPENID_CONNECT_PROVIDERS=OPENID_CONNECT_PROVIDERS) - @patch(('onadata.apps.api.viewsets.openid_connect_viewset.' - 'OpenIDHandler.verify_and_decode_id_token')) - def test_redirect_to_missing_detail_on_missing_email( - self, mock_get_decoded_id_token): - """ - Test that the user is redirected to the missing detail page when the - email is not retrieved successfully - """ - mock_get_decoded_id_token.return_value = { - 'given_name': 'john', - 'family_name': 'doe', - } - - data = {"id_token": 123456, 'username': 'john'} - request = self.factory.post( - '/', data=data) - response = self.view(request, openid_connect_provider='msft') - self.assertEqual(response.status_code, 200) - self.assertIn('Please set an email as an alias', - response.rendered_content.decode('utf-8')) - - @override_settings(OPENID_CONNECT_PROVIDERS=OPENID_CONNECT_PROVIDERS) - def test_400_on_unavailable_token(self): - """ - Test a 400 is returned when a token is not available - """ - request = self.factory.get('/') - response = self.view(request, openid_connect_provider='msft') - self.assertEqual(response.status_code, 400) - - @override_settings(OPENID_CONNECT_PROVIDERS=OPENID_CONNECT_PROVIDERS) - def test_400_on_unconfigured_provider(self): - """ - Test that the endpoint returns a 400 if the utilized provider - is not configured - """ - request = self.factory.get('/') - response = self.view(request, openid_connect_provider='fake') - self.assertEquals(response.status_code, 400) diff --git a/onadata/apps/api/viewsets/openid_connect_viewset.py b/onadata/apps/api/viewsets/openid_connect_viewset.py deleted file mode 100644 index 39591b6a83..0000000000 --- a/onadata/apps/api/viewsets/openid_connect_viewset.py +++ /dev/null @@ -1,211 +0,0 @@ -import secrets -import json - -from django.conf import settings -from django.contrib.auth.models import User -from django.core.cache import cache -from django.http import ( - HttpResponseBadRequest, HttpResponseRedirect) -from django.utils.translation import ugettext as _ - -import jwt -from rest_framework import viewsets -from rest_framework.decorators import action -from rest_framework.permissions import AllowAny -from rest_framework.renderers import TemplateHTMLRenderer -from rest_framework.response import Response - -from onadata.apps.main.models import UserProfile -from onadata.libs.utils.openid_connect_tools import ( - EMAIL, FIRST_NAME, LAST_NAME, NONCE, OpenIDHandler) - - -class OpenIDConnectViewSet(viewsets.ViewSet): - """ - OpenIDConnectViewSet: Handles OpenID connect authentication - - This OpenID Connect authentication flow requires the OpenID - provider to provide the optional 'given_name', 'family_name' and 'email' - claims in their payload - """ - permission_classes = [AllowAny] - authentication_classes = [] - renderer_classes = (TemplateHTMLRenderer, ) - - @action(methods=['GET'], detail=False) - def initiate_oidc_flow( # pylint: disable=no-self-use - self, request, **kwargs): - """ - This endpoint initiates the OpenID Connect Flow by generating a request - to the OpenID Connect Provider with a cached none for verification of - the returned request - """ - provider_config, openid_provider = retrieve_provider_config( - **kwargs) - - if provider_config: - nonce = secrets.randbits(16) - cache.set(nonce, openid_provider) - - return OpenIDHandler(provider_config).make_login_request( - nonce=nonce) - else: - return HttpResponseBadRequest() - - @action(methods=['GET'], detail=False) - def expire(self, request, **kwargs): # pylint: disable=no-self-use - """ - This endpoint ends an Open ID Connect - authenticated user session - """ - provider_config = retrieve_provider_config( - **kwargs)[0] - - if provider_config: - oidc_handler = OpenIDHandler(provider_config) - return oidc_handler.end_openid_provider_session() - else: - return HttpResponseBadRequest() - - @action(methods=['GET', 'POST'], detail=False) - def callback(self, request, **kwargs): # pylint: disable=no-self-use - """ - This endpoint handles all callback requests made - by an Open ID Connect Provider. Verifies that the request from the - provider is valid and creates or gets a User - """ - id_token = None - user = None - provider_config, openid_provider = retrieve_provider_config( - **kwargs) - id_token = request.POST.get('id_token') - data = { - 'logo_data_uri': - getattr(settings, 'OIDC_LOGO_DATA_URI', - 'https://ona.io/img/onadata-logo.png') - } - - if not provider_config: - return HttpResponseBadRequest() - - oidc_handler = OpenIDHandler(provider_config) - - if not id_token: - # Use Authorization code if present to retrieve ID Token - if request.query_params.get('code'): - id_token = oidc_handler.obtain_id_token_from_code( - request.query_params.get('code'), - openid_provider=openid_provider) - else: - return HttpResponseBadRequest() - - data.update({"id_token": id_token}) - username = request.POST.get('username') - decoded_token = oidc_handler.verify_and_decode_id_token( - id_token, cached_nonce=True, openid_provider=openid_provider) - claim_values = oidc_handler.get_claim_values( - [EMAIL, FIRST_NAME, LAST_NAME], - decoded_token) - - if username: - if get_user({"username__iexact": username}): - error_msg = _("The username provided already exists. " - "Please choose a different one.") - data = {'error_msg': error_msg, 'id_token': id_token} - else: - email = claim_values.get(EMAIL) - - if not email: - data.update({ - 'missing_data': - 'email', - 'error_resolver': - 'Please set an email as an alias on your Open ID' + - ' Connect providers({}) User page'.format( - openid_provider) - }) - return Response( - data, template_name='missing_oidc_detail.html') - - last_name = claim_values.get(LAST_NAME) - first_name = claim_values.get(FIRST_NAME) - if not first_name and not last_name: - return HttpResponseBadRequest( - json.dumps( - _('Missing required claims/fields:' - f' {FIRST_NAME}, {LAST_NAME}'))) - else: - first_name = first_name or last_name - - user = create_or_get_user(first_name, last_name, email, - username) - else: - user = get_user({'email': claim_values.get(EMAIL)}) - - if user: - # On Successful login delete the cached nonce - cache.delete(claim_values.get(NONCE)) - - return get_redirect_sso_response( - redirect_uri=provider_config.get('target_url_after_auth'), - email=user.email, - domain=provider_config.get('domain_cookie')) - elif data.get('id_token'): - return Response(data, template_name='oidc_username_entry.html') - else: - return HttpResponseBadRequest( - json.dumps(_(f'Unable to authenticate with {openid_provider}')) - ) - - -def create_or_get_user( - first_name: str, last_name: str, email: str, username: str): - """ - This function creates or retrieves a User Object - """ - user, created = User.objects.get_or_create(email=email, - defaults={ - 'first_name': first_name, - 'last_name': last_name, - 'username': username - }) - if created: - UserProfile.objects.create(user=user) - - return user - - -def retrieve_provider_config(openid_connect_provider: str): - """ - This function retrieves a particular OpenID Connect providers - provider_config - """ - provider = getattr(settings, 'OPENID_CONNECT_PROVIDERS', - {}).get(openid_connect_provider, {}) - - return(provider, openid_connect_provider) - - -def get_user(kwargs): - """ - This function tries to retrieve a user using the passed in kwargs - """ - return User.objects.filter(**kwargs).first() - - -def get_redirect_sso_response( - redirect_uri: str, email: str, domain: str = None): - """ - Returns an HttpResponseRedirect object and sets an - Single Sign On (SSO) cookie to the object - """ - value = jwt.encode({'email': email}, - settings.JWT_SECRET_KEY, - algorithm=settings.JWT_ALGORITHM) - redirect_response = HttpResponseRedirect(redirect_uri) - redirect_response.set_cookie('SSO', - value=value, - max_age=None, - domain=domain) - - return redirect_response diff --git a/onadata/apps/main/urls.py b/onadata/apps/main/urls.py index d60db2f3db..2f570b6b9a 100644 --- a/onadata/apps/main/urls.py +++ b/onadata/apps/main/urls.py @@ -25,9 +25,6 @@ from onadata.apps.restservice import views as restservice_views from onadata.apps.sms_support import views as sms_support_views from onadata.apps.viewer import views as viewer_views -from onadata.apps.api.viewsets.openid_connect_viewset import ( - OpenIDConnectViewSet -) from onadata.apps.api.viewsets.xform_viewset import XFormViewSet from onadata.libs.utils.analytics import init_analytics @@ -43,6 +40,8 @@ re_path(r'^i18n/', include(i18n)), url('^api/v1/', include(api_v1_router.urls)), url('^api/v2/', include(api_v2_router.urls)), + # open id connect urls + url(r"^", include("oidc.urls")), re_path(r'^api-docs/', RedirectView.as_view(url=settings.STATIC_DOC, permanent=True)), re_path(r'^api/$', @@ -205,22 +204,6 @@ '/(?P[^/]+)$', viewer_views.export_download, name='export-download'), - # open id connect urls - re_path(r'^oidc/(?P\w+)/login$', - OpenIDConnectViewSet.as_view({ - 'get': 'initiate_oidc_flow', - 'head': 'callback', - 'post': 'callback' - }), name='open-id-connect-login'), - re_path(r'^oidc/(?P\w+)/expire$', - OpenIDConnectViewSet.as_view({ - 'get': 'expire' - }), name='open-id-connect-logout'), - re_path(r'^oidc/(?P\w+)/callback$', - OpenIDConnectViewSet.as_view({ - 'get': 'callback', 'head': 'callback', 'post': 'callback' - }), name='open-id-connect-callback'), - # xform versions urls re_path(r'^api/v1/forms/(?P[^/.]+)/versions/(?P[^/.]+)$', # noqa XFormViewSet.as_view({'get': 'versions'}), diff --git a/onadata/libs/tests/utils/test_openid_connect_tools.py b/onadata/libs/tests/utils/test_openid_connect_tools.py deleted file mode 100644 index 6740f13647..0000000000 --- a/onadata/libs/tests/utils/test_openid_connect_tools.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Test module for Open ID Connect tools -""" -from onadata.apps.main.tests.test_base import TestBase -from onadata.libs.utils.openid_connect_tools import (EMAIL, LAST_NAME, - OpenIDHandler) - -OPENID_CONNECT_PROVIDERS = { - 'msft': { - 'authorization_endpoint': 'http://test.msft.oidc.com/authorize', - 'client_id': 'test', - 'client_secret': 'test', - 'jwks_endpoint': 'http://test.msft.oidc.com/jwks', - 'token_endpoint': 'http://test.msft.oidc.com/token', - 'callback_uri': 'http://127.0.0.1:8000/oidc/msft/callback', - 'target_url_after_auth': 'http://localhost:3000', - 'target_url_after_logout': 'http://localhost:3000', - 'domain_cookie': '', - 'claims': { - EMAIL: 'sub', - LAST_NAME: 'lname' - }, - 'end_session_endpoint': 'http://test.msft.oidc.com/oidc/logout', - 'scope': 'openid', - 'response_type': 'idtoken', - 'response_mode': 'form-post', - } -} - - -class TestOpenIDConnectTools(TestBase): - def setUp(self): - self.oidc_handler = OpenIDHandler(OPENID_CONNECT_PROVIDERS['msft']) - - def test_gets_claim_values(self): - """ - Test the the get_claim_values function returns the - correct claim values - """ - decoded_token = { - 'at_hash': 'mU342-Fsdsk', - 'sub': 'some@email.com', - 'amr': [ - "Basic Authenticator" - ], - 'iss': 'http://test.msft.oidc.com/oauth2/token', - 'nonce': '12232', - 'lname': 'User', - 'given_name': 'Ted' - } - - claim_values = self.oidc_handler.get_claim_values( - ['email', 'given_name', 'family_name'], decoded_token) - values = { - 'email': decoded_token.get('sub'), - 'given_name': decoded_token.get('given_name'), - 'family_name': decoded_token.get('lname') - } - self.assertEqual(values, claim_values) - - # Test retrieves default values if claim is not set - config = OPENID_CONNECT_PROVIDERS['msft'] - config.pop('claims') - oidc_handler = OpenIDHandler(config) - - decoded_token = { - 'at_hash': 'mU342-Fsdsk', - 'sub': 'sdadasdasda', - 'amr': [ - "Basic Authenticator" - ], - 'iss': 'http://test.msft.oidc.com/oauth2/token', - 'nonce': '12232', - 'email': 'some@email.com', - 'family_name': 'User', - 'given_name': 'Ted' - } - claim_values = oidc_handler.get_claim_values( - ['email', 'given_name', 'family_name'], decoded_token) - values = { - 'email': decoded_token.get('email'), - 'given_name': decoded_token.get('given_name'), - 'family_name': decoded_token.get('family_name') - } - self.assertEqual(values, claim_values) - - def test_make_login_request(self): - """ - Test that the make_login_request function returns - a HttpResponseRedirect object pointing to the correct - url - """ - response = self.oidc_handler.make_login_request(nonce=12323) - expected_url = ('http://test.msft.oidc.com/authorize?nonce=12323' - '&client_id=test&redirect_uri=http://127.0.0.1:8000' - '/oidc/msft/callback&scope=openid&' - 'response_type=idtoken&response_mode=form-post') - - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, expected_url) diff --git a/onadata/libs/utils/openid_connect_tools.py b/onadata/libs/utils/openid_connect_tools.py deleted file mode 100644 index 3eb45cbc54..0000000000 --- a/onadata/libs/utils/openid_connect_tools.py +++ /dev/null @@ -1,210 +0,0 @@ -""" - OpenID Connect Tools -""" -import json - -from django.http import HttpResponseRedirect, Http404 -from django.core.cache import cache -from django.utils.translation import ugettext as _ - -import jwt -import requests -from jwt.algorithms import RSAAlgorithm - -EMAIL = 'email' -FIRST_NAME = 'given_name' -LAST_NAME = 'family_name' -NONCE = 'nonce' - - -class OpenIDHandler: - """ - Base OpenID Connect Handler - - Implements functions neccessary to implement the OpenID Connect - 'code' or 'id_token' authorization flow - """ - - def __init__( - self, - provider_configuration: dict - ): - """ - Initializes a OpenIDHandler Object to handle all OpenID Connect - grant flows - """ - - self.provider_configuration = provider_configuration - self.client_id = provider_configuration.get('client_id') - self.client_secret = provider_configuration.get('client_secret') - - def make_login_request(self, nonce: int, state=None): - """ - Makes a login request to the "authorization_endpoint" listed in the - provider_configuration - """ - if 'authorization_endpoint' in self.provider_configuration: - url = self.provider_configuration['authorization_endpoint'] - url += f'?nonce={nonce}' - - if state: - url += f'&state={state}' - else: - raise ValueError( - 'authorization_endpoint not found in provider configuration') - - if 'client_id' in self.provider_configuration: - url += '&client_id=' + self.provider_configuration['client_id'] - else: - raise ValueError('client_id not found in provider configuration') - - if 'callback_uri' in self.provider_configuration: - url += '&redirect_uri=' + self.provider_configuration[ - 'callback_uri'] - else: - raise ValueError('client_id not found in provider configuration') - - if 'scope' in self.provider_configuration: - url += '&scope=' + self.provider_configuration['scope'] - else: - raise ValueError('scope not found in provider configuration') - - if 'response_type' in self.provider_configuration: - url += '&response_type=' + self.provider_configuration[ - 'response_type'] - else: - raise ValueError( - 'response_type not found in provider configuration') - - if 'response_mode' in self.provider_configuration: - url += '&response_mode=' + self.provider_configuration[ - 'response_mode'] - - return HttpResponseRedirect(url) - - def get_claim_values(self, claim_list: list, decoded_token: dict): - """ - Retrieves claim values from a decoded_token based on the claim name - either configured in the provider configuration or the passed in - claim - - :params - claim_list: A list of strings containing the name of claim - decoded_token: A dict containing the decoded values of an ID Token - """ - claim_values = {} - claim_names = self.provider_configuration.get('claims') - - for claim in claim_list: - claim_name = claim - - if claim_names: - if claim_names.get(claim): - claim_name = claim_names.get(claim) - - claim_values[claim] = decoded_token.get(claim_name) - - return claim_values - - def _retrieve_jwk_related_to_kid(self, kid): - """ - Retrieves the JSON Web Key used to sign the ID Token - from the JSON Web Key Set Endpoint - """ - if "jwks_endpoint" not in self.provider_configuration: - raise ValueError( - "jwks_endpoint not found in provider configuration") - - response = requests.get(self.provider_configuration['jwks_endpoint']) - - if response.status_code == 200: - jwks = response.json() - for jwk in jwks.get('keys'): - if jwk.get('kid') == kid: - return jwk - - def obtain_id_token_from_code(self, code: str, openid_provider: str = ''): - """ - Obtain an ID Token using the Authorization Code flow - """ - headers = {'Content-Type': 'application/x-www-form-urlencoded'} - payload = { - 'grant_type': 'authorization_code', - 'code': code, - 'client_id': self.client_id, - 'client_secret': self.client_secret, - 'redirect_uri': self.provider_configuration.get('callback_uri') - } - - if "token_endpoint" not in self.provider_configuration: - raise ValueError("token_endpoint not in provider configuration") - - response = requests.post( - self.provider_configuration['token_endpoint'], - params=payload, - headers=headers) - - if response.status_code == 200: - id_token = response.json().get('id_token') - return id_token - else: - retry_message = 'Failed to retrieve ID Token, ' + \ - f'retry' + \ - 'the authentication process' - raise Http404(_(retry_message)) - - def verify_and_decode_id_token( - self, id_token: str, - cached_nonce: bool = False, - openid_provider: str = ''): - """ - Verifies that the ID Token passed was signed and sent by the Open ID - Connect Provider and that the client is one of the audiences then - decodes the token and returns the decoded information - """ - unverified_header = jwt.get_unverified_header(id_token) - - # Get public key thumbprint - kid = unverified_header.get('kid') - jwk = self._retrieve_jwk_related_to_kid(kid) - - if jwk: - alg = unverified_header.get('alg') - public_key = RSAAlgorithm.from_jwk(json.dumps(jwk)) - - try: - decoded_token = jwt.decode( - id_token, - public_key, - audience=[self.client_id], - algorithms=alg) - - if cached_nonce: - # Verify that the cached nonce is present and that - # the provider the nonce was initiated for, is the same - # provider returning it - provider_initiated_for = cache.get( - decoded_token.get(NONCE)) - - if provider_initiated_for != openid_provider: - raise Exception('Incorrect nonce value returned') - return decoded_token - except Exception as e: - raise e - - def end_openid_provider_session(self): - """ - Clears the SSO cookie set at authentication and redirects the User - to the end_session endpoint provided by the provider configuration - """ - end_session_endpoint = self.provider_configuration.get( - 'end_session_endpoint') - target_url_after_logout = self.provider_configuration.get( - 'target_url_after_logout') - - response = HttpResponseRedirect( - end_session_endpoint + - '?post_logout_redirect_uri=' + target_url_after_logout) - response.delete_cookie('SSO') - - return response diff --git a/onadata/settings/common.py b/onadata/settings/common.py index e9f94cacb1..f38ba1ab7b 100644 --- a/onadata/settings/common.py +++ b/onadata/settings/common.py @@ -212,6 +212,7 @@ 'actstream', 'onadata.apps.messaging.apps.MessagingConfig', 'django_filters', + 'oidc', ) OAUTH2_PROVIDER = { @@ -223,6 +224,34 @@ 'OAUTH2_VALIDATOR_CLASS': 'onadata.libs.authentication.MasterReplicaOAuth2Validator' # noqa } +OPENID_CONNECT_VIEWSET_CONFIG = { + "REDIRECT_AFTER_AUTH": "http://localhost:3000", + "USE_SSO_COOKIE": True, + "SSO_COOKIE_DATA": "email", + "JWT_SECRET_KEY": 'thesecretkey', + "JWT_ALGORITHM": 'HS256', + "SSO_COOKIE_MAX_AGE": None, + "SSO_COOKIE_DOMAIN": "localhost", + "USE_AUTH_BACKEND": False, + "AUTH_BACKEND": "", # Defaults to django.contrib.auth.backends.ModelBackend + "USE_RAPIDPRO_VIEWSET": False, +} + +OPENID_CONNECT_AUTH_SERVERS = { + "microsoft": { + "AUTHORIZATION_ENDPOINT": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "CLIENT_ID": "client_id", + "JWKS_ENDPOINT": "https://login.microsoftonline.com/common/discovery/v2.0/keys", + "SCOPE": "openid profile", + "TOKEN_ENDPOINT": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "END_SESSION_ENDPOINT": "http://localhost:3000", + "REDIRECT_URI": "http://localhost:8000/oidc/msft/callback", + "RESPONSE_TYPE": "id_token", + "RESPONSE_MODE": "form_post", + "USE_NONCES": True + } +} + REST_FRAMEWORK = { # Use hyperlinked styles by default. # Only used if the `serializer_class` attribute is not set on a view. diff --git a/requirements/base.in b/requirements/base.in index e31b529e59..60f35eeea9 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -8,3 +8,4 @@ -e git+https://github.com/onaio/floip-py.git@3c980eb184069ae7c3c9136b18441978237cd41d#egg=pyfloip -e git+https://github.com/onaio/python-json2xlsclient.git@62b4645f7b4f2684421a13ce98da0331a9dd66a0#egg=python-json2xlsclient -e git+https://github.com/onaio/oauth2client.git@75dfdee77fb640ae30469145c66440571dfeae5c#egg=oauth2client +-e git+https://github.com/onaio/ona-oidc.git#egg=ona-oidc diff --git a/requirements/base.pip b/requirements/base.pip index 82b9addab5..b0c1f938ba 100644 --- a/requirements/base.pip +++ b/requirements/base.pip @@ -10,6 +10,10 @@ # via -r requirements/base.in -e git+https://github.com/onaio/oauth2client.git@75dfdee77fb640ae30469145c66440571dfeae5c#egg=oauth2client # via -r requirements/base.in +-e git+https://github.com/onaio/ona-oidc.git#egg=ona-oidc + # via -r requirements/base.in +-e file:///home/winny/Documents/ona/onadata_stuff/onadata + # via -r requirements/base.in -e git+https://github.com/onaio/floip-py.git@3c980eb184069ae7c3c9136b18441978237cd41d#egg=pyfloip # via -r requirements/base.in -e git+https://github.com/onaio/python-digest.git@3af1bd0ef6114e24bf23d0e8fd9d7ebf389845d1#egg=python-digest @@ -72,6 +76,7 @@ cryptography==3.4.7 # via # jwcrypto # onadata + # pyjwt datapackage==1.15.2 # via pyfloip defusedxml==0.7.1 @@ -125,6 +130,7 @@ django==2.2.23 # djangorestframework-guardian # djangorestframework-jsonapi # jsonfield + # ona-oidc # onadata djangorestframework-csv==2.1.1 # via onadata @@ -144,6 +150,7 @@ djangorestframework==3.12.4 # djangorestframework-gis # djangorestframework-guardian # djangorestframework-jsonapi + # ona-oidc # onadata docutils==0.17.1 # via sphinx @@ -268,8 +275,10 @@ pyflakes==2.3.1 # via flake8 pygments==2.9.0 # via sphinx -pyjwt==2.1.0 - # via onadata +pyjwt[crypto]==2.1.0 + # via + # ona-oidc + # onadata pylibmc==1.6.1 # via onadata pymongo==3.11.4 @@ -313,6 +322,7 @@ requests==2.25.1 # datapackage # django-oauth-toolkit # httmock + # ona-oidc # onadata # python-json2xlsclient # requests-mock