Skip to content
This repository was archived by the owner on May 26, 2020. It is now read-only.

Commit 23e4a82

Browse files
committed
increased security - allow secret to be kept on user model.
1 parent 2ec4fc7 commit 23e4a82

File tree

6 files changed

+102
-7
lines changed

6 files changed

+102
-7
lines changed

docs/index.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ JWT_AUTH = {
163163
'rest_framework_jwt.utils.jwt_response_payload_handler',
164164

165165
'JWT_SECRET_KEY': settings.SECRET_KEY,
166+
'JWT_GET_USER_SECRET_KEY': None,
166167
'JWT_PUBLIC_KEY': None,
167168
'JWT_PRIVATE_KEY': None,
168169
'JWT_ALGORITHM': 'HS256',
@@ -177,6 +178,7 @@ JWT_AUTH = {
177178
'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7),
178179

179180
'JWT_AUTH_HEADER_PREFIX': 'JWT',
181+
'JWT_AUTH_USER_MODEL': settings.AUTH_USER_MODEL,
180182
}
181183
```
182184
This packages uses the JSON Web Token Python implementation, [PyJWT](https://github.com/jpadilla/pyjwt) and allows to modify some of it's available options.
@@ -186,6 +188,12 @@ This is the secret key used to sign the JWT. Make sure this is safe and not shar
186188

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

191+
### JWT_GET_USER_SECRET_KEY
192+
This is more robust version of JWT_SECRET_KEY. It is defined per User, so in case token is compromised it can be
193+
easily changed by owner. Changing this value will make all tokens for given user unusable. Value should be a function, accepting user as only parameter and returning it's secret key.
194+
195+
Default is `None`.
196+
189197
### JWT_PUBLIC_KEY
190198
This is an object of type `cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`. It will be used to verify the signature of the incoming JWT. 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`.
191199

@@ -276,6 +284,11 @@ Another common value used for tokens and Authorization headers is `Bearer`.
276284

277285
Default is `JWT`.
278286

287+
### JWT_AUTH_USER_MODEL
288+
This points the app to default user model. It is used in conjuction with 'JWT_GET_USER_SECRET_KEY'. In most cases default value should be enough.
289+
290+
Default is `settings.AUTH_USER_MODEL`.
291+
279292
## Extending `JSONWebTokenAuthentication`
280293

281294
Right now `JSONWebTokenAuthentication` assumes that the JWT will come in the header. The JWT spec does not require this (see: [Making a service Call](https://developer.atlassian.com/static/connect/docs/concepts/authentication.html)). For example, the JWT may come in the querystring. The ability to send the JWT in the querystring is needed in cases where the user cannot set the header (for example the src element in HTML).

rest_framework_jwt/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
'rest_framework_jwt.utils.jwt_response_payload_handler',
3333

3434
'JWT_SECRET_KEY': settings.SECRET_KEY,
35+
'JWT_GET_USER_SECRET_KEY': None,
3536
'JWT_ALGORITHM': 'HS256',
3637
'JWT_VERIFY': True,
3738
'JWT_VERIFY_EXPIRATION': True,
@@ -44,6 +45,7 @@
4445
'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7),
4546

4647
'JWT_AUTH_HEADER_PREFIX': 'JWT',
48+
'JWT_AUTH_USER_MODEL': settings.AUTH_USER_MODEL,
4749
}
4850

4951
# List of settings that may be in string import notation.
@@ -54,6 +56,7 @@
5456
'JWT_PAYLOAD_GET_USER_ID_HANDLER',
5557
'JWT_PAYLOAD_GET_USERNAME_HANDLER',
5658
'JWT_RESPONSE_PAYLOAD_HANDLER',
59+
'JWT_GET_USER_SECRET_KEY',
5760
)
5861

5962
api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS)

rest_framework_jwt/utils.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,39 @@
11
import jwt
22
import uuid
33
import warnings
4+
5+
from six import string_types
6+
7+
from django.db.models.loading import get_model
8+
49
from calendar import timegm
510
from datetime import datetime
611

7-
from rest_framework_jwt.compat import get_username, get_username_field
12+
from rest_framework_jwt.compat import get_username
13+
from rest_framework_jwt.compat import get_username_field
814
from rest_framework_jwt.settings import api_settings
915

1016

17+
def jwt_get_secret_key(user_id=None):
18+
"""
19+
For enchanced security you may use secret key on user itself.
20+
This way you have an option to logout only this user if:
21+
- token is compromised
22+
- password is changed
23+
- etc.
24+
"""
25+
if api_settings.JWT_GET_USER_SECRET_KEY:
26+
if isinstance(api_settings.JWT_AUTH_USER_MODEL, string_types):
27+
parts = api_settings.JWT_AUTH_USER_MODEL.rsplit('.', 1)
28+
Account = get_model(parts[0], parts[1])
29+
else:
30+
Account = api_settings.JWT_AUTH_USER_MODEL
31+
user = Account.objects.get(pk=user_id)
32+
key = str(api_settings.JWT_GET_USER_SECRET_KEY(user))
33+
return key
34+
return api_settings.JWT_SECRET_KEY
35+
36+
1137
def jwt_payload_handler(user):
1238
username_field = get_username_field()
1339
username = get_username(user)
@@ -66,9 +92,10 @@ def jwt_get_username_from_payload_handler(payload):
6692

