diff --git a/.travis.yml b/.travis.yml index f735052f..9b22d84f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,21 @@ sudo: false +dist: xenial language: python python: - - 2.7 + - 3.5 env: - - TOXENV=django18 - TOXENV=django111 + - TOXENV=django20 + - TOXENV=django21 + - TOXENV=django22 + - TOXENV=quality matrix: include: - - python: 3.6 - env: TOXENV=quality - - python: 3.6 + - python: 2.7 env: TOXENV=django111 cache: @@ -37,5 +39,5 @@ deploy: distributions: sdist bdist_wheel on: tags: true - python: 3.6 + python: 3.5 condition: '$TOXENV = django111' diff --git a/HISTORY.rst b/HISTORY.rst index a3d24a39..025c0ac2 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,88 @@ History ======= +3.0.0 (2020-02-05) +--------- + +- Add support for Django 2 and drop support for some older versions (support changes from 1.8–1.11 to 1.11–2.2) +- Remove (deprecated) OpenID Connect support +- Test with Python 3.5, not 3.6, to match rest of edX code + +2.0.2 (2019-08-12) +------------------ + +Two new commits that changed functionality: + +- Add EdXOAuth2.auth_complete_signal on auth_complete() +- Store refresh_token in extra_data + +2.0.1 (2019-05-13) +------------------ + +Create new Version for auth-backends for release + +2.0.0 (2019-03-28) +------------------ + +EdXOAuth2 will retrieve and store user_id claim + +The EdXOAuth2 backend will now pull the user_id from the JWT and +store it in the UserSocialAuth.extra_data field. + +BREAKING CHANGE: The user_id scope is now required when using the +EdXOAuth2 backend for oAuth+SSO. This means that the oauth +application must first be configured to have access to the user_id +scope, which is not available by default. + +1.2.2 (2019-01-31) +------------------ + +Updates to the EdXOAuth2 backend: + +- Supports the _PUBLIC_URL_ROOT social django setting. +- logout_url() allows _LOGOUT_REDIRECT_URL to be undefined. + +1.2.1 (2018-10-26) +------------------ + +Fix urlencode bug with EdXOAuth2 backend logout url + +1.2.0 (2018-10-26) +------------------ + +Allow for logout redirect with EdXOAuth2 backend. + +1.1.5 (2018-10-19) +------------------ + +Add logout_url property to EdXOAuth2 backend. + +1.1.4 (2018-10-12) +------------------ + +Remove token validation from EdXOAuth2 backend. + +1.1.3 (2018-01-04) +------------------ + +Added support to update email address. + +social_core consider email field protected and won't let it change. +Added a pipeline function to update email address. + +1.1.2 (2017-05-12) +------------------ + +Updated LoginRedirectBaseView to include querystring + +[unlisted] +---------- + +Intervening releases not documented here; see Releases: + +https://github.com/edx/auth-backends/releases?after=1.1.2 + + 0.1.3 (2015-03-31) ------------------ diff --git a/README.rst b/README.rst index daf44b11..8b864eb6 100644 --- a/README.rst +++ b/README.rst @@ -8,16 +8,9 @@ auth-backends |Travis|_ |Codecov|_ This package contains custom authentication backends, views, and pipeline steps used by edX services for single sign-on. -This package is compatible with Python 2.7 and 3.5, and Django 1.8 through 1.11. +This package is compatible with Python 2.7 and 3.5, and Django 1.11 through 2.2. -We currently support two forms of authentication: - -- OAuth 2.0 -- OpenID Connect (deprecated) - -Support for OpenID Connect (OIDC) is deprecated. Clients should use the OAuth 2.0 backend. This backend behaves -similarly to the OIDC backend, except we use a JWT as the access token instead of OIDC's ID token. This allows us to use -any OAuth provider, and not rely on an implementation of an OIDC provider. +We currently support OAuth 2.0 authentication. Support for OpenID Connect (OIDC) was removed as of version 3.0. Use version 2.x if you require OIDC and are not able to migrate to OAuth2. Installation ------------ @@ -53,7 +46,11 @@ OAuth 2.0 Settings +----------------------------------------------------------+-------------------------------------------------------------------------------------------+ | SOCIAL_AUTH_EDX_OAUTH2_SECRET | Client secret | +----------------------------------------------------------+-------------------------------------------------------------------------------------------+ -| SOCIAL_AUTH_EDX_OAUTH2_ENDPOINT | Provider root (e.g. https://courses.stage.edx.org/oauth2) | +| SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT | LMS root, reachable from the application server | +| | (e.g. https://courses.stage.edx.org or http://edx.devstack.lms:18000) | ++----------------------------------------------------------+-------------------------------------------------------------------------------------------+ +| SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT | LMS root, reachable from the end user's browser | +| | (e.g. https://courses.stage.edx.org or http://localhost:18000) | +----------------------------------------------------------+-------------------------------------------------------------------------------------------+ | SOCIAL_AUTH_EDX_OAUTH2_JWS_HMAC_SIGNING_KEY | (Optional) Shared secret for JWT signed with HS512 algorithm | +----------------------------------------------------------+-------------------------------------------------------------------------------------------+ @@ -62,53 +59,10 @@ OAuth 2.0 Settings | SOCIAL_AUTH_EDX_OAUTH2_JWKS_CACHE_TTL | (Optional) Cache timeout for provider's JWKS key data. Defaults to 1 day. | +----------------------------------------------------------+-------------------------------------------------------------------------------------------+ -Note that the OAuth 2.0 provider uses ``SOCIAL_AUTH_EDX_OAUTH2_ENDPOINT`` to read configuration from a special path, -``.well-known/openid-configuration`` (e.g. https://courses.stage.edx.org/oauth2/.well-known/openid-configuration). The -data returned from this endpoint provides the URLs necessary for authentication as well as the public keys used to -verify the signed JWT (JWS) access token. - -As of auth-backends 2.0.0, oAuth2 Applications require access to the ``user_id`` scope in order for the ``EdXOAuth2`` backend to work. The backend will write the ``user_id`` into the social-auth extra_data, and can be accessed within the User model as follows:: +OAuth2 Applications require access to the ``user_id`` scope in order for the ``EdXOAuth2`` backend to work. The backend will write the ``user_id`` into the social-auth extra_data, and can be accessed within the User model as follows:: self.social_auth.first().extra_data[u'user_id'] # pylint: disable=no-member - -OIDC Settings (deprecated) -~~~~~~~~~~~~~~~~~~~~~~~~~~ -The following settings MUST be set: - -+----------------------------------------------+---------------------------------------------------------------------------------------------+ -| Setting | Purpose | -+==============================================+=============================================================================================+ -| SOCIAL_AUTH_EDX_OIDC_KEY | OAuth/OpenID Connect client key | -+----------------------------------------------+---------------------------------------------------------------------------------------------+ -| SOCIAL_AUTH_EDX_OIDC_SECRET | OAuth/OpenID Connect client secret | -+----------------------------------------------+---------------------------------------------------------------------------------------------+ -| SOCIAL_AUTH_EDX_OIDC_ID_TOKEN_DECRYPTION_KEY | Identity token decryption key (same value as the client secret for edX OpenID Connect) | -+----------------------------------------------+---------------------------------------------------------------------------------------------+ -| SOCIAL_AUTH_EDX_OIDC_URL_ROOT | OAuth/OpenID Connect provider root (e.g. https://courses.stage.edx.org/oauth2) | -+----------------------------------------------+---------------------------------------------------------------------------------------------+ -| SOCIAL_AUTH_EDX_OIDC_ISSUER | OAuth/OpenID Connect provider ID token issuer (e.g. https://courses.stage.edx.org/oauth2) | -+----------------------------------------------+---------------------------------------------------------------------------------------------+ -| SOCIAL_AUTH_EDX_OIDC_LOGOUT_URL | OAuth/OpenID Connect provider's logout page URL (e.g. https://courses.stage.edx.org/logout) | -+----------------------------------------------+---------------------------------------------------------------------------------------------+ - -If your application requires additional user data in the identity token, you can specify additional claims by defining -the ``EXTRA_SCOPE`` setting. For example, if you wish to have a claim named `preferred_language`, you would include -the following in your settings: - -.. code-block:: python - - EXTRA_SCOPE = ['preferred_language'] - -Assuming the identity provider knows how to process that scope, the associated claim data will be included in the -identity token returned during authentication. Note that these scopes/claims are dependent on the identity provider -being used. The ``EdXOpenIdConnect`` backend is configured to be used by all edX services out-of-the-box. - -The optional setting ``COURSE_PERMISSIONS_CLAIMS``, used primarily by -`edx-analytics-dashboard `_, can be used to designate scopes/claims that -should be requested in order to retrieve a list of courses the user is permitted to access/administer. The value of this -array depends on the authentication provider's available scopes. - Strategy ~~~~~~~~ We use a custom `strategy `_ that includes many of @@ -129,7 +83,7 @@ below is sufficient for all edX services. .. code-block:: python AUTHENTICATION_BACKENDS = ( - 'auth_backends.backends.EdXOpenIdConnect', + 'auth_backends.backends.EdXOAuth2', 'django.contrib.auth.backends.ModelBackend', ) @@ -139,39 +93,22 @@ In order to make use of the authentication backend, your service's login/logout view should be updated to redirect to the authentication provider's login page. The logout view should be updated to redirect to the authentication provider's logout page. -This package includes views and urlpatterns configured for OIDC and OAuth 2.0. To use them, simply append/prepend -either ``auth_urlpatterns`` or ``oauth2_urlpatterns`` to your service's urlpatterns in `urls.py`. +This package includes views and urlpatterns configured for OAuth 2.0. To use them, simply append/prepend +``oauth2_urlpatterns`` to your service's urlpatterns in `urls.py`. .. code-block:: python - from auth_backends.urls import auth_urlpatterns + from auth_backends.urls import oauth2_urlpatterns - urlpatterns = auth_urlpatterns + [ + urlpatterns = oauth2_urlpatterns + [ url(r'^admin/', include(admin.site.urls)), ... ] It is recommended that you not modify the login view. If, however, you need to modify the logout view (to redirect to -a different URL, for example), you can subclass either ``EdxOAuth2LogoutView`` or ``EdxOpenIdConnectLogoutView`` for +a different URL, for example), you can subclass ``EdxOAuth2LogoutView`` for the view and ``LogoutViewTestMixin`` for your tests. -Devstack --------- -When using the Docker-based devstack, it is necessary to have both internal and public URLs for the OAuth/OIDC -provider. To accommodate this need, set the ``SOCIAL_AUTH_EDX_OIDC_PUBLIC_URL_ROOT`` setting to the value of the -provider's browser-accessible URL. - -.. code-block:: python - - SOCIAL_AUTH_EDX_OIDC_URL_ROOT = 'http://edx.devstack.edxapp:18000/oauth2' - SOCIAL_AUTH_EDX_OIDC_PUBLIC_URL_ROOT = 'http://localhost:18000/oauth2' - -Additionally, the logout URL should also be browser-accessible: - -.. code-block:: python - - SOCIAL_AUTH_EDX_OIDC_LOGOUT_URL = 'http://localhost:18000/logout' - Testing ------- diff --git a/auth_backends/__init__.py b/auth_backends/__init__.py index 72786b9e..0dae2f0b 100644 --- a/auth_backends/__init__.py +++ b/auth_backends/__init__.py @@ -3,4 +3,4 @@ These package is designed to be used primarily with Open edX Django projects, but should be compatible with non-edX projects as well. """ -__version__ = '2.0.2' # pragma: no cover +__version__ = '3.0.0' # pragma: no cover diff --git a/auth_backends/backends.py b/auth_backends/backends.py index e76b11c4..4cf31f16 100644 --- a/auth_backends/backends.py +++ b/auth_backends/backends.py @@ -2,19 +2,9 @@ For more information visit https://docs.djangoproject.com/en/dev/topics/auth/customizing/. """ -import datetime -import json -import warnings -from calendar import timegm - import jwt -import six -from django.conf import settings from django.dispatch import Signal -from jwkest.jwk import KEYS from social_core.backends.oauth import BaseOAuth2 -from social_core.backends.open_id_connect import OpenIdConnectAuth -from social_core.exceptions import AuthTokenError PROFILE_CLAIMS_TO_DETAILS_KEY_MAP = { 'preferred_username': 'username', @@ -23,211 +13,10 @@ 'given_name': 'first_name', 'family_name': 'last_name', 'locale': 'language', + 'user_id': 'user_id', } -def _merge_two_dicts(x, y): - """ - Given two dicts, merge them into a new dict as a shallow copy. - - Once Python 3.6+ only is supported, replace method with ``z = {**x, **y}`` - """ - z = x.copy() - z.update(y) - return z - - -# pylint: disable=abstract-method,missing-docstring -class EdXBackendMixin(object): - # used by social-auth - ACCESS_TOKEN_METHOD = 'POST' - DEFAULT_SCOPE = ['profile', 'email'] - ID_KEY = 'preferred_username' - - # local only (not part of social-auth) - CLAIMS_TO_DETAILS_KEY_MAP = PROFILE_CLAIMS_TO_DETAILS_KEY_MAP - - def get_user_details(self, response): - details = self._map_user_details(response) - - # Limits the scope of languages we can use - locale = response.get('locale') - if locale: - details['language'] = _to_language(response['locale']) - - details['is_staff'] = response.get('administrator', False) - details['is_superuser'] = response.get('superuser', False) - - return details - - def get_public_or_internal_url_root(self): - return self.setting('PUBLIC_URL_ROOT') or self.setting('URL_ROOT') - - def _map_user_details(self, response): - """Maps key/values from the response to key/values in the user model. - - Does not transfer any key/value that is empty or not present in the response. - """ - dest = {} - for source_key, dest_key in self.CLAIMS_TO_DETAILS_KEY_MAP.items(): - value = response.get(source_key) - if value is not None: - dest[dest_key] = value - - return dest - - -class EdXOpenIdConnect(EdXBackendMixin, OpenIdConnectAuth): - """ DEPRECATED: OpenID Connect backend designed for use with the Open edX auth provider. """ - name = 'edx-oidc' - - ACCESS_TOKEN_METHOD = 'POST' - REDIRECT_STATE = False - ID_KEY = 'preferred_username' - - # Store the token type to ensure that we use the correct authentication mechanism for future calls. - EXTRA_DATA = OpenIdConnectAuth.EXTRA_DATA + ['token_type'] - - DEFAULT_SCOPE = ['openid', 'profile', 'email'] + getattr(settings, 'EXTRA_SCOPE', []) - - CLAIMS_TO_DETAILS_KEY_MAP = _merge_two_dicts(PROFILE_CLAIMS_TO_DETAILS_KEY_MAP, { - 'user_tracking_id': 'user_tracking_id', - }) - - auth_complete_signal = Signal(providing_args=['user', 'id_token']) - - def __init__(self, *args, **kwargs): - super(EdXOpenIdConnect, self).__init__(*args, **kwargs) - warnings.warn('EdXOpenIdConnect is deprecated. Please use EdXOAuth2.', DeprecationWarning) - - def get_jwks_keys(self): - """ Returns the keys used to decode the ID token. - - Note: - edX uses symmetric keys, so bypass the parent class's calls to an external - server and return the key from settings. - """ - keys = KEYS() - keys.add({'key': self.setting('ID_TOKEN_DECRYPTION_KEY'), 'kty': 'oct'}) - return keys - - @property - def ID_TOKEN_ISSUER(self): # pylint: disable=invalid-name - """ Expected value of the `iss` claim in the ID token. """ - return self.setting('ISSUER') - - @property - def OIDC_ENDPOINT(self): # pylint: disable=invalid-name - """ OpenID Connect discovery endpoint. """ - return self.get_public_or_internal_url_root() - - @property - def AUTHORIZATION_URL(self): # pylint: disable=invalid-name - """ URL of the auth provider's authorization endpoint. """ - url_root = self.get_public_or_internal_url_root() - return '{}/authorize/'.format(url_root) - - @property - def ACCESS_TOKEN_URL(self): # pylint: disable=invalid-name - """ URL of the auth provider's access token endpoint. """ - return '{}/access_token/'.format(self.setting('URL_ROOT')) - - @property - def USER_INFO_URL(self): # pylint: disable=invalid-name - """ URL of the auth provider's user info endpoint. """ - return '{}/user_info/'.format(self.setting('URL_ROOT')) - - @property - def logout_url(self): - """ URL of the auth provider's logout page. """ - return self.setting('LOGOUT_URL') - - def user_data(self, _access_token, *_args, **_kwargs): - # Include decoded id_token fields in user data. - return self.id_token - - def auth_complete_params(self, state=None): - params = super(EdXOpenIdConnect, self).auth_complete_params(state) - - # TODO: Due a limitation in the OIDC provider in the LMS, the list of all course permissions - # is computed during the authentication process. As an optimization, we explicitly request - # the list here, avoiding further roundtrips. This is no longer necessary once the limitation - # is resolved and instead the course permissions can be requested on a need to have basis, - # reducing overhead significantly. - claim_names = getattr(settings, 'COURSE_PERMISSIONS_CLAIMS', []) - courses_claims_request = {name: {'essential': True} for name in claim_names} - params['claims'] = json.dumps({'id_token': courses_claims_request}) - - return params - - def auth_complete(self, *args, **kwargs): - # WARNING: During testing, the user model class is `social_core.tests.models.User`, - # not the model specified for the application. - user = super(EdXOpenIdConnect, self).auth_complete(*args, **kwargs) - self.auth_complete_signal.send(sender=self.__class__, user=user, id_token=self.id_token) - return user - - def get_user_claims(self, access_token, claims=None, token_type='Bearer'): - """Returns a dictionary with the values for each claim requested.""" - data = self.get_json( - self.USER_INFO_URL, - headers={'Authorization': '{token_type} {token}'.format(token_type=token_type, token=access_token)} - ) - - if claims: - claims_names = set(claims) - data = {k: v for (k, v) in six.iteritems(data) if k in claims_names} - - return data - - # NOTE (CCB): We are TEMPORARILY disabling the nonce validation while we transition our - # authentication provider to properly implement storing the nonce at the point of initial - # authorization, rather than when we request the access token. - def auth_params(self, state=None): - return super(OpenIdConnectAuth, self).auth_params(state) # pylint: disable=bad-super-call - - def validate_claims(self, id_token): - if id_token['iss'] != self.id_token_issuer(): - raise AuthTokenError(self, 'Invalid issuer') - - client_id, __ = self.get_key_and_secret() - - if isinstance(id_token['aud'], six.string_types): - id_token['aud'] = [id_token['aud']] - - if client_id not in id_token['aud']: - raise AuthTokenError(self, 'Invalid audience') - - if len(id_token['aud']) > 1 and 'azp' not in id_token: - raise AuthTokenError(self, 'Incorrect id_token: azp') - - if 'azp' in id_token and id_token['azp'] != client_id: - raise AuthTokenError(self, 'Incorrect id_token: azp') - - utc_timestamp = timegm(datetime.datetime.utcnow().utctimetuple()) - if utc_timestamp > id_token['exp']: - raise AuthTokenError(self, 'Signature has expired') - - if 'nbf' in id_token and utc_timestamp < id_token['nbf']: - raise AuthTokenError(self, 'Incorrect id_token: nbf') - - # Verify the token was issued in the last 10 minutes - iat_leeway = self.setting('ID_TOKEN_MAX_AGE', self.ID_TOKEN_MAX_AGE) - if utc_timestamp > id_token['iat'] + iat_leeway: - raise AuthTokenError(self, 'Incorrect id_token: iat') - - # Validate the nonce to ensure the request was not modified - # nonce = id_token.get('nonce') - # if not nonce: - # raise AuthTokenError(self, 'Incorrect id_token: nonce') - # - # nonce_obj = self.get_nonce(nonce) - # if nonce_obj: - # self.remove_nonce(nonce_obj.id) - # else: - # raise AuthTokenError(self, 'Incorrect id_token: nonce') - - def _to_language(locale): """Convert locale name to language code if necessary. @@ -242,12 +31,15 @@ def _to_language(locale): return locale.replace('_', '-').lower() -class EdXOAuth2(EdXBackendMixin, BaseOAuth2): +# pylint: disable=abstract-method +class EdXOAuth2(BaseOAuth2): """ IMPORTANT: The oauth2 application must have access to the ``user_id`` scope in order to use this backend. """ # used by social-auth + ACCESS_TOKEN_METHOD = 'POST' + ID_KEY = 'preferred_username' name = 'edx-oauth2' @@ -263,9 +55,7 @@ class EdXOAuth2(EdXBackendMixin, BaseOAuth2): ] # local only (not part of social-auth) - CLAIMS_TO_DETAILS_KEY_MAP = _merge_two_dicts(PROFILE_CLAIMS_TO_DETAILS_KEY_MAP, { - 'user_id': 'user_id', - }) + CLAIMS_TO_DETAILS_KEY_MAP = PROFILE_CLAIMS_TO_DETAILS_KEY_MAP # This signal is fired after the user has successfully logged in. auth_complete_signal = Signal(providing_args=['user']) @@ -314,3 +104,32 @@ def user_data(self, access_token, *args, **kwargs): keys = list(self.CLAIMS_TO_DETAILS_KEY_MAP.keys()) + ['administrator', 'superuser'] user_data = {key: decoded_access_token[key] for key in keys if key in decoded_access_token} return user_data + + def get_user_details(self, response): + details = self._map_user_details(response) + + # Limits the scope of languages we can use + locale = response.get('locale') + if locale: + details['language'] = _to_language(response['locale']) + + details['is_staff'] = response.get('administrator', False) + details['is_superuser'] = response.get('superuser', False) + + return details + + def get_public_or_internal_url_root(self): + return self.setting('PUBLIC_URL_ROOT') or self.setting('URL_ROOT') + + def _map_user_details(self, response): + """Maps key/values from the response to key/values in the user model. + + Does not transfer any key/value that is empty or not present in the response. + """ + dest = {} + for source_key, dest_key in self.CLAIMS_TO_DETAILS_KEY_MAP.items(): + value = response.get(source_key) + if value is not None: + dest[dest_key] = value + + return dest diff --git a/auth_backends/tests/mixins.py b/auth_backends/tests/mixins.py index 58462e46..61887388 100644 --- a/auth_backends/tests/mixins.py +++ b/auth_backends/tests/mixins.py @@ -1,7 +1,7 @@ """ Test mixins. """ from django.contrib.auth import get_user, get_user_model -from django.core.urlresolvers import reverse +from django.urls import reverse from social_django.models import UserSocialAuth PASSWORD = 'test' @@ -14,7 +14,7 @@ class LogoutViewTestMixin(object): def create_user(self): """ Create a new user. """ user = User.objects.create_user('test', password=PASSWORD) - UserSocialAuth.objects.create(user=user, provider='edx-oidc', uid=user.username) + UserSocialAuth.objects.create(user=user, provider='edx-oauth2', uid=user.username) return user def get_logout_url(self): @@ -28,7 +28,7 @@ def get_redirect_url(self): def assert_authentication_status(self, is_authenticated): """ Verifies the authentication status of the user attached to the test client. """ user = get_user(self.client) - self.assertEqual(user.is_authenticated(), is_authenticated) + self.assertEqual(user.is_authenticated, is_authenticated) def test_x_frame_options_header(self): """ Verify no X-Frame-Options header is set in the resposne. """ diff --git a/auth_backends/tests/test_backends.py b/auth_backends/tests/test_backends.py index 37f91a96..03dd594e 100644 --- a/auth_backends/tests/test_backends.py +++ b/auth_backends/tests/test_backends.py @@ -1,191 +1,17 @@ """ Tests for the backends. """ import datetime import json -import unittest from calendar import timegm -import ddt -import mock -import pytest import six from Cryptodome.PublicKey import RSA from django.core.cache import cache -from jwkest.jwk import SYMKey, RSAKey +from jwkest.jwk import RSAKey from jwkest.jws import JWS -from jwkest.jwt import b64encode_item from social_core.tests.backends.oauth import OAuth2Test -from social_core.tests.backends.open_id_connect import OpenIdConnectTestMixin -from auth_backends.backends import EdXOpenIdConnect -from auth_backends.strategies import EdxDjangoStrategy - -class BackendTestMixin(object): - def set_social_auth_setting(self, setting_name, value): - """ - Set a social auth django setting during the middle of a test. - """ - # The inherited backend defines self.name, such as "EDX_OIDC" or "EDX_OAUTH2". - backend_name = self.name - - # NOTE: We use the strategy's method, rather than override_settings, because the TestStrategy class being used - # does not rely on Django settings. - self.strategy.set_settings({'SOCIAL_AUTH_{}_{}'.format(backend_name, setting_name): value}) - - -@ddt.ddt -class EdXOpenIdConnectTests(BackendTestMixin, OpenIdConnectTestMixin, OAuth2Test): - """ Tests for the EdXOpenIdConnect backend. """ - - backend_path = 'auth_backends.backends.EdXOpenIdConnect' - url_root = 'http://www.example.com' - public_url_root = 'http://public.example.com' - logout_url = 'http://www.example.com/logout/' - issuer = url_root - expected_username = 'test_user' - fake_locale = 'en_US' - fake_data = { - 'a-claim': 'some-data', - 'another-claim': 'some-other-data' - } - fake_access_token = 'an-access-token' - - # NOTE (CCB): We don't use this, but it's required by OpenIdConnectTestMixin.setUp(). - openid_config_body = '{ "jwks_uri": "http://www.example.com" }' - - def setUp(self): - super(EdXOpenIdConnectTests, self).setUp() - self.key = SYMKey(key=self.client_secret) - - # NOTE (CCB): We are TEMPORARILY disabling the nonce validation while we transition our - # authentication provider to properly implement storing the nonce at the point of initial - # authorization, rather than when we request the access token. - def access_token_body(self, request, _url, headers): # pylint: disable=method-hidden - """ - Get the nonce from the request parameters, add it to the id_token, and - return the complete response. - """ - # nonce = self.backend.data['nonce'].encode('utf-8') - # body = self.prepare_access_token_body(nonce=nonce) - body = self.prepare_access_token_body() - return 200, headers, body - - @unittest.skip('Disabled until we release https://github.com/edx/edx-platform/pull/14966.') - def test_invalid_nonce(self): - self.authtoken_raised( - 'Token error: Incorrect id_token: nonce', - nonce='something-wrong' - ) - - def extra_settings(self): - """ Define additional Django settings. """ - settings = super(EdXOpenIdConnectTests, self).extra_settings() - settings.update({ - 'SOCIAL_AUTH_{0}_URL_ROOT'.format(self.name): self.url_root, - 'SOCIAL_AUTH_{0}_ISSUER'.format(self.name): self.issuer, - 'SOCIAL_AUTH_{0}_LOGOUT_URL'.format(self.name): self.logout_url, - }) - - # Use settings from our default strategy so that we can validate them - settings.update(EdxDjangoStrategy.DEFAULT_SETTINGS) - - return settings - - def get_id_token(self, client_key=None, expiration_datetime=None, issue_datetime=None, nonce=None, issuer=None): - data = super(EdXOpenIdConnectTests, self).get_id_token( - client_key, expiration_datetime, issue_datetime, nonce, issuer) - - # Set the field used to derive the username of the logged user. - data['preferred_username'] = self.expected_username - - # Exercise the locale name to language code path - data['locale'] = self.fake_locale - - return data - - def prepare_access_token_body(self, client_key=None, tamper_message=False, expiration_datetime=None, - issue_datetime=None, nonce=None, issuer=None): - """ - Prepares a provider access token response. - - Note: - We only override this method to force the JWS class to use the HS256 algorithm. - """ - - body = {'access_token': 'foobar', 'token_type': 'bearer'} - client_key = client_key or self.client_key - now = datetime.datetime.utcnow() - expiration_datetime = expiration_datetime or (now + datetime.timedelta(seconds=30)) - issue_datetime = issue_datetime or now - nonce = nonce or 'a-nonce' - issuer = issuer or self.issuer - id_token = self.get_id_token( - client_key, timegm(expiration_datetime.utctimetuple()), - timegm(issue_datetime.utctimetuple()), nonce, issuer) - - body['id_token'] = JWS(id_token, jwk=self.key, alg='HS256').sign_compact() - if tamper_message: - header, msg, sig = body['id_token'].split('.') - id_token['sub'] = '1235' - msg = b64encode_item(id_token).decode('utf-8') - body['id_token'] = '.'.join([header, msg, sig]) - - return json.dumps(body) - - @pytest.mark.django_db(transaction=False) - def test_login(self): - user = self.do_login() - self.assertIsNotNone(user) - - @ddt.data(None, 'Bearer', 'JWT') - def test_get_user_claims(self, token_type): - expected_token_type = token_type or 'Bearer' - with mock.patch('auth_backends.backends.EdXOpenIdConnect.get_json') as mock_get_json: - mock_get_json.return_value = self.fake_data - - claim = six.next(six.iteritems(self.fake_data)) - kwargs = { - 'claims': [claim[0]], - } - - if token_type: - kwargs['token_type'] = token_type - - actual = self.backend.get_user_claims(self.fake_access_token, **kwargs) - - # Verify the correct claim data is returned - self.assertDictEqual(actual, {claim[0]: claim[1]}) - - # Verify the call to the user info endpoint was made with the correct authorization headers - headers = { - 'Authorization': '{token_type} {token}'.format(token_type=expected_token_type, - token=self.fake_access_token) - } - mock_get_json.assert_called_once_with(self.backend.USER_INFO_URL, headers=headers) - - def test_logout_url(self): - """ Verify the property returns the configured logout URL. """ - self.assertEqual(self.backend.logout_url, self.logout_url) - - def test_authorization_url(self): - """ Verify the method utilizes the public URL, if one is set. """ - authorize_path = '/authorize/' - self.assertEqual(self.backend.AUTHORIZATION_URL, self.url_root + authorize_path) - - # Now, add the public url root to the settings. - self.set_social_auth_setting('PUBLIC_URL_ROOT', self.public_url_root) - self.assertEqual(self.backend.AUTHORIZATION_URL, self.public_url_root + authorize_path) - - def test_deprecated(self): - """ Attempts to instantiate EdXOpenIdConnect should fire a warning. """ - with mock.patch('warnings.warn') as mock_warn: - EdXOpenIdConnect(self.strategy, redirect_uri=self.complete_url) - mock_warn.assert_called_once_with( - 'EdXOpenIdConnect is deprecated. Please use EdXOAuth2.', DeprecationWarning - ) - - -class EdXOAuth2Tests(BackendTestMixin, OAuth2Test): +class EdXOAuth2Tests(OAuth2Test): """ Tests for the EdXOAuth2 backend. """ backend_path = 'auth_backends.backends.EdXOAuth2' @@ -201,6 +27,17 @@ def setUp(self): super(EdXOAuth2Tests, self).setUp() self.key = RSAKey(kid='testkey', key=RSA.generate(2048)) + def set_social_auth_setting(self, setting_name, value): + """ + Set a social auth django setting during the middle of a test. + """ + # The inherited backend defines self.name, i.e. "EDX_OAUTH2". + backend_name = self.name + + # NOTE: We use the strategy's method, rather than override_settings, because the TestStrategy class being used + # does not rely on Django settings. + self.strategy.set_settings({'SOCIAL_AUTH_{}_{}'.format(backend_name, setting_name): value}) + def access_token_body(self, request, _url, headers): """ Generates a response from the provider's access token endpoint. """ # The backend should always request JWT access tokens, not Bearer. diff --git a/auth_backends/tests/test_views.py b/auth_backends/tests/test_views.py index 0874e2c8..b83f9e09 100644 --- a/auth_backends/tests/test_views.py +++ b/auth_backends/tests/test_views.py @@ -1,31 +1,33 @@ """ Tests for the views module. """ -from django.core.urlresolvers import reverse +from django.urls import reverse from django.test import TestCase, override_settings from auth_backends.tests.mixins import LogoutViewTestMixin -from auth_backends.urls import auth_urlpatterns +from auth_backends.urls import oauth2_urlpatterns -LOGOUT_REDIRECT_URL = 'https://www.example.com/logout/' +URL_ROOT = 'https://www.example.com' +LOGOUT_REDIRECT_URL = URL_ROOT + '/logout' -urlpatterns = auth_urlpatterns +# Django magic to determine which URL patterns are in effect for the tests +urlpatterns = oauth2_urlpatterns @override_settings(ROOT_URLCONF=__name__) -class EdxOpenIdConnectLoginViewTests(TestCase): - """ Tests for EdxOpenIdConnectLoginView. """ +class EdxOAuth2LoginViewTests(TestCase): + """ Tests for EdxOAuth2ConnectLoginView. """ def test_redirect(self): - """ Verify the view redirects to the edX OIDC login page. """ + """ Verify the view redirects to the edX OAuth2 login page. """ qs = 'next=/test/' response = self.client.get('{url}?{qs}'.format(url=reverse('login'), qs=qs)) - expected = '{url}?{qs}'.format(url=reverse('social:begin', args=['edx-oidc']), qs=qs) + expected = '{url}?{qs}'.format(url=reverse('social:begin', args=['edx-oauth2']), qs=qs) self.assertRedirects(response, expected, fetch_redirect_response=False) -@override_settings(ROOT_URLCONF=__name__, SOCIAL_AUTH_EDX_OIDC_LOGOUT_URL=LOGOUT_REDIRECT_URL) -class EdxOpenIdConnectLogoutView(LogoutViewTestMixin, TestCase): - """ Tests for EdxOpenIdConnectLogoutView. """ +@override_settings(ROOT_URLCONF=__name__, URL_ROOT=URL_ROOT) +class EdxOAuth2LogoutView(LogoutViewTestMixin, TestCase): + """ Tests for EdxOAuth2ConnectLogoutView. """ def get_redirect_url(self): return LOGOUT_REDIRECT_URL diff --git a/auth_backends/urls.py b/auth_backends/urls.py index 7fb883cc..3ab9c896 100644 --- a/auth_backends/urls.py +++ b/auth_backends/urls.py @@ -5,15 +5,10 @@ from django.conf.urls import url, include from auth_backends.views import ( - EdxOpenIdConnectLoginView, EdxOpenIdConnectLogoutView, EdxOAuth2LoginView, EdxOAuth2LogoutView + EdxOAuth2LoginView, + EdxOAuth2LogoutView, ) -auth_urlpatterns = [ # pylint: disable=invalid-name - url(r'^login/$', EdxOpenIdConnectLoginView.as_view(), name='login'), - url(r'^logout/$', EdxOpenIdConnectLogoutView.as_view(), name='logout'), - url('', include('social_django.urls', namespace='social')), -] - oauth2_urlpatterns = [ # pylint: disable=invalid-name url(r'^login/$', EdxOAuth2LoginView.as_view(), name='login'), url(r'^logout/$', EdxOAuth2LogoutView.as_view(), name='logout'), diff --git a/auth_backends/views.py b/auth_backends/views.py index 54fb02a9..88efedbb 100644 --- a/auth_backends/views.py +++ b/auth_backends/views.py @@ -2,7 +2,7 @@ import logging from django.contrib.auth import logout -from django.core.urlresolvers import reverse +from django.urls import reverse from django.http import HttpResponse from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_exempt @@ -12,8 +12,8 @@ logger = logging.getLogger(__name__) -class LogoutRedirectBaseView(RedirectView): - """ Base logout view for projects with single sign out/logout. +class EdxOAuth2LogoutView(RedirectView): + """ Logout view for projects utilizing edX OAuth 2.0 for single sign-on. This is a base view. You MUST override `url` attribute or `get_redirect_url` method to make this view useful. See the documentation for `RedirectView` for more info: @@ -27,7 +27,7 @@ class LogoutRedirectBaseView(RedirectView): provider. Additionally, no X-Frame-Options header is set, allowing this page to be loaded in an iframe on the authorization server's logout page. This allows signout to be triggered by the authorization server. """ - auth_backend_name = None + auth_backend_name = 'edx-oauth2' permanent = False user = None @@ -40,7 +40,7 @@ def dispatch(self, request, *args, **kwargs): if request.GET.get('no_redirect'): return HttpResponse() - return super(LogoutRedirectBaseView, self).dispatch(request, *args, **kwargs) + return super(EdxOAuth2LogoutView, self).dispatch(request, *args, **kwargs) @property def url(self): @@ -51,9 +51,13 @@ def url(self): return backend.logout_url -class LoginRedirectBaseView(RedirectView): - """ Base view for backend logins. """ - auth_backend_name = None +class EdxOAuth2LoginView(RedirectView): + """ + Login view for projects utilizing edX OAuth 2.0 for single sign-on. + + Usage of this view requires `python-social-auth` to be installed and configured in `urls.py`. + """ + auth_backend_name = 'edx-oauth2' permanent = False query_string = True @@ -62,29 +66,3 @@ def url(self): # NOTE: We use a property here so that we can take advantage of the base class' # get_redirect_url() with minimal effort. return reverse('social:begin', args=[self.auth_backend_name]) - - -class EdxOpenIdConnectLoginView(LoginRedirectBaseView): - """ Login view for projects utilizing edX OpenID Connect for single sign-on. - - Usage of this view requires `python-social-auth` to be installed and configured in `urls.py`. - """ - auth_backend_name = 'edx-oidc' - - -class EdxOpenIdConnectLogoutView(LogoutRedirectBaseView): - """ Logout view for projects utilizing edX OpenID Connect for single sign-on. """ - auth_backend_name = 'edx-oidc' - - -class EdxOAuth2LoginView(LoginRedirectBaseView): - """ Login view for projects utilizing edX OAuth 2.0 for single sign-on. - - Usage of this view requires `python-social-auth` to be installed and configured in `urls.py`. - """ - auth_backend_name = 'edx-oauth2' - - -class EdxOAuth2LogoutView(LogoutRedirectBaseView): - """ Logout view for projects utilizing edX OAuth 2.0 for single sign-on. """ - auth_backend_name = 'edx-oauth2' diff --git a/setup.py b/setup.py index d33f23b7..2f0860e4 100644 --- a/setup.py +++ b/setup.py @@ -21,12 +21,11 @@ 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', 'Framework :: Django', - 'Framework :: Django :: 1.8', - 'Framework :: Django :: 1.9', - 'Framework :: Django :: 1.10', 'Framework :: Django :: 1.11', + 'Framework :: Django :: 2.0', + 'Framework :: Django :: 2.1', + 'Framework :: Django :: 2.2', 'Topic :: Internet', ], keywords='authentication edx', @@ -36,10 +35,10 @@ license='AGPL', packages=find_packages(), install_requires=[ - 'Django>=1.8,<2.0', + 'Django>=1.11,<2.3', 'pyjwt', 'six', - 'social-auth-core[openidconnect]>=1.3.0,<2.0.0', - 'social-auth-app-django>=1.2.0,<2.0.0', + 'social-auth-core>=3.1.0,<4.0.0', + 'social-auth-app-django>=3.1.0,<4.0.0', ], ) diff --git a/test_requirements.txt b/test_requirements.txt index d4294f40..fdf152d7 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1,11 +1,11 @@ coverage>=4.3.1,<5.0.0 -ddt>=1.1.1,<2.0.0 edx-lint>=0.5.4,<1.0.0 httpretty>=0.8.14,<1.0.0 -mock>=2.0.0,<3.0.0 pep8>=1.7.0,<2.0.0 pytest-catchlog>=1.2.2,<2.0.0 pytest-cov>=2.4.0,<3.0.0 pytest-django>=3.1.2,<4.0.0 tox>=2.3.2,<3.0.0 unittest2>=1.1.0,<2.0.0 +# pyjwkest is used for crypto tests +pyjwkest>=1.0.1 diff --git a/test_settings.py b/test_settings.py index f536e29f..b6335504 100644 --- a/test_settings.py +++ b/test_settings.py @@ -26,13 +26,12 @@ } } -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -43,6 +42,6 @@ COURSE_PERMISSIONS_CLAIMS = [] AUTHENTICATION_BACKENDS = ( - 'auth_backends.backends.EdXOpenIdConnect', + 'auth_backends.backends.EdXOAuth2', 'django.contrib.auth.backends.ModelBackend', ) diff --git a/tox.ini b/tox.ini index 6030a3cc..1e70acc1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27-django{18,111}, py36-django{111}, quality +envlist = py27-django{111}, py35-django{111,20,21,22}, quality [testenv] setenv = @@ -7,8 +7,10 @@ setenv = PYTHONPATH = {toxinidir} deps = - django18: Django>=1.8,<1.9 django111: Django>=1.11,<2.0 + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + django22: Django>=2.2,<2.3 -rtest_requirements.txt commands =