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

Commit 3b1b15a

Browse files
authored
Merge pull request #310 from ArabellaTech/master
increased security - allow secret to be kept on user model.
2 parents b356a8c + 13ca172 commit 3b1b15a

File tree

6 files changed

+89
-9
lines changed

6 files changed

+89
-9
lines changed

docs/index.md

Lines changed: 8 additions & 2 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',
@@ -178,6 +179,7 @@ JWT_AUTH = {
178179

179180
'JWT_AUTH_HEADER_PREFIX': 'JWT',
180181
'JWT_AUTH_COOKIE': None,
182+
181183
}
182184
```
183185
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.
@@ -187,6 +189,12 @@ This is the secret key used to sign the JWT. Make sure this is safe and not shar
187189

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

192+
### JWT_GET_USER_SECRET_KEY
193+
This is more robust version of JWT_SECRET_KEY. It is defined per User, so in case token is compromised it can be
194+
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.
195+
196+
Default is `None`.
197+
190198
### JWT_PUBLIC_KEY
191199
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`.
192200

@@ -282,8 +290,6 @@ You can set this to a string if you want to use http cookies in addition to the
282290
The string you set here will be used as the cookie name that will be set in the response headers when requesting a token. The token validation
283291
procedure will also look into this cookie, if set. The 'Authorization' header takes precedence if both the header and the cookie are present in the request.
284292

285-
Another common value used for tokens and Authorization headers is `Bearer`.
286-
287293
Default is `None` and no cookie is set when creating tokens nor accepted when validating them.
288294

289295
## Extending `JSONWebTokenAuthentication`

rest_framework_jwt/settings.py

Lines changed: 2 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,
@@ -55,6 +56,7 @@
5556
'JWT_PAYLOAD_GET_USER_ID_HANDLER',
5657
'JWT_PAYLOAD_GET_USERNAME_HANDLER',
5758
'JWT_RESPONSE_PAYLOAD_HANDLER',
59+
'JWT_GET_USER_SECRET_KEY',
5860
)
5961

6062
api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS)

rest_framework_jwt/utils.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
11
import jwt
22
import uuid
33
import warnings
4+
5+
from django.contrib.auth import get_user_model
6+
47
from calendar import timegm
58
from datetime import datetime
69

7-
from rest_framework_jwt.compat import get_username, get_username_field
10+
from rest_framework_jwt.compat import get_username
11+
from rest_framework_jwt.compat import get_username_field
812
from rest_framework_jwt.settings import api_settings
913

1014

15+
def jwt_get_secret_key(payload=None):
16+
"""
17+
For enchanced security you may use secret key on user itself.
18+
19+
This way you have an option to logout only this user if:
20+
- token is compromised
21+
- password is changed
22+
- etc.
23+
"""
24+
if api_settings.JWT_GET_USER_SECRET_KEY:
25+
User = get_user_model() # noqa: N806
26+
user = User.objects.get(pk=payload.get('user_id'))
27+
key = str(api_settings.JWT_GET_USER_SECRET_KEY(user))
28+
return key
29+
return api_settings.JWT_SECRET_KEY
30+
31+
1132
def jwt_payload_handler(user):
1233
username_field = get_username_field()
1334
username = get_username(user)
@@ -66,9 +87,10 @@ def jwt_get_username_from_payload_handler(payload):
6687

6788

6889
def jwt_encode_handler(payload):
90+
key = api_settings.JWT_PRIVATE_KEY or jwt_get_secret_key(payload)
6991
return jwt.encode(
7092
payload,
71-
api_settings.JWT_PRIVATE_KEY or api_settings.JWT_SECRET_KEY,
93+
key,
7294
api_settings.JWT_ALGORITHM
7395
).decode('utf-8')
7496

@@ -77,10 +99,12 @@ def jwt_decode_handler(token):
7799
options = {
78100
'verify_exp': api_settings.JWT_VERIFY_EXPIRATION,
79101
}
80-
102+
# get user from token, BEFORE verification, to get user secret key
103+
unverified_payload = jwt.decode(token, None, False)
104+
secret_key = jwt_get_secret_key(unverified_payload)
81105
return jwt.decode(
82106
token,
83-
api_settings.JWT_PUBLIC_KEY or api_settings.JWT_SECRET_KEY,
107+
api_settings.JWT_PUBLIC_KEY or secret_key,
84108
api_settings.JWT_VERIFY,
85109
options=options,
86110
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: 40 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,36 @@ 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_GET_USER_SECRET_KEY = get_jwt_secret
155+
156+
tmp_user = CustomUser.objects.create(email='[email protected]')
157+
payload = utils.jwt_payload_handler(tmp_user)
158+
token = utils.jwt_encode_handler(payload)
159+
160+
auth = 'JWT {0}'.format(token)
161+
response = self.csrf_client.post(
162+
'/jwt/', {'example': 'example'}, HTTP_AUTHORIZATION=auth, format='json')
163+
164+
self.assertEqual(response.status_code, status.HTTP_200_OK)
165+
166+
# change token, verify
167+
tmp_user.jwt_secret = uuid.uuid4()
168+
tmp_user.save()
169+
170+
response = self.csrf_client.post(
171+
'/jwt/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
172+
173+
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
174+
175+
# revert api settings
176+
api_settings.JWT_GET_USER_SECRET_KEY = DEFAULTS['JWT_GET_USER_SECRET_KEY']
177+
140178
def test_post_invalid_token_failing_jwt_auth(self):
141179
"""
142180
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)