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

increased security - allow secret to be kept on user model. #310

Merged
merged 7 commits into from
Mar 21, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ JWT_AUTH = {
'rest_framework_jwt.utils.jwt_response_payload_handler',

'JWT_SECRET_KEY': settings.SECRET_KEY,
'JWT_GET_USER_SECRET_KEY': None,
'JWT_PUBLIC_KEY': None,
'JWT_PRIVATE_KEY': None,
'JWT_ALGORITHM': 'HS256',
Expand All @@ -178,6 +179,7 @@ JWT_AUTH = {

'JWT_AUTH_HEADER_PREFIX': 'JWT',
'JWT_AUTH_COOKIE': None,

}
```
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.
Expand All @@ -187,6 +189,12 @@ This is the secret key used to sign the JWT. Make sure this is safe and not shar

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

### JWT_GET_USER_SECRET_KEY
This is more robust version of JWT_SECRET_KEY. It is defined per User, so in case token is compromised it can be
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.

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. 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`.

Expand Down Expand Up @@ -282,8 +290,6 @@ You can set this to a string if you want to use http cookies in addition to the
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
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.

Another common value used for tokens and Authorization headers is `Bearer`.

Default is `None` and no cookie is set when creating tokens nor accepted when validating them.

## Extending `JSONWebTokenAuthentication`
Expand Down
2 changes: 2 additions & 0 deletions rest_framework_jwt/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
'rest_framework_jwt.utils.jwt_response_payload_handler',

'JWT_SECRET_KEY': settings.SECRET_KEY,
'JWT_GET_USER_SECRET_KEY': None,
'JWT_ALGORITHM': 'HS256',
'JWT_VERIFY': True,
'JWT_VERIFY_EXPIRATION': True,
Expand All @@ -55,6 +56,7 @@
'JWT_PAYLOAD_GET_USER_ID_HANDLER',
'JWT_PAYLOAD_GET_USERNAME_HANDLER',
'JWT_RESPONSE_PAYLOAD_HANDLER',
'JWT_GET_USER_SECRET_KEY',
)

api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS)
32 changes: 28 additions & 4 deletions rest_framework_jwt/utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
import jwt
import uuid
import warnings

from django.contrib.auth import get_user_model

from calendar import timegm
from datetime import datetime

from rest_framework_jwt.compat import get_username, get_username_field
from rest_framework_jwt.compat import get_username
from rest_framework_jwt.compat import get_username_field
from rest_framework_jwt.settings import api_settings


def jwt_get_secret_key(payload=None):
"""
For enchanced security you may use secret key on user itself.

This way you have an option to logout only this user if:
- token is compromised
- password is changed
- etc.
"""
if api_settings.JWT_GET_USER_SECRET_KEY:
User = get_user_model() # noqa: N806
user = User.objects.get(pk=payload.get('user_id'))
key = str(api_settings.JWT_GET_USER_SECRET_KEY(user))
return key
return api_settings.JWT_SECRET_KEY


def jwt_payload_handler(user):
username_field = get_username_field()
username = get_username(user)
Expand Down Expand Up @@ -66,9 +87,10 @@ def jwt_get_username_from_payload_handler(payload):


def jwt_encode_handler(payload):
key = api_settings.JWT_PRIVATE_KEY or jwt_get_secret_key(payload)
return jwt.encode(
payload,
api_settings.JWT_PRIVATE_KEY or api_settings.JWT_SECRET_KEY,
key,
api_settings.JWT_ALGORITHM
).decode('utf-8')

Expand All @@ -77,10 +99,12 @@ def jwt_decode_handler(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 api_settings.JWT_SECRET_KEY,
api_settings.JWT_PUBLIC_KEY or secret_key,
api_settings.JWT_VERIFY,
options=options,
leeway=api_settings.JWT_LEEWAY,
Expand Down
8 changes: 7 additions & 1 deletion tests/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import uuid

from django.contrib.auth.models import AbstractBaseUser
from django.contrib.auth.models import BaseUserManager
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager


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

objects = BaseUserManager()

Expand Down
42 changes: 40 additions & 2 deletions tests/test_authentication.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import unittest
import uuid

from .models import CustomUser

from .utils import get_jwt_secret
from django.test.utils import override_settings

from django.test import TestCase
from rest_framework import status
Expand All @@ -19,11 +25,13 @@
# because models have not been initialized.
oauth2_provider = None

from rest_framework.test import APIRequestFactory, APIClient
from rest_framework.test import APIClient
from rest_framework.test import APIRequestFactory

from rest_framework_jwt import utils
from rest_framework_jwt.compat import get_user_model
from rest_framework_jwt.settings import api_settings, DEFAULTS
from rest_framework_jwt.settings import DEFAULTS
from rest_framework_jwt.settings import api_settings

User = get_user_model()

Expand Down Expand Up @@ -137,6 +145,36 @@ def test_post_expired_token_failing_jwt_auth(self):
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertEqual(response['WWW-Authenticate'], 'JWT realm="api"')

@override_settings(AUTH_USER_MODEL='tests.CustomUser')
def test_post_form_failing_jwt_auth_changed_user_secret_key(self):
"""
Ensure changin secret key on USER level makes tokens invalid
"""
# fine tune settings
api_settings.JWT_GET_USER_SECRET_KEY = get_jwt_secret

tmp_user = CustomUser.objects.create(email='[email protected]')
payload = utils.jwt_payload_handler(tmp_user)
token = utils.jwt_encode_handler(payload)

auth = 'JWT {0}'.format(token)
response = self.csrf_client.post(
'/jwt/', {'example': 'example'}, HTTP_AUTHORIZATION=auth, format='json')

self.assertEqual(response.status_code, status.HTTP_200_OK)

# change token, verify
tmp_user.jwt_secret = uuid.uuid4()
tmp_user.save()

response = self.csrf_client.post(
'/jwt/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)

self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

# revert api settings
api_settings.JWT_GET_USER_SECRET_KEY = DEFAULTS['JWT_GET_USER_SECRET_KEY']

def test_post_invalid_token_failing_jwt_auth(self):
"""
Ensure POSTing over JWT auth with invalid token fails
Expand Down
4 changes: 4 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ def jwt_response_payload_handler(token, user=None, request=None):
'user': get_username(user),
'token': token
}


def get_jwt_secret(user):
return user.jwt_secret