Login service support for JWT Cookies

This commit is contained in:
Nimisha Asthagiri
2018-09-20 15:00:09 -04:00
parent 0a88746aef
commit 02ba5fb0e8
27 changed files with 884 additions and 331 deletions

View File

@@ -19,8 +19,8 @@ from course_modes.models import CourseMode
from lms.djangoapps.certificates.apis.v0.views import CertificatesDetailView
from lms.djangoapps.certificates.models import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from openedx.core.djangoapps.oauth_dispatch.jwt import _create_jwt
from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES
from openedx.core.lib.token_utils import JwtBuilder
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -131,12 +131,11 @@ class CertificatesRestApiTest(SharedModuleStoreTestCase, APITestCase):
if scopes is None:
scopes = CertificatesDetailView.required_scopes
return JwtBuilder(user).build_token(
scopes,
additional_claims=dict(
is_restricted=(auth_type == AuthType.jwt_restricted),
filters=filters,
),
return _create_jwt(
user,
scopes=scopes,
is_restricted=(auth_type == AuthType.jwt_restricted),
filters=filters,
)
def _get_response(self, requesting_user, auth_type, url=None, token=None):

View File

@@ -517,6 +517,10 @@ OAUTH2_PROVIDER = {
# otherwise it fails saying this attribute is not present in Settings
OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'
# Automatically clean up edx-django-oauth2-provider tokens on use
OAUTH_DELETE_EXPIRED = True
OAUTH_ID_TOKEN_EXPIRATION = 60 * 60
################################## TEMPLATE CONFIGURATION #####################################
# Mako templating
import tempfile
@@ -2933,10 +2937,6 @@ DEFAULT_MOBILE_AVAILABLE = True
# Enrollment API Cache Timeout
ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT = 60
# Automatically clean up edx-django-oauth2-provider tokens on use
OAUTH_DELETE_EXPIRED = True
OAUTH_ID_TOKEN_EXPIRATION = 60 * 60
# These tabs are currently disabled
NOTES_DISABLED_TABS = ['course_structure', 'tags']
@@ -3161,8 +3161,12 @@ JWT_AUTH = {
'JWT_LEEWAY': 1,
'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.utils.jwt_decode_handler',
# Number of seconds before JWT tokens expire
# Number of seconds before JWTs expire
'JWT_EXPIRATION': 30,
'JWT_COOKIE_EXPIRATION': 60 * 60,
'JWT_LOGIN_CLIENT_ID': 'login-service-client-id',
'JWT_SUPPORTED_VERSION': '1.1.0',
'JWT_ALGORITHM': 'HS256',

View File

@@ -580,14 +580,10 @@ VIDEO_TRANSCRIPTS_SETTINGS = dict(
JWT_AUTH.update({
'JWT_PUBLIC_SIGNING_JWK_SET': (
'{"keys": [{"kid": "TEST_KEY", "e": "AQAB", "kty": "RSA", "n": "smKFSYowG6nNUAdeqH1jQQnH1PmIHphzBmwJ5vRf1vu'
'48BUI5VcVtUWIPqzRK_LDSlZYh9D0YFL0ZTxIrlb6Tn3Xz7pYvpIAeYuQv3_H5p8tbz7Fb8r63c1828wXPITVTv8f7oxx5W3lFFgpFAyYMmROC'
'4Ee9qG5T38LFe8_oAuFCEntimWxN9F3P-FJQy43TL7wG54WodgiM0EgzkeLr5K6cDnyckWjTuZbWI-4ffcTgTZsL_Kq1owa_J2ngEfxMCObnzG'
'y5ZLcTUomo4rZLjghVpq6KZxfS6I1Vz79ZsMVUWEdXOYePCKKsrQG20ogQEkmTf9FT_SouC6jPcHLXw"}, {"kid": "BTZ9HA6K", "e": "A'
'QAB", "kty": "RSA", "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQ'
'n6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3K'
'EUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_'
'qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"}]}'
'{"keys": [{"kid": "BTZ9HA6K", "e": "AQAB", "kty": "RSA", "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6'
'sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc'
'4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEu'
'lLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"}]}'
),
'JWT_PRIVATE_SIGNING_JWK': (
'{"e": "AQAB", "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_Xv'
@@ -599,9 +595,10 @@ JWT_AUTH.update({
'q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", "q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Lo'
'z3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy'
'1KB6fCby0C9WE", "p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfH'
'ayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", "kid": "TEST_KEY", "kty"'
'ayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", "kid": "BTZ9HA6K", "kty"'
': "RSA"}'
),
'JWT_LOGIN_CLIENT_ID': 'test-login-service-client-id',
})
####################### Plugin Settings ##########################

View File

@@ -16,7 +16,6 @@ from django.contrib.auth import login
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from edx_oauth2_provider.constants import SCOPE_VALUE_DICT
from oauth2_provider import models as dot_models
from oauth2_provider.settings import oauth2_settings
from oauth2_provider.views.base import TokenView as DOTAccessTokenView
@@ -30,6 +29,7 @@ from rest_framework.views import APIView
from openedx.core.djangoapps.auth_exchange.forms import AccessTokenExchangeForm
from openedx.core.djangoapps.oauth_dispatch import adapters
from openedx.core.djangoapps.oauth_dispatch.api import create_dot_access_token
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
@@ -111,13 +111,7 @@ class DOTAccessTokenExchangeView(AccessTokenExchangeBase, DOTAccessTokenView):
"""
Create and return a new access token.
"""
_days = 24 * 60 * 60
token_generator = BearerToken(
expires_in=settings.OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS * _days,
request_validator=oauth2_settings.OAUTH2_VALIDATOR_CLASS(),
)
self._populate_create_access_token_request(request, user, scope, client)
return token_generator.create_token(request, refresh_token=True)
return create_dot_access_token(request, user, client, scope=scope)
def access_token_response(self, token):
"""
@@ -125,20 +119,6 @@ class DOTAccessTokenExchangeView(AccessTokenExchangeBase, DOTAccessTokenView):
"""
return Response(data=token)
def _populate_create_access_token_request(self, request, user, scope, client):
"""
django-oauth-toolkit expects certain non-standard attributes to
be present on the request object. This function modifies the
request object to match these expectations
"""
request.user = user
request.scopes = [SCOPE_VALUE_DICT[scope]]
request.client = client
request.state = None
request.refresh_token = None
request.extra_credentials = None
request.grant_type = client.authorization_grant_type
def error_response(self, form_errors, **kwargs):
"""
Return an error response consisting of the errors in the form

View File

@@ -57,6 +57,18 @@ class DOPAdapter(object):
"""
return models.AccessToken.objects.get(token=token_string)
def create_access_token_for_test(self, token_string, client, user, expires):
"""
Returns a new AccessToken object created from the given arguments.
This method is currently used only by tests.
"""
return models.AccessToken.objects.create(
token=token_string,
client=client,
user=user,
expires=expires,
)
def normalize_scopes(self, scopes):
"""
Given a list of scopes, return a space-separated list of those scopes.
@@ -69,13 +81,13 @@ class DOPAdapter(object):
"""
return scope.to_names(token.scope)
def is_client_restricted(self, client_id): # pylint: disable=unused-argument
def is_client_restricted(self, client): # pylint: disable=unused-argument
"""
Returns true if the client is set up as a RestrictedApplication.
"""
return False
def get_authorization_filters(self, client_id): # pylint: disable=unused-argument
def get_authorization_filters(self, client): # pylint: disable=unused-argument
"""
Get the authorization filters for the given client application.
"""

View File

@@ -67,6 +67,18 @@ class DOTAdapter(object):
"""
return models.AccessToken.objects.get(token=token_string)
def create_access_token_for_test(self, token_string, client, user, expires):
"""
Returns a new AccessToken object created from the given arguments.
This method is currently used only by tests.
"""
return models.AccessToken.objects.create(
token=token_string,
application=client,
user=user,
expires=expires,
)
def normalize_scopes(self, scopes):
"""
Given a list of scopes, return a space-separated list of those scopes.
@@ -81,18 +93,17 @@ class DOTAdapter(object):
"""
return list(token.scopes)
def is_client_restricted(self, client_id):
def is_client_restricted(self, client):
"""
Returns true if the client is set up as a RestrictedApplication.
"""
application = self.get_client(client_id=client_id)
return RestrictedApplication.objects.filter(application=application).exists()
return RestrictedApplication.objects.filter(application=client).exists()
def get_authorization_filters(self, client_id):
def get_authorization_filters(self, client):
"""
Get the authorization filters for the given client application.
"""
application = self.get_client(client_id=client_id)
application = client
filters = [org_relation.to_jwt_filter_claim() for org_relation in application.organizations.all()]
# Allow applications configured with the client credentials grant type to access

View File

@@ -1,6 +1,14 @@
""" OAuth related Python apis. """
import json
from django.conf import settings
from edx_oauth2_provider.constants import SCOPE_VALUE_DICT
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
from oauthlib.oauth2.rfc6749.tokens import BearerToken
from oauth2_provider.models import AccessToken as dot_access_token
from oauth2_provider.models import RefreshToken as dot_refresh_token
from oauth2_provider.oauth2_backends import get_oauthlib_core
from oauth2_provider.settings import oauth2_settings as dot_settings
from provider.oauth2.models import AccessToken as dop_access_token
from provider.oauth2.models import RefreshToken as dop_refresh_token
@@ -13,3 +21,87 @@ def destroy_oauth_tokens(user):
dop_refresh_token.objects.filter(user=user.id).delete()
dot_access_token.objects.filter(user=user.id).delete()
dot_refresh_token.objects.filter(user=user.id).delete()
def create_dot_access_token(request, user, client, expires_in=None, scope=None):
"""
Create and return a new (persisted) access token, including a refresh token.
The token is returned in the form of a Dict:
{
u'access_token': u'some string',
u'refresh_token': u'another string',
u'token_type': u'Bearer',
u'expires_in': 36000,
u'scope': u'default',
},
"""
# TODO (ARCH-204) the 'scope' argument may not really be needed by callers.
expires_in = _get_expires_in_value(expires_in)
token_generator = BearerToken(
expires_in=expires_in,
request_validator=dot_settings.OAUTH2_VALIDATOR_CLASS(),
)
_populate_create_access_token_request(request, user, client, scope)
return token_generator.create_token(request, refresh_token=True)
def refresh_dot_access_token(request, client_id, refresh_token, expires_in=None):
"""
Create and return a new (persisted) access token, given a previously created
refresh_token, possibly returned from create_dot_access_token above.
"""
auth_core = get_oauthlib_core()
expires_in = _get_expires_in_value(expires_in)
_populate_refresh_token_request(request, client_id, refresh_token)
# Note: Unlike create_dot_access_token, we use the top-level auth library
# code for creating the token since we want to enforce registered validations
# (valid refresh token, valid client, etc), rather than create the token
# ourselves directly.
_, _, body, status = auth_core.create_token_response(request) # returns uri, headers, body, status
if status != 200:
raise OAuth2Error(body)
return json.loads(body)
def _get_expires_in_value(expires_in):
"""
Returns the expires_in value to use for the token.
"""
# TODO (ARCH-246) Fix expiration configuration as this does not actually
# override the token's expiration. Rather, DOT's save_bearer_token method
# will always use dot_settings.ACCESS_TOKEN_EXPIRE_SECONDS.
if not expires_in:
seconds_in_a_day = 24 * 60 * 60
expires_in = settings.OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS * seconds_in_a_day
return expires_in
def _populate_create_access_token_request(request, user, client, scope=None):
"""
django-oauth-toolkit expects certain non-standard attributes to
be present on the request object. This function modifies the
request object to match these expectations
"""
if scope is None:
scope = 0
request.user = user
request.scopes = [SCOPE_VALUE_DICT[scope]]
request.client = client
request.state = None
request.refresh_token = None
request.extra_credentials = None
request.grant_type = client.authorization_grant_type
def _populate_refresh_token_request(request, client_id, refresh_token):
"""
django-oauth-toolkit expects parameters passed through the request's POST.
"""
request.POST = dict(
client_id=client_id,
refresh_token=refresh_token,
grant_type='refresh_token',
)

View File

@@ -0,0 +1,204 @@
"""Utilities for working with ID tokens."""
import json
from time import time
from django.conf import settings
from jwkest import jwk
from jwkest.jws import JWS
from edx_django_utils.monitoring import set_custom_metric
from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES
from student.models import UserProfile, anonymous_id_for_user
def create_jwt_for_user(user, secret=None, aud=None, additional_claims=None):
"""
Returns a JWT to identify the given user.
TODO (ARCH-204) Note the returned JWT does not have an underlying access
token associated with it and so cannot be invalidated nor refreshed. This
interface should be revisited when addressing authentication-related cleanup
as part of ARCH-204.
Arguments:
user (User): User for which to generate the JWT.
Deprecated Arguments (to be removed):
secret (string): Overrides configured JWT secret (signing) key.
aud (string): Optional. Overrides configured JWT audience claim.
additional_claims (dict): Optional. Additional claims to include in the token.
"""
expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
return _create_jwt(
user,
expires_in=expires_in,
aud=aud,
additional_claims=additional_claims,
secret=secret,
use_asymmetric_key=False,
)
def create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=None):
"""
Returns a JWT created from the given access token.
Arguments:
token_dict (dict): An access token structure as returned from an
underlying OAuth provider.
Deprecated Arguments (to be removed):
oauth_adapter (DOPAdapter|DOTAdapter): An OAuth adapter that will
provide the given token's information.
use_asymmetric_key (Boolean): Optional. Whether the JWT should be signed
with this app's private key. If not provided, defaults to whether
ENFORCE_JWT_SCOPES is enabled and the OAuth client is restricted.
"""
access_token = oauth_adapter.get_access_token(token_dict['access_token'])
client = oauth_adapter.get_client_for_token(access_token)
# TODO (ARCH-204) put access_token as a JWT ID claim (jti)
return _create_jwt(
access_token.user,
scopes=token_dict['scope'].split(' '),
expires_in=token_dict['expires_in'],
use_asymmetric_key=use_asymmetric_key,
is_restricted=oauth_adapter.is_client_restricted(client),
filters=oauth_adapter.get_authorization_filters(client),
)
def _create_jwt(
user,
scopes=None,
expires_in=None,
is_restricted=False,
filters=None,
aud=None,
additional_claims=None,
use_asymmetric_key=None,
secret=None,
):
"""
Returns an encoded JWT (string).
Arguments:
user (User): User for which to generate the JWT.
scopes (list): Optional. Scopes that limit access to the token bearer and
controls which optional claims are included in the token.
Defaults to ['email', 'profile'].
expires_in (int): Optional. Overrides time to token expiry, specified in seconds.
filters (list): Optional. Filters to include in the JWT.
is_restricted (Boolean): Whether the client to whom the JWT is issued is restricted.
Deprecated Arguments (to be removed):
aud (string): Optional. Overrides configured JWT audience claim.
additional_claims (dict): Optional. Additional claims to include in the token.
use_asymmetric_key (Boolean): Optional. Whether the JWT should be signed
with this app's private key. If not provided, defaults to whether
ENFORCE_JWT_SCOPES is enabled and the OAuth client is restricted.
secret (string): Overrides configured JWT secret (signing) key.
"""
use_asymmetric_key = _get_use_asymmetric_key_value(is_restricted, use_asymmetric_key)
scopes = scopes or ['email', 'profile']
iat, exp = _compute_time_fields(expires_in)
payload = {
# TODO (ARCH-204) Consider getting rid of the 'aud' claim since we don't use it.
'aud': aud if aud else settings.JWT_AUTH['JWT_AUDIENCE'],
'exp': exp,
'iat': iat,
'iss': settings.JWT_AUTH['JWT_ISSUER'],
'preferred_username': user.username,
'scopes': scopes,
'version': settings.JWT_AUTH['JWT_SUPPORTED_VERSION'],
'sub': anonymous_id_for_user(user, None),
'filters': filters or [],
'is_restricted': is_restricted,
}
payload.update(additional_claims or {})
_update_from_additional_handlers(payload, user, scopes)
return _encode_and_sign(payload, use_asymmetric_key, secret)
def _get_use_asymmetric_key_value(is_restricted, use_asymmetric_key):
"""
Returns the value to use for use_asymmetric_key.
"""
# TODO: (ARCH-162)
# If JWT scope enforcement is enabled, we need to sign tokens
# given to restricted applications with a key that
# other IDAs do not have access to. This prevents restricted
# applications from getting access to API endpoints available
# on other IDAs which have not yet been protected with the
# scope-related DRF permission classes. Once all endpoints have
# been protected, we can enable all IDAs to use the same new
# (asymmetric) key.
if use_asymmetric_key is None:
use_asymmetric_key = ENFORCE_JWT_SCOPES.is_enabled() and is_restricted
return use_asymmetric_key
def _compute_time_fields(expires_in):
"""
Returns (iat, exp) tuple to be used as time-related values in a token.
"""
now = int(time())
expires_in = expires_in or settings.JWT_AUTH['JWT_EXPIRATION']
set_custom_metric('jwt_expires_in', expires_in)
return now, now + expires_in
def _update_from_additional_handlers(payload, user, scopes):
"""
Updates the given token payload with data from additional handlers, as
requested by the given scopes.
"""
_claim_handlers = {
'email': _attach_email_claim,
'profile': _attach_profile_claim
}
for scope in scopes:
handler = _claim_handlers.get(scope)
if handler:
handler(payload, user)
def _attach_email_claim(payload, user):
"""Add the email claim details to the JWT payload."""
payload['email'] = user.email
def _attach_profile_claim(payload, user):
"""Add the profile claim details to the JWT payload."""
try:
# Some users (e.g., service users) may not have user profiles.
name = UserProfile.objects.get(user=user).name
except UserProfile.DoesNotExist:
name = None
payload.update({
'name': name,
'family_name': user.last_name,
'given_name': user.first_name,
'administrator': user.is_staff,
})
def _encode_and_sign(payload, use_asymmetric_key, secret):
"""Encode and sign the provided payload."""
set_custom_metric('jwt_is_asymmetric', use_asymmetric_key)
keys = jwk.KEYS()
if use_asymmetric_key:
serialized_keypair = json.loads(settings.JWT_AUTH['JWT_PRIVATE_SIGNING_JWK'])
keys.add(serialized_keypair)
algorithm = settings.JWT_AUTH['JWT_SIGNING_ALGORITHM']
else:
key = secret if secret else settings.JWT_AUTH['JWT_SECRET_KEY']
keys.add({'key': key, 'kty': 'oct'})
algorithm = settings.JWT_AUTH['JWT_ALGORITHM']
data = json.dumps(payload)
jws = JWS(data, alg=algorithm)
return jws.sign_compact(keys=keys)

View File

@@ -15,24 +15,16 @@ class AccessTokenMixin(object):
""" Mixin for tests dealing with OAuth 2 access tokens. """
def assert_valid_jwt_access_token(self, access_token, user, scopes=None, should_be_expired=False, filters=None,
should_be_asymmetric_key=False, should_be_restricted=None):
should_be_asymmetric_key=False, should_be_restricted=None, aud=None, secret=None):
"""
Verify the specified JWT access token is valid, and belongs to the specified user.
Args:
access_token (str): JWT
user (User): User whose information is contained in the JWT payload.
(optional) should_be_expired: indicates if the passed in JWT token is expected to be expired
(optional) should_be_asymmetric_key: indicates if the JWT token should be signed with an
asymmetric key.
Returns:
dict: Decoded JWT payload
"""
scopes = scopes or []
audience = settings.JWT_AUTH['JWT_AUDIENCE']
audience = aud or settings.JWT_AUTH['JWT_AUDIENCE']
secret_key = secret or settings.JWT_AUTH['JWT_SECRET_KEY']
issuer = settings.JWT_AUTH['JWT_ISSUER']
secret_key = settings.JWT_AUTH['JWT_SECRET_KEY']
def _decode_jwt(verify_expiration):
"""

View File

@@ -0,0 +1,103 @@
""" Tests for OAuth Dispatch python API module. """
import unittest
from django.conf import settings
from django.http import HttpRequest
from django.test import TestCase
from oauth2_provider.models import AccessToken
from student.tests.factories import UserFactory
OAUTH_PROVIDER_ENABLED = settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER')
if OAUTH_PROVIDER_ENABLED:
from openedx.core.djangoapps.oauth_dispatch import api
from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter
from openedx.core.djangoapps.oauth_dispatch.tests.constants import DUMMY_REDIRECT_URL
EXPECTED_DEFAULT_EXPIRES_IN = 36000
@unittest.skipUnless(OAUTH_PROVIDER_ENABLED, 'OAuth2 not enabled')
class TestOAuthDispatchAPI(TestCase):
""" Tests for oauth_dispatch's api.py module. """
def setUp(self):
super(TestOAuthDispatchAPI, self).setUp()
self.adapter = DOTAdapter()
self.user = UserFactory()
self.client = self.adapter.create_public_client(
name='public app',
user=self.user,
redirect_uri=DUMMY_REDIRECT_URL,
client_id='public-client-id',
)
self.request = HttpRequest()
def _assert_stored_token(self, stored_token_value, expected_token_user, expected_client):
stored_access_token = AccessToken.objects.get(token=stored_token_value)
self.assertEqual(stored_access_token.user.id, expected_token_user.id)
self.assertEqual(stored_access_token.application.client_id, expected_client.client_id)
self.assertEqual(stored_access_token.application.user.id, expected_client.user.id)
def test_create_token_success(self):
token = api.create_dot_access_token(self.request, self.user, self.client)
self.assertTrue(token['access_token'])
self.assertTrue(token['refresh_token'])
self.assertDictContainsSubset(
{
u'token_type': u'Bearer',
u'expires_in': EXPECTED_DEFAULT_EXPIRES_IN,
u'scope': u'default',
},
token,
)
self._assert_stored_token(token['access_token'], self.user, self.client)
def test_create_token_another_user(self):
another_user = UserFactory()
token = api.create_dot_access_token(self.request, another_user, self.client)
self._assert_stored_token(token['access_token'], another_user, self.client)
def test_create_token_overrides(self):
expires_in = 4800
token = api.create_dot_access_token(self.request, self.user, self.client, expires_in=expires_in, scope=2)
self.assertDictContainsSubset({u'scope': u'profile'}, token)
with self.assertRaises(AssertionError): # TODO (ARCH-246) expiration override does not actually work
self.assertDictContainsSubset({u'expires_in': expires_in}, token)
self.assertDictContainsSubset({u'expires_in': EXPECTED_DEFAULT_EXPIRES_IN}, token)
def test_refresh_token_success(self):
old_token = api.create_dot_access_token(self.request, self.user, self.client)
new_token = api.refresh_dot_access_token(self.request, self.client.client_id, old_token['refresh_token'])
self.assertDictContainsSubset(
{
u'token_type': u'Bearer',
u'expires_in': EXPECTED_DEFAULT_EXPIRES_IN,
u'scope': u'default',
},
new_token,
)
# verify new tokens are generated
self.assertNotEqual(old_token['access_token'], new_token['access_token'])
self.assertNotEqual(old_token['refresh_token'], new_token['refresh_token'])
# verify old token is replaced by the new token
with self.assertRaises(AccessToken.DoesNotExist):
self._assert_stored_token(old_token['access_token'], self.user, self.client)
self._assert_stored_token(new_token['access_token'], self.user, self.client)
def test_refresh_token_invalid_client(self):
token = api.create_dot_access_token(self.request, self.user, self.client)
with self.assertRaises(api.OAuth2Error) as error:
api.refresh_dot_access_token(
self.request, 'invalid_client_id', token['refresh_token'],
)
self.assertIn('invalid_client', error.exception.description)
def test_refresh_token_invalid_token(self):
api.create_dot_access_token(self.request, self.user, self.client)
with self.assertRaises(api.OAuth2Error) as error:
api.refresh_dot_access_token(
self.request, self.client.client_id, 'invalid_refresh_token',
)
self.assertIn('invalid_grant', error.exception.description)

View File

@@ -68,8 +68,8 @@ class DOPAdapterTestCase(TestCase):
self.assertEqual(self.adapter.get_client_for_token(token), self.public_client)
def test_get_access_token(self):
token = models.AccessToken.objects.create(
token='token-id',
token = self.adapter.create_access_token_for_test(
'token-id',
client=self.public_client,
user=self.user,
expires=now() + timedelta(days=30),

View File

@@ -93,9 +93,9 @@ class DOTAdapterTestCase(TestCase):
self.assertEqual(self.adapter.get_client_for_token(token), self.public_client)
def test_get_access_token(self):
token = models.AccessToken.objects.create(
token='token-id',
application=self.public_client,
token = self.adapter.create_access_token_for_test(
'token-id',
client=self.public_client,
user=self.user,
expires=now() + timedelta(days=30),
)
@@ -106,9 +106,9 @@ class DOTAdapterTestCase(TestCase):
Make sure when generating an access_token for a restricted client
that the token is immediately expired
"""
models.AccessToken.objects.create(
token='expired-token-id',
application=self.restricted_client,
self.adapter.create_access_token_for_test(
'expired-token-id',
client=self.restricted_client,
user=self.user,
expires=now() + timedelta(days=30),
)

View File

@@ -0,0 +1,93 @@
""" Tests for OAuth Dispatch's jwt module. """
import itertools
from datetime import timedelta
import ddt
from django.test import TestCase
from django.utils.timezone import now
from openedx.core.djangoapps.oauth_dispatch import jwt as jwt_api
from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter, DOPAdapter
from openedx.core.djangoapps.oauth_dispatch.models import RestrictedApplication
from openedx.core.djangoapps.oauth_dispatch.tests.mixins import AccessTokenMixin
from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES
from student.tests.factories import UserFactory
@ddt.ddt
class TestCreateJWTs(AccessTokenMixin, TestCase):
""" Tests for oauth_dispatch's jwt creation functionality. """
def setUp(self):
super(TestCreateJWTs, self).setUp()
self.user = UserFactory()
self.default_scopes = ['email', 'profile']
def _create_client(self, oauth_adapter, client_restricted):
"""
Creates and returns an OAuth client using the given oauth_adapter.
Configures the client as a RestrictedApplication if client_restricted is
True.
"""
client = oauth_adapter.create_public_client(
name='public app',
user=self.user,
redirect_uri='',
client_id='public-client-id',
)
if client_restricted:
RestrictedApplication.objects.create(application=client)
return client
def _create_jwt_for_token(
self, oauth_adapter, use_asymmetric_key, client_restricted=False,
):
""" Creates and returns the jwt returned by jwt_api.create_jwt_from_token. """
client = self._create_client(oauth_adapter, client_restricted)
expires_in = 60 * 60
expires = now() + timedelta(seconds=expires_in)
token_dict = dict(
access_token=oauth_adapter.create_access_token_for_test('token', client, self.user, expires),
expires_in=expires_in,
scope=' '.join(self.default_scopes)
)
return jwt_api.create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=use_asymmetric_key)
def _assert_jwt_is_valid(self, jwt_token, should_be_asymmetric_key):
""" Asserts the given jwt_token is valid and meets expectations. """
self.assert_valid_jwt_access_token(
jwt_token, self.user, self.default_scopes, should_be_asymmetric_key=should_be_asymmetric_key,
)
@ddt.data(DOPAdapter, DOPAdapter)
def test_create_jwt_for_token(self, oauth_adapter_cls):
oauth_adapter = oauth_adapter_cls()
jwt_token = self._create_jwt_for_token(oauth_adapter, use_asymmetric_key=False)
self._assert_jwt_is_valid(jwt_token, should_be_asymmetric_key=False)
def test_dot_create_jwt_for_token_with_asymmetric(self):
jwt_token = self._create_jwt_for_token(DOTAdapter(), use_asymmetric_key=True)
self._assert_jwt_is_valid(jwt_token, should_be_asymmetric_key=True)
@ddt.data(*itertools.product(
(True, False),
(True, False),
))
@ddt.unpack
def test_dot_create_jwt_for_token(self, scopes_enforced, client_restricted):
with ENFORCE_JWT_SCOPES.override(scopes_enforced):
jwt_token = self._create_jwt_for_token(
DOTAdapter(),
use_asymmetric_key=None,
client_restricted=client_restricted,
)
self._assert_jwt_is_valid(jwt_token, should_be_asymmetric_key=scopes_enforced and client_restricted)
def test_create_jwt_for_user(self):
aud = 'test_aud'
secret = 'test_secret'
additional_claims = {'claim1_key': 'claim1_val'}
jwt_token = jwt_api.create_jwt_for_user(self.user, secret=secret, aud=aud, additional_claims=additional_claims)
token_payload = self.assert_valid_jwt_access_token(
jwt_token, self.user, self.default_scopes, aud=aud, secret=secret,
)
self.assertDictContainsSubset(additional_claims, token_payload)

View File

@@ -271,30 +271,21 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
(i.e. expiry set to Jan 1, 1970)
"""
with ENFORCE_JWT_SCOPES.override(enforce_jwt_scopes_enabled):
response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
public_jwk_set, private_jwk = self._generate_key_pair()
jwt_auth_settings = settings.JWT_AUTH
jwt_auth_settings.update({
'JWT_PRIVATE_SIGNING_JWK': private_jwk,
'JWT_PUBLIC_SIGNING_JWK_SET': public_jwk_set,
})
with override_settings(JWT_AUTH=jwt_auth_settings):
response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertIn('expires_in', data)
self.assertEqual(data['expires_in'] < 0, expiration_expected)
self.assertEqual(data['token_type'], 'JWT')
self.assert_valid_jwt_access_token(
data['access_token'],
self.user,
data['scope'].split(' '),
should_be_expired=expiration_expected,
should_be_asymmetric_key=enforce_jwt_scopes_enabled,
should_be_restricted=True,
)
self.assertIn('expires_in', data)
self.assertEqual(data['expires_in'] < 0, expiration_expected)
self.assertEqual(data['token_type'], 'JWT')
self.assert_valid_jwt_access_token(
data['access_token'],
self.user,
data['scope'].split(' '),
should_be_expired=expiration_expected,
should_be_asymmetric_key=enforce_jwt_scopes_enabled,
should_be_restricted=True,
)
def test_restricted_access_token(self):
"""
@@ -349,7 +340,7 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
organization=OrganizationFactory()
)
scopes = dot_app_access.scopes
filters = self.dot_adapter.get_authorization_filters(dot_app.client_id)
filters = self.dot_adapter.get_authorization_filters(dot_app)
response = self._post_request(self.user, dot_app, token_type='jwt', scope=scopes)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)

View File

@@ -17,11 +17,9 @@ from ratelimit import ALL
from ratelimit.mixins import RatelimitMixin
from openedx.core.djangoapps.auth_exchange import views as auth_exchange_views
from openedx.core.lib.token_utils import JwtBuilder
from . import adapters
from .dot_overrides import views as dot_overrides_views
from .toggles import ENFORCE_JWT_SCOPES
from openedx.core.djangoapps.oauth_dispatch import adapters
from openedx.core.djangoapps.oauth_dispatch.dot_overrides import views as dot_overrides_views
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_from_token
class _DispatchingView(View):
@@ -112,54 +110,13 @@ class AccessTokenView(RatelimitMixin, _DispatchingView):
def _build_jwt_response_from_access_token_response(self, request, response):
""" Builds the content of the response, including the JWT token. """
client_id = self._get_client_id(request)
adapter = self.get_adapter(request)
is_client_restricted = adapter.is_client_restricted(client_id)
expires_in, scope, user = self._parse_access_token_response(adapter, response)
jwt_builder = self._get_jwt_builder(user, is_client_restricted)
content = {
'access_token': jwt_builder.build_token(
scope.split(' '),
expires_in,
additional_claims={
'filters': adapter.get_authorization_filters(client_id),
'is_restricted': is_client_restricted,
},
),
'expires_in': expires_in,
'scope': scope,
token_dict = json.loads(response.content)
jwt = create_jwt_from_token(token_dict, self.get_adapter(request))
token_dict.update({
'access_token': jwt,
'token_type': 'JWT',
}
return json.dumps(content)
def _parse_access_token_response(self, adapter, response):
""" Parses the expires_in, scope, and user values of the response. """
content = json.loads(response.content)
access_token = content['access_token']
expires_in = content['expires_in']
scope = content['scope']
user = adapter.get_access_token(access_token).user
return expires_in, scope, user
def _get_jwt_builder(self, user, is_client_restricted):
""" Creates and returns a JWTBuilder object for creating JWTs. """
# If JWT scope enforcement is enabled, we need to sign tokens
# given to restricted applications with a key that
# other IDAs do not have access to. This prevents restricted
# applications from getting access to API endpoints available
# on other IDAs which have not yet been protected with the
# scope-related DRF permission classes. Once all endpoints have
# been protected, we can enable all IDAs to use the same new
# (asymmetric) key.
# TODO: ARCH-162
use_asymmetric_key = ENFORCE_JWT_SCOPES.is_enabled() and is_client_restricted
return JwtBuilder(
user,
asymmetric=use_asymmetric_key,
)
})
return json.dumps(token_dict)
class AuthorizationView(_DispatchingView):

View File

@@ -4,21 +4,31 @@ Utility functions for setting "logged in" cookies used by subdomains.
from __future__ import unicode_literals
import json
import logging
import time
import six
from django.conf import settings
from django.contrib.auth.models import User
from django.urls import NoReverseMatch, reverse
from django.dispatch import Signal
from django.urls import NoReverseMatch, reverse
from django.utils.http import cookie_date
from edx_rest_framework_extensions.auth.jwt import cookies as jwt_cookies
from edx_rest_framework_extensions.auth.jwt.constants import JWT_DELIMITER
from oauth2_provider.models import Application
from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter
from openedx.core.djangoapps.oauth_dispatch.api import create_dot_access_token, refresh_dot_access_token
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_from_token
from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
from openedx.core.djangoapps.user_authn.waffle import JWT_COOKIES_FLAG
from student.models import CourseEnrollment
log = logging.getLogger(__name__)
CREATE_LOGON_COOKIE = Signal(providing_args=['user', 'response'])
@@ -48,14 +58,9 @@ ALL_LOGGED_IN_COOKIE_NAMES = JWT_COOKIE_NAMES + DEPRECATED_LOGGED_IN_COOKIE_NAME
def is_logged_in_cookie_set(request):
""" Check whether the request has logged in cookies set. """
if JWT_COOKIES_FLAG.is_enabled():
expected_cookie_names = ALL_LOGGED_IN_COOKIE_NAMES
else:
expected_cookie_names = DEPRECATED_LOGGED_IN_COOKIE_NAMES
return all(
cookie_name in request.COOKIES
for cookie_name in expected_cookie_names
return (
settings.EDXMKTG_LOGGED_IN_COOKIE_NAME in request.COOKIES and
request.COOKIES[settings.EDXMKTG_LOGGED_IN_COOKIE_NAME]
)
@@ -131,12 +136,26 @@ def set_logged_in_cookies(request, response, user):
_set_deprecated_logged_in_cookie(response, request)
_set_deprecated_user_info_cookie(response, request, user)
_set_jwt_cookies(response, request, user)
_create_and_set_jwt_cookies(response, request, user)
CREATE_LOGON_COOKIE.send(sender=None, user=user, response=response)
return response
def refresh_jwt_cookies(request, response):
"""
Resets the JWT related cookies in the response, while expecting a refresh
cookie in the request.
"""
if JWT_COOKIES_FLAG.is_enabled():
try:
refresh_token = request.COOKIES[jwt_cookies.jwt_refresh_cookie_name()]
except KeyError:
raise AuthFailedError(u"JWT Refresh Cookie not found in request.")
_create_and_set_jwt_cookies(response, request, refresh_token=refresh_token)
return response
def _set_deprecated_logged_in_cookie(response, request):
""" Sets the logged in cookie on the response. """
@@ -182,13 +201,6 @@ def _set_deprecated_user_info_cookie(response, request, user):
)
def _set_jwt_cookies(response, request, user): # pylint: disable=unused-argument
""" Sets a cookie containing a JWT on the response. """
if not JWT_COOKIES_FLAG.is_enabled():
return
# TODO (ARCH-236)
def _get_user_info_cookie_data(request, user):
""" Returns information that will populate the user info cookie. """
@@ -229,6 +241,94 @@ def _get_user_info_cookie_data(request, user):
return user_info
def _create_and_set_jwt_cookies(response, request, user=None, refresh_token=None):
""" Sets a cookie containing a JWT on the response. """
if not JWT_COOKIES_FLAG.is_enabled():
return
# TODO (ARCH-246) Need to fix configuration of token expiration settings.
cookie_settings = standard_cookie_settings(request)
_set_jwt_expiration(cookie_settings)
expires_in = cookie_settings['max_age']
oauth_application = _get_login_oauth_client()
if refresh_token:
access_token = refresh_dot_access_token(
request, oauth_application.client_id, refresh_token, expires_in=expires_in,
)
else:
access_token = create_dot_access_token(
request, user, oauth_application, expires_in=expires_in,
)
jwt = create_jwt_from_token(access_token, DOTAdapter(), use_asymmetric_key=True)
jwt_header_and_payload, jwt_signature = _parse_jwt(jwt)
_set_jwt_cookies(
response,
cookie_settings,
jwt_header_and_payload,
jwt_signature,
access_token['refresh_token'],
)
def _parse_jwt(jwt):
"""
Parses and returns the following parts of the jwt: header_and_payload, signature
"""
jwt_parts = jwt.split(JWT_DELIMITER)
header_and_payload = JWT_DELIMITER.join(jwt_parts[0:2])
signature = jwt_parts[2]
return header_and_payload, signature
def _set_jwt_cookies(response, cookie_settings, jwt_header_and_payload, jwt_signature, refresh_token):
"""
Sets the given jwt_header_and_payload, jwt_signature, and refresh token in 3 different cookies.
The latter 2 cookies are set as httponly.
"""
cookie_settings['httponly'] = None
response.set_cookie(
jwt_cookies.jwt_cookie_header_payload_name(),
jwt_header_and_payload,
**cookie_settings
)
cookie_settings['httponly'] = True
response.set_cookie(
jwt_cookies.jwt_cookie_signature_name(),
jwt_signature,
**cookie_settings
)
response.set_cookie(
jwt_cookies.jwt_refresh_cookie_name(),
refresh_token,
**cookie_settings
)
def _set_jwt_expiration(cookie_settings):
"""
Updates cookie_settings with the configured expiration values for JWT
Cookies.
"""
max_age = settings.JWT_AUTH['JWT_COOKIE_EXPIRATION']
cookie_settings['max_age'] = max_age
cookie_settings['expires'] = _cookie_expiration_based_on_max_age(max_age)
def _cookie_expiration_based_on_max_age(max_age):
expires_time = time.time() + max_age
return cookie_date(expires_time)
def _get_login_oauth_client():
"""
Returns the configured OAuth Client/Application used for Login.
"""
login_client_id = settings.JWT_AUTH['JWT_LOGIN_CLIENT_ID']
try:
return Application.objects.get(client_id=login_client_id)
except Application.DoesNotExist:
raise AuthFailedError(
u"OAuth Client for the Login service, '{}', is not configured.".format(login_client_id)
)

View File

@@ -1,30 +1,36 @@
# pylint: disable=missing-docstring
from __future__ import unicode_literals
from mock import MagicMock
import six
from django.conf import settings
from django.http import HttpResponse
from django.urls import reverse
from django.test import RequestFactory
from django.test import RequestFactory, TestCase
from openedx.core.djangoapps.user_authn.cookies import _get_user_info_cookie_data
from edx_rest_framework_extensions.auth.jwt.middleware import JwtAuthCookieMiddleware
from openedx.core.djangoapps.user_authn import cookies as cookies_api
from openedx.core.djangoapps.user_authn.tests.utils import setup_login_oauth_client
from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, AnonymousUserFactory
class CookieTests(SharedModuleStoreTestCase):
@classmethod
def setUpClass(cls):
super(CookieTests, cls).setUpClass()
cls.course = CourseFactory()
class CookieTests(TestCase):
def setUp(self):
super(CookieTests, self).setUp()
self.user = UserFactory.create()
self.request = RequestFactory().get('/')
self.request.user = self.user
self.request.session = self._get_stub_session()
def _get_expected_header_urls(self, request):
def _get_stub_session(self, expire_at_browser_close=False, max_age=604800):
return MagicMock(
get_expire_at_browser_close=lambda: expire_at_browser_close,
get_expiry_age=lambda: max_age,
)
def _get_expected_header_urls(self):
expected_header_urls = {
'logout': reverse('logout'),
'resume_block': retrieve_last_sitewide_block_completed(self.user.username)
@@ -39,21 +45,81 @@ class CookieTests(SharedModuleStoreTestCase):
# Convert relative URL paths to absolute URIs
for url_name, url_path in six.iteritems(expected_header_urls):
expected_header_urls[url_name] = request.build_absolute_uri(url_path)
expected_header_urls[url_name] = self.request.build_absolute_uri(url_path)
return expected_header_urls
def test_get_user_info_cookie_data(self):
request = RequestFactory().get('/')
request.user = self.user
def _copy_cookies_to_request(self, response, request):
request.COOKIES = {
key: val.value
for key, val in response.cookies.iteritems()
}
actual = _get_user_info_cookie_data(request, self.user)
def _assert_recreate_jwt_from_cookies(self, response, can_recreate):
"""
Verifies that a JWT can be properly recreated from the 2 separate
JWT-related cookies using the JwtAuthCookieMiddleware middleware.
"""
self.request.COOKIES = response.cookies
JwtAuthCookieMiddleware().process_request(self.request)
self.assertEqual(
cookies_api.jwt_cookies.jwt_cookie_name() in self.request.COOKIES,
can_recreate,
)
def _assert_cookies_present(self, response, expected_cookies):
self.assertSetEqual(set(response.cookies.keys()), set(expected_cookies))
def test_get_user_info_cookie_data(self):
actual = cookies_api._get_user_info_cookie_data(self.request, self.user) # pylint: disable=protected-access
expected = {
'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION,
'username': self.user.username,
'header_urls': self._get_expected_header_urls(request),
'header_urls': self._get_expected_header_urls(),
'enrollmentStatusHash': CourseEnrollment.generate_enrollment_status_hash(self.user)
}
self.assertDictEqual(actual, expected)
def test_set_logged_in_cookies_anonymous_user(self):
anonymous_user = AnonymousUserFactory()
response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), anonymous_user)
self._assert_cookies_present(response, [])
def test_set_logged_in_deprecated_cookies(self):
response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), self.user)
self._assert_cookies_present(response, cookies_api.DEPRECATED_LOGGED_IN_COOKIE_NAMES)
self._assert_recreate_jwt_from_cookies(response, can_recreate=False)
def test_set_logged_in_jwt_cookies(self):
setup_login_oauth_client()
with cookies_api.JWT_COOKIES_FLAG.override(True):
response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), self.user)
self._assert_cookies_present(response, cookies_api.ALL_LOGGED_IN_COOKIE_NAMES)
self._assert_recreate_jwt_from_cookies(response, can_recreate=True)
def test_delete_and_is_logged_in_cookie_set(self):
response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), self.user)
self._copy_cookies_to_request(response, self.request)
self.assertTrue(cookies_api.is_logged_in_cookie_set(self.request))
cookies_api.delete_logged_in_cookies(response)
self._copy_cookies_to_request(response, self.request)
self.assertFalse(cookies_api.is_logged_in_cookie_set(self.request))
def test_refresh_jwt_cookies(self):
def _get_refresh_token_value(response):
return response.cookies[cookies_api.jwt_cookies.jwt_refresh_cookie_name()].value
setup_login_oauth_client()
with cookies_api.JWT_COOKIES_FLAG.override(True):
response = cookies_api.set_logged_in_cookies(self.request, HttpResponse(), self.user)
self._copy_cookies_to_request(response, self.request)
new_response = cookies_api.refresh_jwt_cookies(self.request, HttpResponse())
self._assert_recreate_jwt_from_cookies(new_response, can_recreate=True)
self.assertNotEqual(
_get_refresh_token_value(response),
_get_refresh_token_value(new_response),
)

