Login service support for JWT Cookies
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ##########################
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
204
openedx/core/djangoapps/oauth_dispatch/jwt.py
Normal file
204
openedx/core/djangoapps/oauth_dispatch/jwt.py
Normal 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)
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
103
openedx/core/djangoapps/oauth_dispatch/tests/test_api.py
Normal file
103
openedx/core/djangoapps/oauth_dispatch/tests/test_api.py
Normal 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)
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
93
openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py
Normal file
93
openedx/core/djangoapps/oauth_dispatch/tests/test_jwt.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
17
openedx/core/djangoapps/user_authn/tests/utils.py
Normal file
17
openedx/core/djangoapps/user_authn/tests/utils.py
Normal 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'],
|
||||
)
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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={})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user