Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multi key and key id support. #33

Merged
merged 8 commits into from
Apr 23, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
42 changes: 42 additions & 0 deletions changelog.d/33.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
* Support multiple algorithms and keys

Existing code made key rollovers or algorithm changes hard and
basically required a breaking change: Once any of `JWT_ALGORITHM`,
`JWT_SECRET_KEY`, or `JWT_PRIVATE_KEY`/`JWT_PUBLIC_KEY` were
changed, existing tokens were rendered invalid.

We now support `JWT_ALGORITHM`, `JWT_SECRET_KEY`, and
`JWT_PUBLIC_KEY` optionally being a list, where all members are
accepted as valid.

When `JWT_SECRET_KEY` is a list, the first member is used for
signing and all others are accepted for verification.

* Support multiple keys with key ids

We also support identifing keys by key id (`kid` header): When a JWT
carries a key id, we can identify immediately if it is known and
only need to make at most one verification attempt.

To configure keys with ids, `JWT_SECRET_KEY`, `JWT_PRIVATE_KEY` and
`JWT_PUBLIC_KEY` can now also be a dict in the form

```
{ "kid1": key1, "kid2": key2, ... }
```

When a JWT does not carry a key id (`kid` header), the default is to
fall back to trying all keys if keys are named (defined as a dict).
Setting `JWT_INSIST_ON_KID: True` avoids this fallback and requires
any JWT to be validated to carry a key id _if_ key IDs are used

*NOTE: For python < 3.7, use a `collections.OrderedDict` object
instead of a dict
nigoroll marked this conversation as resolved.
Show resolved Hide resolved

* Require cryptographic dependencies of PyJWT

We changed the PyJWT requirement to include support for RSA by
default. This was done to improve the user experience, but will lead
to cryptography support be installed where not already present.