View File

@@ -0,0 +1,17 @@
""" Common utilities for tests in the user_authn app. """
from django.conf import settings
from openedx.core.djangoapps.oauth_dispatch.adapters.dot import DOTAdapter
from student.tests.factories import UserFactory
def setup_login_oauth_client():
"""
Sets up a test OAuth client for the login service.
"""
login_service_user = UserFactory.create()
DOTAdapter().create_public_client(
name='login-service',
user=login_service_user,
redirect_uri='',
client_id=settings.JWT_AUTH['JWT_LOGIN_CLIENT_ID'],
)

View File

@@ -17,6 +17,7 @@ urlpatterns = [
url(r'^login_post$', login.login_user, name='login_post'),
url(r'^login_ajax$', login.login_user, name="login"),
url(r'^login_ajax/(?P<error>[^/]*)$', login.login_user),
url(r'^login_refresh$', login.login_refresh, name="login_refresh"),
url(r'^logout$', logout.LogoutView.as_view(), name='logout'),
]

View File

@@ -20,7 +20,7 @@ from ratelimitbackend.exceptions import RateLimitException
from edxmako.shortcuts import render_to_response
from eventtracking import tracker
from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies
from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies, refresh_jwt_cookies
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
import openedx.core.djangoapps.external_auth.views
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
@@ -392,4 +392,16 @@ def login_user(request):
# detect that the user is logged in.
return set_logged_in_cookies(request, response, possibly_authenticated_user)
except AuthFailedError as error:
log.exception(error.get_response())
return JsonResponse(error.get_response())
@ensure_csrf_cookie
@require_http_methods(['POST'])
def login_refresh(request):
try:
response = JsonResponse({'success': True})
return refresh_jwt_cookies(request, response)
except AuthFailedError as error:
log.exception(error.get_response())
return JsonResponse(error.get_response(), status=400)