6793

6894
def jwt_encode_handler(payload):
95+
key = api_settings.JWT_PRIVATE_KEY or jwt_get_secret_key(payload.get('user_id'))
6996
return jwt.encode(
7097
payload,
71-
api_settings.JWT_PRIVATE_KEY or api_settings.JWT_SECRET_KEY,
98+
key,
7299
api_settings.JWT_ALGORITHM
73100
).decode('utf-8')
74101

@@ -77,10 +104,12 @@ def jwt_decode_handler(token):
77104
options = {
78105
'verify_exp': api_settings.JWT_VERIFY_EXPIRATION,
79106
}
80-
107+
# get user from token, BEFORE verification, to get user secret key
108+
unverified_payload = jwt.decode(token, None, False)
109+
secret_key = jwt_get_secret_key(unverified_payload.get('user_id'))
81110
return jwt.decode(
82111
token,
83-
api_settings.JWT_PUBLIC_KEY or api_settings.JWT_SECRET_KEY,
112+
api_settings.JWT_PUBLIC_KEY or secret_key,
84113
api_settings.JWT_VERIFY,
85114
options=options,
86115
leeway=api_settings.JWT_LEEWAY,

tests/models.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import uuid
2+
3+
from django.contrib.auth.models import AbstractBaseUser
4+
from django.contrib.auth.models import BaseUserManager
25
from django.db import models
3-
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
46

57

68
class CustomUser(AbstractBaseUser):
79
email = models.EmailField(max_length=255, unique=True)
10+
jwt_secret = models.UUIDField(
11+
'Token secret',
12+
help_text='Changing this will log out user everywhere',
13+
default=uuid.uuid4)
814

915
objects = BaseUserManager()
1016

tests/test_authentication.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import unittest
2+
import uuid
3+
4+
from .models import CustomUser
5+
6+
from .utils import get_jwt_secret
7+
from django.test.utils import override_settings
28

39
from django.test import TestCase
410
from rest_framework import status
@@ -19,11 +25,13 @@
1925
# because models have not been initialized.
2026
oauth2_provider = None
2127

22-
from rest_framework.test import APIRequestFactory, APIClient
28+
from rest_framework.test import APIClient
29+
from rest_framework.test import APIRequestFactory
2330

2431
from rest_framework_jwt import utils
2532
from rest_framework_jwt.compat import get_user_model
26-
from rest_framework_jwt.settings import api_settings, DEFAULTS
33+
from rest_framework_jwt.settings import DEFAULTS
34+
from rest_framework_jwt.settings import api_settings
2735

2836
User = get_user_model()
2937

@@ -137,6 +145,38 @@ def test_post_expired_token_failing_jwt_auth(self):
137145
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
138146
self.assertEqual(response['WWW-Authenticate'], 'JWT realm="api"')
139147

148+
@override_settings(AUTH_USER_MODEL='tests.CustomUser')
149+
def test_post_form_failing_jwt_auth_changed_user_secret_key(self):
150+
"""
151+
Ensure changin secret key on USER level makes tokens invalid
152+
"""
153+
# fine tune settings
154+
api_settings.JWT_AUTH_USER_MODEL = CustomUser
155+
api_settings.JWT_GET_USER_SECRET_KEY = get_jwt_secret
156+
157+
tmp_user = CustomUser.objects.create(email='[email protected]')
158+
payload = utils.jwt_payload_handler(tmp_user)
159+
token = utils.jwt_encode_handler(payload)
160+
161+
auth = 'JWT {0}'.format(token)
162+
response = self.csrf_client.post(
163+
'/jwt/', {'example': 'example'}, HTTP_AUTHORIZATION=auth, format='json')
164+
165+
self.assertEqual(response.status_code, status.HTTP_200_OK)
166+
167+
# change token, verify
168+
tmp_user.jwt_secret = uuid.uuid4()
169+
tmp_user.save()
170+
171+
response = self.csrf_client.post(
172+
'/jwt/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
173+
174+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
175+
176+
#revert api settings
177+
api_settings.JWT_AUTH_USER_MODEL = DEFAULTS['JWT_AUTH_USER_MODEL']
178+
api_settings.JWT_GET_USER_SECRET_KEY = DEFAULTS['JWT_GET_USER_SECRET_KEY']
179+
140180
def test_post_invalid_token_failing_jwt_auth(self):
141181
"""
142182
Ensure POSTing over JWT auth with invalid token fails

tests/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,7 @@ def jwt_response_payload_handler(token, user=None, request=None):
2020
'user': get_username(user),
2121
'token': token
2222
}
23+
24+
25+
def get_jwt_secret(user):
26+
return user.jwt_secret

0 commit comments

Comments
 (0)