See: https://pyjwt.readthedocs.io/en/latest/installation.html#cryptographic-dependencies-optional
82 changes: 79 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,39 @@ This package uses the JSON Web Token Python implementation, [PyJWT](https://gith

This is the secret key used to sign the JWT. Make sure this is safe and not shared or public.

Can be a dict, a list or a scalar.

* When a dict, the dict keys are taken as the JWT key ids and the values as
keys, e.g.:

```python
{ "kid1": key1, "kid2": key2, ... }
```

The first element is used for signing.

If a JWT to be verified contains a key id (`kid` header), only the
key with that id is tried (if any).

* When a list, all elements are accepted for verification and the
first element is used for signing.

* When a scalar, this secret is used for signing and verification.

*NOTE: For python < 3.7, use a `collections.OrderedDict` object*, e.g.:

```python
from collections import OrderedDict

JWT_AUTH["JWT_SECRET_KEY"] = OrderedDict(kid1=key1, kid2=key2, ...)
```

(The first) `JWT_SECRET_KEY` is only used for signing if (the first)
`JWT_ALGORITHM` is `HS*`, otherwise `JWT_PRIVATE_KEY` is used.

`JWT_SECRET_KEY`(s) is/are only used for verification of JWTs with
`alg` matching `HS*`
nigoroll marked this conversation as resolved.
Show resolved Hide resolved

Default is your project's `settings.SECRET_KEY`.

### JWT_GET_USER_SECRET_KEY
Expand All @@ -200,22 +233,65 @@ Default is `None`.

### JWT_PRIVATE_KEY

This is an object of type `cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey`. It will be used to sign the signature component of the JWT. It will override `JWT_SECRET_KEY` when set. Read the [documentation](https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey) for more details. Please note that `JWT_ALGORITHM` must be set to one of `RS256`, `RS384`, or `RS512`.
Can be a scalar or a dict.

When a dict, the dict key is taken as the JWT key id and the values as
the key, e.g.:

```python
{ "kid": key }
```

The scalar or the dict value must be in any [private key format supported by PyJWT](https://pyjwt.readthedocs.io/en/latest/algorithms.html), for example of the types

* `cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey`
* `cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey`

And will be used to sign the signature component of the JWT if `JWT_ALGORITHM` is set to any of the [supported algorithms](https://pyjwt.readthedocs.io/en/latest/algorithms.html) other than the hash types `HS*`.

Default is `None`.

### JWT_PUBLIC_KEY

This is an object of type `cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`. It will be used to verify the signature of the incoming JWT. It will override `JWT_SECRET_KEY` when set. Read the [documentation](https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey) for more details. Please note that `JWT_ALGORITHM` must be set to one of `RS256`, `RS384`, or `RS512`.
Can be a scalar, a list or a dict.

* When a dict, the dict keys are taken as the JWT key ids and the values as
keys, e.g.:

```python
{ "kid1": key1, "kid2": key2, ... }
nigoroll marked this conversation as resolved.
Show resolved Hide resolved
```

If a JWT that contains a key id (kid header) is to be verified, only
the associated key is tried. Otherwise, or

* when a list, all of the elements will be accepted for verification of JWTs with `alg` being (any of) `JWT_ALGORITHM` not matching `HS*`.

The scalar or elements/values of the list/dict must be in any [public key format supported by PyJWT](https://pyjwt.readthedocs.io/en/latest/algorithms.html), for example of the types

* `cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`
* `cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`

Default is `None`.

### JWT_ALGORITHM

Possible values are any of the [supported algorithms](https://github.com/jpadilla/pyjwt/blob/master/docs/algorithms.rst) for cryptographic signing in `PyJWT`.
Possible values are any of the [supported algorithms](https://pyjwt.readthedocs.io/en/latest/algorithms.html) for cryptographic signing in `PyJWT`.

Can be a scalar or a list.

* For a scalar, this algorithm is used for signing and verification.

* For a list, the first element is used for signing and all elements are accepted for verification.

Default is `"HS256"`.

### JWT_INSIST_ON_KID

When key IDs are used (`JWT_SECRET_KEY` and/or `JWT_PUBLIC_KEY` given
as a dict assigning key IDs to keys), insist that JWTs to be validated
have a `kid` header with a defined key.

nigoroll marked this conversation as resolved.
Show resolved Hide resolved
### JWT_AUDIENCE

This is a string that will be checked against the `aud` field of the token, if present.
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ python_requires = >= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.*
zip_safe = False
include_package_data = True
install_requires =
PyJWT>=1.5.2,<2.0.0
PyJWT[crypto]>=1.5.2,<2.0.0
Django>=1.11
djangorestframework>=3.7

Expand Down
1 change: 1 addition & 0 deletions src/rest_framework_jwt/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
'JWT_PRIVATE_KEY': None,
'JWT_PUBLIC_KEY': None,
'JWT_ALGORITHM': 'HS256',
'JWT_INSIST_ON_KID': False,
'JWT_AUDIENCE': None,
'JWT_ISSUER': None,
'JWT_ENCODE_HANDLER':
Expand Down
78 changes: 65 additions & 13 deletions src/rest_framework_jwt/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,23 @@ def jwt_get_username_from_payload_handler(payload):
def jwt_encode_payload(payload):
"""Encode JWT token claims."""

key = api_settings.JWT_PRIVATE_KEY or jwt_get_secret_key(payload)
return force_str(jwt.encode(payload, key, api_settings.JWT_ALGORITHM))
headers=None

signing_algorithm = api_settings.JWT_ALGORITHM
if isinstance(signing_algorithm,list):
signing_algorithm = signing_algorithm[0]
if signing_algorithm.startswith("HS"):
key = jwt_get_secret_key(payload)
else:
key = api_settings.JWT_PRIVATE_KEY

if isinstance(key, dict):
kid, key = next(iter(key.items()))
headers = {"kid": kid}
elif isinstance(key,list):
key = key[0]
nigoroll marked this conversation as resolved.
Show resolved Hide resolved

return jwt.encode(payload, key, signing_algorithm, headers=headers).decode()


def jwt_decode_token(token):
Expand All @@ -117,17 +132,54 @@ def jwt_decode_token(token):
options = {
'verify_exp': api_settings.JWT_VERIFY_EXPIRATION,
}
# get user from token, BEFORE verification, to get user secret key
unverified_payload = jwt.decode(token, None, False)
secret_key = jwt_get_secret_key(unverified_payload)
return jwt.decode(
token, api_settings.JWT_PUBLIC_KEY or secret_key,
api_settings.JWT_VERIFY, options=options,
leeway=api_settings.JWT_LEEWAY, audience=api_settings.JWT_AUDIENCE,
issuer=api_settings.JWT_ISSUER, algorithms=[
api_settings.JWT_ALGORITHM
]
)

algos = api_settings.JWT_ALGORITHM
nigoroll marked this conversation as resolved.
Show resolved Hide resolved
if not isinstance(algos, list):
algos = [algos]

hdr = jwt.get_unverified_header(token)
alg = hdr["alg"]
if alg not in algos:
raise jwt.exceptions.InvalidAlgorithmError

kid = hdr["kid"] if "kid" in hdr else None

keys = None
if alg.startswith("HS"):
unverified_payload = jwt.decode(token, None, False)
keys = jwt_get_secret_key(unverified_payload)
else:
keys = api_settings.JWT_PUBLIC_KEY

# if keys are named and the jwt has a kid, only consider exactly that key
# otherwise if the JWT has no kid, JWT_INSIST_ON_KID selects if we fail
# or try all defined keys
if isinstance(keys, dict):
if kid:
try:
keys = keys[kid]
except KeyError:
raise jwt.exceptions.InvalidKeyError
elif api_settings.JWT_INSIST_ON_KID:
raise jwt.exceptions.InvalidKeyError
else:
keys = list(keys.values())

if not isinstance(keys, list):
keys = [keys]

ex = None
for key in keys:
try:
return jwt.decode(
token, key, api_settings.JWT_VERIFY, options=options,
leeway=api_settings.JWT_LEEWAY,
audience=api_settings.JWT_AUDIENCE,
issuer=api_settings.JWT_ISSUER, algorithms=[alg]
)
except (jwt.exceptions.InvalidSignatureError) as e:
ex = e
nigoroll marked this conversation as resolved.
Show resolved Hide resolved
raise ex


def jwt_create_response_payload(
Expand Down