Asymmetric JWT support

This commit is contained in:
Nimisha Asthagiri
2018-07-23 16:16:44 -04:00
parent d06545a3cd
commit eac1ce7bfd
13 changed files with 328 additions and 165 deletions

View File

@@ -100,8 +100,6 @@ from lms.envs.common import (
# to generating test databases will discover and try to create all tables
# and this setting needs to be present
OAUTH2_PROVIDER_APPLICATION_MODEL,
DEFAULT_JWT_ISSUER,
RESTRICTED_APPLICATION_JWT_ISSUER,
JWT_AUTH,
USERNAME_REGEX_PARTIAL,

View File

@@ -883,14 +883,8 @@ LTI_AGGREGATE_SCORE_PASSBACK_DELAY = ENV_TOKENS.get(
CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_URL)
#### JWT configuration ####
DEFAULT_JWT_ISSUER = ENV_TOKENS.get('DEFAULT_JWT_ISSUER', DEFAULT_JWT_ISSUER)
RESTRICTED_APPLICATION_JWT_ISSUER = ENV_TOKENS.get(
'RESTRICTED_APPLICATION_JWT_ISSUER',
RESTRICTED_APPLICATION_JWT_ISSUER
)
JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {}))
JWT_PRIVATE_SIGNING_KEY = ENV_TOKENS.get('JWT_PRIVATE_SIGNING_KEY', JWT_PRIVATE_SIGNING_KEY)
JWT_EXPIRED_PRIVATE_SIGNING_KEYS = ENV_TOKENS.get('JWT_EXPIRED_PRIVATE_SIGNING_KEYS', JWT_EXPIRED_PRIVATE_SIGNING_KEYS)
JWT_AUTH.update(AUTH_TOKENS.get('JWT_AUTH', {}))
################# PROCTORING CONFIGURATION ##################

View File

@@ -3146,11 +3146,6 @@ LTI_USER_EMAIL_DOMAIN = 'lti.example.com'
# The time value is in seconds.
LTI_AGGREGATE_SCORE_PASSBACK_DELAY = 15 * 60
# For help generating a key pair import and run `openedx.core.lib.rsa_key_utils.generate_rsa_key_pair()`
JWT_PRIVATE_SIGNING_KEY = None
JWT_EXPIRED_PRIVATE_SIGNING_KEYS = []
# Credit notifications settings
NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css"
NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png"
@@ -3158,20 +3153,7 @@ NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.pn
################################ Settings for JWTs ################################
DEFAULT_JWT_ISSUER = {
'ISSUER': 'change-me',
'SECRET_KEY': SECRET_KEY,
'AUDIENCE': 'change-me',
}
RESTRICTED_APPLICATION_JWT_ISSUER = {
'ISSUER': 'change-me',
'SECRET_KEY': SECRET_KEY,
'AUDIENCE': 'change-me',
}
JWT_AUTH = {
'JWT_ALGORITHM': 'HS256',
'JWT_VERIFY_EXPIRATION': True,
'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'),
@@ -3182,13 +3164,15 @@ JWT_AUTH = {
'JWT_EXPIRATION': 30,
'JWT_SUPPORTED_VERSION': '1.1.0',
'JWT_SECRET_KEY': DEFAULT_JWT_ISSUER['SECRET_KEY'],
'JWT_ISSUER': DEFAULT_JWT_ISSUER['ISSUER'],
'JWT_AUDIENCE': DEFAULT_JWT_ISSUER['AUDIENCE'],
'JWT_ISSUERS': [
DEFAULT_JWT_ISSUER,
RESTRICTED_APPLICATION_JWT_ISSUER,
],
'JWT_ALGORITHM': 'HS256',
'JWT_SECRET_KEY': SECRET_KEY,
'JWT_SIGNING_ALGORITHM': 'RS512',
'JWT_PRIVATE_SIGNING_JWK': None,
'JWT_PUBLIC_SIGNING_JWK_SET': None,
'JWT_ISSUER': 'change-me',
'JWT_AUDIENCE': 'change-me',
}
################################ Settings for Microsites ################################