View File

@@ -16,6 +16,9 @@ from six import text_type
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, waffle
from openedx.core.djangoapps.user_authn.cookies import jwt_cookies
from openedx.core.djangoapps.user_authn.tests.utils import setup_login_oauth_client
from openedx.core.djangoapps.user_authn.waffle import JWT_COOKIES_FLAG
from openedx.core.djangoapps.password_policy.compliance import (
NonCompliantPasswordException,
NonCompliantPasswordWarning
@@ -284,6 +287,19 @@ class LoginTest(CacheIsolationTestCase):
response, _audit_log = self._login_response('test@edx.org', 'wrong_password')
self._assert_response(response, success=False, value='Too many failed login attempts')
def test_login_refresh(self):
def _assert_jwt_cookie_present(response):
self.assertEqual(response.status_code, 200)
self.assertIn(jwt_cookies.jwt_refresh_cookie_name(), self.client.cookies)
setup_login_oauth_client()
with JWT_COOKIES_FLAG.override(True):
response, _ = self._login_response('test@edx.org', 'test_password')
_assert_jwt_cookie_present(response)
response = self.client.post(reverse('login_refresh'))
_assert_jwt_cookie_present(response)
@patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True})
def test_single_session(self):
creds = {'email': 'test@edx.org', 'password': 'test_password'}