View File

@@ -228,41 +228,32 @@ CORS_ALLOW_CREDENTIALS = True
CORS_ORIGIN_WHITELIST = ()
CORS_ORIGIN_ALLOW_ALL = True
# JWT settings for devstack
JWT_PRIVATE_SIGNING_KEY = """\
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkK6N/mhkEYrgx
p8xEZj37N1FEj1gObWv7zVygMLKxKvCSFOQUjA/Z2ZLqVi8m5DnCJ+5BrdYW/UqH
02vZdEnWb04vf8mmYzJOL9i7APu0h/rm1pvVI5JFiSjE4pG669m5dAb2dZtesYOd
yfC5bF97KbBZoisCEAtRLn6cNrt1q6PxWeCxZq4ysQD8xZKETOxHnfAYqVyIRkDW
v8B9DnldLjYa8GhuGHL1J5ncHoseJoATLCnAWYo+yy6gdI2Fs9rj0tbeBcnoKwUZ
ENwEUp3En+Xw7zjtDuSDWW9ySkuwrK7nXrs0r1CPVf87dLBUEvdzHHUelDr6rdIY
tnieCjCHAgMBAAECggEBAJvTiAdQPzq4cVlAilTKLz7KTOsknFJlbj+9t5OdZZ9g
wKQIDE2sfEcti5O+Zlcl/eTaff39gN6lYR73gMEQ7h0J3U6cnsy+DzvDkpY94qyC
/ZYqUhPHBcnW3Mm0vNqNj0XGae15yBXjrKgSy9lUknSXJ3qMwQHeNL/DwA2KrfiL
g0iVjk32dvSSHWcBh0M+Qy1WyZU0cf9VWzx+Q1YLj9eUCHteStVubB610XV3JUZt
UTWiUCffpo2okHsTBuKPVXK/5BL+BpGplcxRSlnSbMaI611kN3iKlO8KGISXHBz7
nOPdkfZC9poEXt5SshtINuGGCCc8hDxpg1otYqCLaYECgYEA1MSCPs3pBkEagchV
g0rxYmDUC8QkeIOBuZFjhkdoUgZ6rFntyRZd1NbCUi3YBbV1YC12ZGohqWUWom1S
AtNbQ2ZTbqEnDKWbNvLBRwkdp/9cKBce85lCCD6+U2o2Ha8C0+hKeLBn8un1y0zY
1AQTqLAz9ItNr0aDPb89cs5voWcCgYEAxYdC8vR3t8iYMUnK6LWYDrKSt7YiorvF
qXIMANcXQrnO0ptC0B56qrUCgKHNrtPi5bGpNBJ0oKMfbmGfwX+ca8sCUlLvq/O8
S2WZwSJuaHH4lEBi8ErtY++8F4B4l3ENCT84Hyy5jiMpbpkHEnh/1GNcvvmyI8ud
3jzovCNZ4+ECgYEA0r+Oz0zAOzyzV8gqw7Cw5iRJBRqUkXaZQUj8jt4eO9lFG4C8
IolwCclrk2Drb8Qsbka51X62twZ1ZA/qwve9l0Y88ADaIBHNa6EKxyUFZglvrBoy
w1GT8XzMou06iy52G5YkZeU+IYOSvnvw7hjXrChUXi65lRrAFqJd6GEIe5MCgYA/
0LxDa9HFsWvh+JoyZoCytuSJr7Eu7AUnAi54kwTzzL3R8tE6Fa7BuesODbg6tD/I
v4YPyaqePzUnXyjSxdyOQq8EU8EUx5Dctv1elTYgTjnmA4szYLGjKM+WtC3Bl4eD
pkYGZFeqYRfAoHXVdNKvlk5fcKIpyF2/b+Qs7CrdYQKBgQCc/t+JxC9OpI+LhQtB
tEtwvklxuaBtoEEKJ76P9vrK1semHQ34M1XyNmvPCXUyKEI38MWtgCCXcdmg5syO
PBXdDINx+wKlW7LPgaiRL0Mi9G2aBpdFNI99CWVgCr88xqgSE24KsOxViMwmi0XB
Ld/IRK0DgpGP5EJRwpKsDYe/UQ==
-----END PRIVATE KEY-----"""
###################### JWTs ######################
JWT_AUTH.update({
'JWT_SECRET_KEY': 'lms-secret',
'JWT_ISSUER': 'http://127.0.0.1:8000/oauth2',
'JWT_ISSUER': OAUTH_OIDC_ISSUER,
'JWT_AUDIENCE': 'lms-key',
'JWT_SECRET_KEY': 'lms-secret',
'JWT_PRIVATE_SIGNING_JWK': (
'{"e": "AQAB", "d": "RQ6k4NpRU3RB2lhwCbQ452W86bMMQiPsa7EJiFJUg-qBJthN0FMNQVbArtrCQ0xA1BdnQHThFiUnHcXfsTZUwmwvTu'
'iqEGR_MI6aI7h5D8vRj_5x-pxOz-0MCB8TY8dcuK9FkljmgtYvV9flVzCk_uUb3ZJIBVyIW8En7n7nV7JXpS9zey1yVLld2AbRG6W5--Pgqr9J'
'CI5-bLdc2otCLuen2sKyuUDHO5NIj30qGTaKUL-OW_PgVmxrwKwccF3w5uGNEvMQ-IcicosCOvzBwdIm1uhdm9rnHU1-fXz8VLRHNhGVv7z6mo'
'ghjNI0_u4smhUkEsYeshPv7RQEWTdkOQ", "n": "smKFSYowG6nNUAdeqH1jQQnH1PmIHphzBmwJ5vRf1vu48BUI5VcVtUWIPqzRK_LDSlZYh'
'9D0YFL0ZTxIrlb6Tn3Xz7pYvpIAeYuQv3_H5p8tbz7Fb8r63c1828wXPITVTv8f7oxx5W3lFFgpFAyYMmROC4Ee9qG5T38LFe8_oAuFCEntimW'
'xN9F3P-FJQy43TL7wG54WodgiM0EgzkeLr5K6cDnyckWjTuZbWI-4ffcTgTZsL_Kq1owa_J2ngEfxMCObnzGy5ZLcTUomo4rZLjghVpq6KZxfS'
'6I1Vz79ZsMVUWEdXOYePCKKsrQG20ogQEkmTf9FT_SouC6jPcHLXw", "q": "7KWj7l-ZkfCElyfvwsl7kiosvi-ppOO7Imsv90cribf88Dex'
'cO67xdMPesjM9Nh5X209IT-TzbsOtVTXSQyEsy42NY72WETnd1_nAGLAmfxGdo8VV4ZDnRsA8N8POnWjRDwYlVBUEEeuT_MtMWzwIKU94bzkWV'
'nHCY5vbhBYLeM", "p": "wPkfnjavNV1Hqb5Qqj2crBS9HQS6GDQIZ7WF9hlBb2ofDNe2K2dunddFqCOdvLXr7ydRcK51ZwSeHjcjgD1aJkHA'
'9i1zqyboxgd0uAbxVDo6ohnlVqYLtap2tXXcavKm4C9MTpob_rk6FBfEuq4uSsuxFvCER4yG3CYBBa4gZVU", "kid": "devstack_key", "'
'kty": "RSA"}'
),
'JWT_PUBLIC_SIGNING_JWK_SET': (
'{"keys": [{"kid": "devstack_key", "e": "AQAB", "kty": "RSA", "n": "smKFSYowG6nNUAdeqH1jQQnH1PmIHphzBmwJ5vRf1vu'
'48BUI5VcVtUWIPqzRK_LDSlZYh9D0YFL0ZTxIrlb6Tn3Xz7pYvpIAeYuQv3_H5p8tbz7Fb8r63c1828wXPITVTv8f7oxx5W3lFFgpFAyYMmROC'
'4Ee9qG5T38LFe8_oAuFCEntimWxN9F3P-FJQy43TL7wG54WodgiM0EgzkeLr5K6cDnyckWjTuZbWI-4ffcTgTZsL_Kq1owa_J2ngEfxMCObnzG'
'y5ZLcTUomo4rZLjghVpq6KZxfS6I1Vz79ZsMVUWEdXOYePCKKsrQG20ogQEkmTf9FT_SouC6jPcHLXw"}]}'
),
})
#####################################################################

View File

@@ -27,18 +27,8 @@ CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:18150'
OAUTH_OIDC_ISSUER = '{}/oauth2'.format(LMS_ROOT_URL)
DEFAULT_JWT_ISSUER = {
'ISSUER': OAUTH_OIDC_ISSUER,
'SECRET_KEY': 'lms-secret',
'AUDIENCE': 'lms-key',
}
JWT_AUTH.update({
'JWT_ISSUER': DEFAULT_JWT_ISSUER['ISSUER'],
'JWT_AUDIENCE': DEFAULT_JWT_ISSUER['AUDIENCE'],
'JWT_ISSUERS': [
DEFAULT_JWT_ISSUER,
RESTRICTED_APPLICATION_JWT_ISSUER,
],
'JWT_ISSUER': OAUTH_OIDC_ISSUER,
})
FEATURES.update({

View File

@@ -272,19 +272,6 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
# don't cache courses for testing
OIDC_COURSE_HANDLER_CACHE_TIMEOUT = 0
########################### Settings for JWTs ##################################
RESTRICTED_APPLICATION_JWT_ISSUER = {
'ISSUER': 'restricted-app',
'SECRET_KEY': 'restricted-secret',
'AUDIENCE': 'restricted-app',
}
JWT_AUTH.update({
'JWT_ISSUERS': [
DEFAULT_JWT_ISSUER,
RESTRICTED_APPLICATION_JWT_ISSUER,
],
})
########################### External REST APIs #################################
FEATURES['ENABLE_MOBILE_REST_API'] = True
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True

View File

@@ -72,13 +72,11 @@ unprotected microservices.
make the new keys available to a microservice only after they
have been updated to enforce OAuth Scopes.
* edx_rest_framework_extensions.settings_ supports having a list of
JWT_ISSUERS instead of just a single one.
* The `edx-platform settings`_ will be updated to have a list of
JWT_ISSUERS instead of a single JWT_ISSUER in its settings (example_).
A separate settings field will keep track of which is the new issuer
key that is to be used for signing tokens for Restricted Application.
* The `edx-platform settings`_ will be updated to support a new signing
key. Since this transition to using a new key will happen as a staged
rollout, we will take this opportunity to have the new signing key be
an asymmetric key, rather than the current (not as secure) shared
symmetric key.
* oauth_dispatch.views.AccessTokenView.dispatch_ will be updated to
pass the new JWT key to JwtBuilder_, but only if
@@ -90,12 +88,10 @@ unprotected microservices.
JWT tokens for Restricted Applications, but ONLY if:
* the token_type in the request equals *"jwt"* and
* a `feature toggle (switch)`_ named "oauth2.unexpired_restricted_applications"
* a `feature toggle (switch)`_ named "oauth2.enforce_jwt_scopes"
is enabled.
.. _edx_rest_framework_extensions.settings: https://github.com/edx/edx-drf-extensions/blob/1db9f5e3e5130a1e0f43af2035489b3ed916d245/edx_rest_framework_extensions/settings.py#L73
.. _edx-platform settings: https://github.com/edx/edx-platform/blob/master/lms/envs/docs/README.rst
.. _example: https://github.com/edx/edx-drf-extensions/blob/1db9f5e3e5130a1e0f43af2035489b3ed916d245/test_settings.py#L51
.. _JwtBuilder: https://github.com/edx/edx-platform/blob/d3d64970c36f36a96d684571ec5b48ed645618d8/openedx/core/lib/token_utils.py#L15
.. _oauth_dispatch.views.AccessTokenView.dispatch: https://github.com/edx/edx-platform/blob/d21a09828072504bc97a2e05883c1241e3a35da9/openedx/core/djangoapps/oauth_dispatch/views.py#L100
.. _oauth_dispatch.validators: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py

View File

@@ -0,0 +1,185 @@
8. Use Asymmetric JWTs
----------------------
Status
------
Accepted
Context
-------
The edX OAuth Provider (via this OAuth Dispatch Django app) builds and returns JSON Web Tokens (JWTs)
when an OAuth client requests an access token with "token_type=jwt" in the request. See `Use JWT as
OAuth2 Tokens; Remove OpenID Connect`_.
We use a shared secret ("symmetric" cryptographic key) to "sign" the JWT with an HMAC (a keyed-hash
message authentication code). This means the secret used by the OAuth Provider to create JWTs is not
really a secret since all OAuth Clients need to know the value of the secret in order to verify the
contents of the JWT.
The JWT is currently not encrypted, only signed. So any client can always read the contents of the JWT.
But to verify that the JWT was created by the OAuth Provider, the client should first verify the HMAC
sent along with the JWT. Since the secret is "symmetric" any OAuth Client that is privy to the secret
could also have just as easily created the JWT (thus spoofing the OAuth Provider).
.. _`Use JWT as OAuth2 Tokens; Remove OpenID Connect`: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0003-use-jwt-as-oauth-tokens-remove-openid-connect.rst
Additionally, for clients that still use Open ID Connect, their `ID Tokens are HMACed with their own
client_secret`_ (privately shared with the OAuth Provider). Although this somewhat mitigates the issue
above since each OAuth Client can no longer create tokens verifiable by other Clients, it does not
allow a Client to forward a verifiable token to other Clients.
.. _ID Tokens are HMACed with their own client_secret: https://github.com/edx/edx-oauth2-provider/blob/7e59e30ae0bfd9eac4d05469768d79c50a90aeb7/edx_oauth2_provider/views.py#L155-L163
Looking forward, we want to support Single Page Apps (a.k.a., Microfronteds), where users can seamlessly
traverse from one microfronted to another and access APIs on various backends. This *Single Sign On*
capability cannot be achieved unless verifiable tokens can be forwarded from one service to another.
Decisions
---------
Asymmetric JWTs
~~~~~~~~~~~~~~~
We will introduce identified "asymmetric" cryptographic keys for signing JWTs. The OAuth Provider will
be the only service configured with the aymmetric keypair, including its Private and Public key portions.
All other OAuth Clients will be configured with only the Public key portion of the asymmetric key pair.
"kid" Key Identifier
~~~~~~~~~~~~~~~~~~~~
In order to support key rotation in a forward compatible manner, we will identify the asymmetric keys,
using the `JSON Web Key (JWK)`_ standard's `"kid" (Key ID)`_ parameter. When a `JSON Web Signature (JWS)`_
is created to sign a JWT, its `"kid" header parameter`_ specifies which key was used to secure the JWS.
The code examples below show this in action.
.. _JSON Web Key (JWK): https://tools.ietf.org/html/draft-ietf-jose-json-web-key-36
.. _`"kid" (Key ID)`: https://tools.ietf.org/html/draft-ietf-jose-json-web-key-36#section-4.5
.. _JSON Web Signature (JWS): https://tools.ietf.org/html/rfc7515
.. _`"kid" header parameter`: https://tools.ietf.org/html/rfc7515#section-4.1.4
Remove JWT_ISSUERS
~~~~~~~~~~~~~~~~~~
edx_rest_framework_extensions.settings_ supports having a list of **JWT_ISSUERS** instead of just a single
one. This support for configuring multiple issuers is present across many services. However, this does not
conform to the `JWT standard`_, where the `issuer`_ is intended to identify the entity that generates and
signs the JWT. In our case, that should be the single Auth service only.
If different values for the issuer_ claim are needed for multi-tenancy purposes, those should be specified
using `site configuration`_ variants instead of adding complexity with multiple issuers.
Additionally, **JWT_ISSUERS** is not intended to be used for key rotation. Rather, the set of active signing
keys should be specified as a `JSON Web Key Set (JWK Set)`_ instead. Thus, there would only be a single
issuer, but with (the potential of) multiple signing keys stored in a JWT Set.
.. _edx_rest_framework_extensions.settings: https://github.com/edx/edx-drf-extensions/blob/1db9f5e3e5130a1e0f43af2035489b3ed916d245/edx_rest_framework_extensions/settings.py#L73
.. _JWT standard: https://tools.ietf.org/html/rfc7519
.. _issuer: https://tools.ietf.org/html/rfc7519#section-4.1.1
.. _JSON Web Key Set (JWK Set): https://tools.ietf.org/html/draft-ietf-jose-json-web-key-36#section-5
.. _site configuration: https://github.com/edx/edx-platform/blob/af841336c7e39d634c238cd8a11c5a3a661aa9e2/openedx/core/djangoapps/site_configuration/__init__.py
Example Code
------------
KeyPair Generation
~~~~~~~~~~~~~~~~~~
Here is code for generating a keypair::
from Cryptodome.PublicKey import RSA
from jwkest import jwk
rsa_key = RSA.generate(2048)
rsa_jwk = jwk.RSAKey(kid="your_key_id", key=rsa_key)
To serialize the **public key** in a `JSON Web Key Set (JWK Set)`_::
public_keys = jwk.KEYS()
public_keys.append(rsa_jwk)
serialized_public_keys_json = public_keys.dump_jwks()
and its sample output::
{
"keys": [
{
"kid": "your_key_id",
"e": "strawberry",
"kty": "RSA",
"n": "something"
}
]
}
To serialize the **keypair** as a JWK::
serialized_keypair = rsa_jwk.serialize(private=True)
serialized_keypair_json = json.dumps(serialized_keypair)
and its sample output::
{
"e": "strawberry",
"d": "apple",
"n": "banana",
"q": "pear",
"p": "plum",
"kid": "your_key_id",
"kty": "RSA"
}
Signing
~~~~~~~
To deserialize the keypair from above::
private_keys = jwk.KEYS()
serialized_keypair = json.loads(serialized_keypair_json)
private_keys.add(serialized_keypair)
To create a signature::
from jwkest.jws import JWS
jws = JWS("JWT payload", alg="RS512")
signed_message = jws.sign_compact(keys=private_keys)
Note: we specify **RS512** above to identify *RSASSA-PKCS1-v1_5 using SHA-512* as
the signature algorithm value as described in the `JSON Web Algorithms (JWA)`_ spec.
.. _JSON Web Algorithms (JWA): https://tools.ietf.org/html/rfc7518#section-3.3
Verify Signature
~~~~~~~~~~~~~~~~
To verify the signature from above::
public_keys = jwk.KEYS()
public_keys.load_jwks(serialized_public_keys_json)
jws.verify_compact(signed_message, public_keys)
Key Rotation
~~~~~~~~~~~~
When a new public key is added in the future, it should have a unique "kid"
value and added to the public keys JWK set::
new_rsa_key = RSA.generate(2048)
new_rsa_jwk = jwk.RSAKey(kid="new_id", key=new_rsa_key)
public_keys.append(new_rsa_jwk)
When a JWS is created, it is signed with a certain "kid"-identified keypair. When it
is later verified, the public key with the matching "kid" in the JWK set is used.
Consequences
------------
* As described in the Context_, there are both security and feature (Single Sign On)
benefits of using asymmetric JWTs.
* As we transition away from DOP and Open ID Connect (see past decisions), we continue
to have multiple authentication implementations in the platform. Introducing
asymmetric JWTs introduces yet another. The sooner we upgrade our dependent services
and remove these other mechanisms, the better - in the meantime, we are increasing
code complexity.

View File

@@ -1,9 +1,10 @@
"""
OAuth Dispatch test mixins
"""
from django.conf import settings
from jwkest.jwk import KEYS
from jwkest.jws import JWS
import jwt
from jwt.exceptions import ExpiredSignatureError
@@ -14,7 +15,7 @@ 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,
jwt_issuer=settings.DEFAULT_JWT_ISSUER, should_be_restricted=None):
should_be_asymmetric_key=False, should_be_restricted=None):
"""
Verify the specified JWT access token is valid, and belongs to the specified user.
@@ -22,27 +23,38 @@ class AccessTokenMixin(object):
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 = jwt_issuer['AUDIENCE']
issuer = jwt_issuer['ISSUER']
secret_key = jwt_issuer['SECRET_KEY']
audience = settings.JWT_AUTH['JWT_AUDIENCE']
issuer = settings.JWT_AUTH['JWT_ISSUER']
secret_key = settings.JWT_AUTH['JWT_SECRET_KEY']
def _decode_jwt(verify_expiration):
"""
Helper method to decode a JWT with the ability to
verify the expiration of said token
"""
keys = KEYS()
if should_be_asymmetric_key:
keys.load_jwks(settings.JWT_AUTH['JWT_PUBLIC_SIGNING_JWK_SET'])
else:
keys.add({'key': secret_key, 'kty': 'oct'})
_ = JWS().verify_compact(access_token.encode('utf-8'), keys)
return jwt.decode(
access_token,
secret_key,
algorithms=[settings.JWT_AUTH['JWT_ALGORITHM']],
audience=audience,
issuer=issuer,
verify_expiration=verify_expiration
verify_expiration=verify_expiration,
options={'verify_signature': False},
)
# Note that if we expect the claims to have expired

View File

@@ -9,8 +9,12 @@ import ddt
import httpretty
from django.conf import settings
from django.urls import reverse
from django.test import RequestFactory, TestCase
from django.test import RequestFactory, TestCase, override_settings
from mock import call, patch
from Cryptodome.PublicKey import RSA
from jwkest import jwk
from oauth2_provider import models as dot_models
from organizations.tests.factories import OrganizationFactory
@@ -167,6 +171,20 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
return body
def _generate_key_pair(self):
""" Generates an asymmetric key pair and returns the JWK of its public keys and keypair. """
rsa_key = RSA.generate(2048)
rsa_jwk = jwk.RSAKey(kid="key_id", key=rsa_key)
public_keys = jwk.KEYS()
public_keys.append(rsa_jwk)
serialized_public_keys_json = public_keys.dump_jwks()
serialized_keypair = rsa_jwk.serialize(private=True)
serialized_keypair_json = json.dumps(serialized_keypair)
return serialized_public_keys_json, serialized_keypair_json
@ddt.data('dop_app', 'dot_app')
def test_access_token_fields(self, client_attr):
client = getattr(self, client_attr)
@@ -242,33 +260,41 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
mock_set_custom_metric.assert_has_calls(expected_calls, any_order=True)
@ddt.data(
(False, True, settings.DEFAULT_JWT_ISSUER),
(True, False, settings.RESTRICTED_APPLICATION_JWT_ISSUER),
(False, True),
(True, False),
)
@ddt.unpack
def test_restricted_jwt_access_token(self, enforce_jwt_scopes_enabled, expiration_expected,
jwt_issuer_expected):
def test_restricted_jwt_access_token(self, enforce_jwt_scopes_enabled, expiration_expected):
"""
Verify that when requesting a JWT token from a restricted Application
within the DOT subsystem, that our claims is marked as already expired
(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)
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,
jwt_issuer=jwt_issuer_expected,
should_be_restricted=True,
)
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,
)
def test_restricted_access_token(self):
"""

View File

@@ -6,6 +6,7 @@ django-oauth-toolkit as appropriate.
from __future__ import unicode_literals
import json
import logging
from django.conf import settings
from django.views.generic import View
@@ -23,6 +24,8 @@ from . import adapters
from .dot_overrides import views as dot_overrides_views
from .toggles import ENFORCE_JWT_SCOPES
log = logging.getLogger(__name__)
class _DispatchingView(View):
"""
@@ -115,61 +118,58 @@ class AccessTokenView(RatelimitMixin, _DispatchingView):
""" Builds the content of the response, including the JWT token. """
client_id = self._get_client_id(request)
adapter = self.get_adapter(request)
expires_in, scopes, user = self._decompose_access_token_response(adapter, response)
issuer, secret, audience, filters, is_client_restricted = self._get_client_specific_claims(
client_id,
adapter
)
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': JwtBuilder(
user,
secret=secret,
issuer=issuer,
).build_token(
scopes,
'access_token': jwt_builder.build_token(
scope.split(' '),
expires_in,
aud=audience,
additional_claims={
'filters': filters,
'filters': adapter.get_authorization_filters(client_id),
'is_restricted': is_client_restricted,
},
),
'expires_in': expires_in,
'scope': scope,
'token_type': 'JWT',
'scope': ' '.join(scopes),
}
return json.dumps(content)
def _decompose_access_token_response(self, adapter, response):
""" Decomposes the access token in the request to an expiration date, scopes, and User. """
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']
scope = content['scope']
scopes = scope.split(' ')
user = adapter.get_access_token(access_token).user
expires_in = content['expires_in']
return expires_in, scopes, user
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. """
def _get_client_specific_claims(self, client_id, adapter):
""" Get claims that are specific to the client. """
# If JWT scope enforcement is enabled, we need to sign tokens
# given to restricted application with a separate secret which
# 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 remove this if/else and go back to using
# a single secret.
# been protected, we can enable all IDAs to use the same new
# (asymmetric) key.
# TODO: ARCH-162
is_client_restricted = adapter.is_client_restricted(client_id)
if ENFORCE_JWT_SCOPES.is_enabled() and is_client_restricted:
issuer_setting = 'RESTRICTED_APPLICATION_JWT_ISSUER'
else:
issuer_setting = 'DEFAULT_JWT_ISSUER'
use_asymmetric_key = ENFORCE_JWT_SCOPES.is_enabled() and is_client_restricted
monitoring_utils.set_custom_metric('oauth_asymmetric_jwt', use_asymmetric_key)
jwt_issuer = getattr(settings, issuer_setting)
filters = adapter.get_authorization_filters(client_id)
return jwt_issuer['ISSUER'], jwt_issuer['SECRET_KEY'], jwt_issuer['AUDIENCE'], filters, is_client_restricted
log.info("Using Asymmetric JWT: %s", use_asymmetric_key)
return JwtBuilder(
user,
asymmetric=use_asymmetric_key,
secret=settings.JWT_AUTH['JWT_SECRET_KEY'],
issuer=settings.JWT_AUTH['JWT_ISSUER'],
)
class AuthorizationView(_DispatchingView):

View File

@@ -2,10 +2,9 @@
import json
from time import time
from Cryptodome.PublicKey import RSA
from django.conf import settings
from django.utils.functional import cached_property
from jwkest.jwk import KEYS, RSAKey
from jwkest import jwk
from jwkest.jws import JWS
from student.models import UserProfile, anonymous_id_for_user
@@ -105,11 +104,12 @@ class JwtBuilder(object):
def encode(self, payload):
"""Encode the provided payload."""
keys = KEYS()
keys = jwk.KEYS()
if self.asymmetric:
keys.add(RSAKey(key=RSA.importKey(settings.JWT_PRIVATE_SIGNING_KEY)))
algorithm = 'RS512'
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'})

View File

@@ -116,7 +116,7 @@ edx-completion==0.1.8
edx-django-oauth2-provider==1.3.4
edx-django-release-util==0.3.1
edx-django-sites-extensions==2.3.1
edx-drf-extensions==1.5.2
edx-drf-extensions==1.5.4
edx-enterprise==0.72.2
edx-i18n-tools==0.4.6
edx-milestones==0.1.13