View File

@@ -40,7 +40,10 @@ def _finish_auth_url(params):
class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, SharedModuleStoreTestCase):
"""Test rendering of the login form. """
URLCONF_MODULES = ['openedx.core.djangoapps.user_authn.urls']
URLCONF_MODULES = [
'openedx.core.djangoapps.user_authn.urls',
'openedx.core.djangoapps.user_api.legacy_urls',
]
@classmethod
def setUpClass(cls):

View File

@@ -331,6 +331,14 @@ class WaffleFlag(object):
flag_undefined_default=self.flag_undefined_default
)
@contextmanager
def override(self, active=True):
# TODO We can move this import to the top of the file once this code is
# not all contained within the __init__ module.
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
with override_waffle_flag(self, active):
yield
class CourseWaffleFlag(WaffleFlag):
"""

View File

@@ -1,6 +1,5 @@
"""Tests covering JWT construction utilities."""
"""Tests covering the JwtBuilder utility."""
import ddt
import jwt
from django.test import TestCase
from openedx.core.djangoapps.oauth_dispatch.tests import mixins
@@ -9,32 +8,27 @@ from student.tests.factories import UserFactory, UserProfileFactory
@ddt.ddt
class TestJwtBuilder(mixins.AccessTokenMixin, TestCase):
class TestDeprecatedJwtBuilder(mixins.AccessTokenMixin, TestCase):
"""
Test class for JwtBuilder.
Test class for the deprecated JwtBuilder class.
"""
expires_in = 10
shard = 2
def setUp(self):
super(TestJwtBuilder, self).setUp()
super(TestDeprecatedJwtBuilder, self).setUp()
self.user = UserFactory()
self.profile = UserProfileFactory(user=self.user)
self.scopes = ['email', 'profile']
@ddt.data(
[],
['email'],
['profile'],
['email', 'profile'],
)
def test_jwt_construction(self, scopes):
def test_jwt_construction(self):
"""
Verify that a valid JWT is built, including claims for the requested scopes.
"""
token = JwtBuilder(self.user).build_token(scopes, self.expires_in)
self.assert_valid_jwt_access_token(token, self.user, scopes)
token = JwtBuilder(self.user).build_token(expires_in=self.expires_in)
self.assert_valid_jwt_access_token(token, self.user, self.scopes)
def test_user_profile_missing(self):
"""
@@ -42,27 +36,21 @@ class TestJwtBuilder(mixins.AccessTokenMixin, TestCase):
"""
self.profile.delete()
scopes = ['profile']
token = JwtBuilder(self.user).build_token(scopes, self.expires_in)
self.assert_valid_jwt_access_token(token, self.user, scopes)
token = JwtBuilder(self.user).build_token(expires_in=self.expires_in)
self.assert_valid_jwt_access_token(token, self.user, self.scopes)
def test_override_secret_and_audience_and_issuer(self):
def test_override_secret_and_audience(self):
"""
Verify that the signing key, audience, and issuer can be overridden.
Verify that the signing key and audience can be overridden.
"""
secret = 'avoid-this'
audience = 'avoid-this-too'
issuer = 'avoid-this-too'
scopes = []
token = JwtBuilder(
self.user,
secret=secret,
issuer=issuer,
).build_token(
scopes,
self.expires_in,
expires_in=self.expires_in,
aud=audience,
)
jwt.decode(token, secret, audience=audience, issuer=issuer)
self.assert_valid_jwt_access_token(token, self.user, self.scopes, aud=audience, secret=secret)

View File

@@ -1,124 +1,33 @@
"""Utilities for working with ID tokens."""
import json
from time import time
from django.conf import settings
from django.utils.functional import cached_property
from jwkest import jwk
from jwkest.jws import JWS
from edx_django_utils.monitoring import set_custom_metric
from student.models import UserProfile, anonymous_id_for_user
"""
TODO (ARCH-248)
Deprecated JwtBuilder class.
Use openedx.core.djangoapps.oauth_dispatch.jwt.JwtBuilder directly.
This is here for backward compatibility reasons only.
"""
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
class JwtBuilder(object):
"""Utility for building JWTs.
Unifies diverse approaches to JWT creation in a single class. This utility defaults to using the system's
JWT configuration.
NOTE: This utility class will allow you to override the signing key and audience claim to support those
clients which still require this. This approach to JWT creation is DEPRECATED. Avoid doing this for new clients.
Arguments:
user (User): User for which to generate the JWT.
Keyword Arguments:
asymmetric (Boolean): Whether the JWT should be signed with this app's private key.
secret (string): Overrides configured JWT secret (signing) key. Unused if an asymmetric signature is requested.
issuer (string): Overrides configured JWT issuer.
"""
def __init__(self, user, asymmetric=False, secret=None, issuer=None):
Deprecated. See module docstring above.
"""
def __init__(self, user, secret=None):
self.user = user
self.asymmetric = asymmetric
self.secret = secret
self.issuer = issuer
self.jwt_auth = settings.JWT_AUTH
def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None):
"""Returns a JWT access token.
Arguments:
scopes (list): Scopes controlling which optional claims are included in the token.
Keyword Arguments:
expires_in (int): Time to token expiry, specified in seconds.
aud (string): Overrides configured JWT audience claim.
additional_claims (dict): Additional claims to include in the token.
Returns:
str: Encoded JWT
def build_token(
self,
scopes=None, # pylint: disable=unused-argument
expires_in=None, # pylint: disable=unused-argument
aud=None,
additional_claims=None,
):
"""
now = int(time())
expires_in = expires_in or self.jwt_auth['JWT_EXPIRATION']
set_custom_metric('jwt_expires_in', expires_in)
payload = {
# TODO Consider getting rid of this claim since we don't use it.
'aud': aud if aud else self.jwt_auth['JWT_AUDIENCE'],
'exp': now + expires_in,
'iat': now,
'iss': self.issuer if self.issuer else self.jwt_auth['JWT_ISSUER'],
'preferred_username': self.user.username,
'scopes': scopes,
'version': self.jwt_auth['JWT_SUPPORTED_VERSION'],
'sub': anonymous_id_for_user(self.user, None),
}
if additional_claims:
payload.update(additional_claims)
for scope in scopes:
handler = self.claim_handlers.get(scope)
if handler:
handler(payload)
return self.encode(payload)
@cached_property
def claim_handlers(self):
"""Returns a dictionary mapping scopes to methods that will add claims to the JWT payload."""
return {
'email': self.attach_email_claim,
'profile': self.attach_profile_claim
}
def attach_email_claim(self, payload):
"""Add the email claim details to the JWT payload."""
payload['email'] = self.user.email
def attach_profile_claim(self, payload):
"""Add the profile claim details to the JWT payload."""
try:
# Some users (e.g., service users) may not have user profiles.
name = UserProfile.objects.get(user=self.user).name
except UserProfile.DoesNotExist:
name = None
payload.update({
'name': name,
'family_name': self.user.last_name,
'given_name': self.user.first_name,
'administrator': self.user.is_staff,
})
def encode(self, payload):
"""Encode the provided payload."""
set_custom_metric('jwt_asymmetric', self.asymmetric)
keys = jwk.KEYS()
if self.asymmetric:
serialized_keypair = json.loads(self.jwt_auth['JWT_PRIVATE_SIGNING_JWK'])
keys.add(serialized_keypair)
algorithm = self.jwt_auth['JWT_SIGNING_ALGORITHM']
else:
key = self.secret if self.secret else self.jwt_auth['JWT_SECRET_KEY']
keys.add({'key': key, 'kty': 'oct'})
algorithm = self.jwt_auth['JWT_ALGORITHM']
data = json.dumps(payload)
jws = JWS(data, alg=algorithm)
return jws.sign_compact(keys=keys)
Deprecated. See module docstring above.
"""
return create_jwt_for_user(
self.user,
secret=self.secret,
aud=aud,
additional_claims=additional_claims,
)

View File

@@ -546,9 +546,10 @@ def get_enterprise_learner_data(user):
"""
Client API operation adapter/wrapper
"""
enterprise_learner_data = EnterpriseApiClient(user=user).fetch_enterprise_learner_data(user)
if enterprise_learner_data:
return enterprise_learner_data['results']
if user.is_authenticated:
enterprise_learner_data = EnterpriseApiClient(user=user).fetch_enterprise_learner_data(user)
if enterprise_learner_data:
return enterprise_learner_data['results']
@enterprise_is_enabled(otherwise={})

View File

@@ -166,10 +166,7 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase):
@httpretty.activate
def test_consent_needed_for_course(self):
user = mock.MagicMock(
username='janedoe',
is_authenticated=lambda: True,
)
user = UserFactory(username='janedoe')
request = mock.MagicMock(session={}, user=user)
self.mock_enterprise_learner_api()
self.mock_consent_missing(user.username, 'fake-course', 'cf246b88-d5f6-4908-a522-fc307e0b0c59')