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

fixes #156 -- provide token refresh endpoint #158

Closed
wants to merge 1 commit into from
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ to fit these new changes.

- `AuthToken` model field has been changed from `expires` to `expiry`
- Successful login now always returns a `expiry` field for when the token expires
- New endpoint allows refreshing a token to increase its expiration date. See the documentation for more information
- Reverse url for `LogoutAllView` has been renamed to `knox_logout_all` and the url changed to `/api/auth/logout/all`

3.6.0
=====
Expand Down
3 changes: 2 additions & 1 deletion docs/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ Depending on your usage you might have to adjust your code
to fit these new changes.

- `AuthToken` model field has been changed from `expires` to `expiry`
- Successful login now always returns a `expiry` field for when the token expires
- New endpoint allows refreshing a token to increase its expiration date. See the documentation for more information
- Reverse url for `LogoutAllView` has been renamed to `knox_logout_all` and the url changed to `/api/auth/logout/all`

## 3.6.0

Expand Down
10 changes: 10 additions & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ REST_KNOX = {
'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512',
'AUTH_TOKEN_CHARACTER_LENGTH': 64,
'TOKEN_TTL': timedelta(hours=10),
'ENABLE_REFRESH_ENDPOINT': False,
'USER_SERIALIZER': 'knox.serializers.UserSerializer',
'TOKEN_LIMIT_PER_USER': None,
'AUTO_REFRESH': FALSE,
Expand Down Expand Up @@ -55,6 +56,15 @@ Setting the TOKEN_TTL to `None` will create tokens that never expire.
Warning: setting a 0 or negative timedelta will create tokens that instantly expire,
the system will not prevent you setting this.

## ENABLE_REFRESH_ENDPOINT
Set this setting to `True` to allow users to extend the life of a token. However this endpoint is disabled by default.

Send an authenticated `POST` request to increase token with the amount
set for `TOKEN_TTL`.

**Warning**: This setting depends on `TOKEN_TTL` being set to not `None`.
Otherwise it will raise a `ValueError` which in turn will cause an internal server error (500).

## TOKEN_LIMIT_PER_USER
This allows you to control how many tokens can be issued per user.
By default this option is disabled and set to `None` -- thus no limit.
Expand Down
8 changes: 5 additions & 3 deletions docs/urls.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#URLS `knox.urls`
# URLS `knox.urls`
Knox provides a url config ready with its three default views routed.

This can easily be included in your url config:
Expand All @@ -17,12 +17,14 @@ The views would then acessible as:

- `/api/auth/login` -> `LoginView`
- `/api/auth/logout` -> `LogoutView`
- `/api/auth/logoutall` -> `LogoutAllView`
- `/api/auth/logout/all` -> `LogoutAllView`
- `/api/auth/refresh` -> `TokenRefreshView`

they can also be looked up by name:

```python
reverse('knox_login')
reverse('knox_logout')
reverse('knox_logoutall')
reverse('knox_logout_all')
reverse('knox_refresh')
```
8 changes: 8 additions & 0 deletions docs/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,11 @@ system and can no longer be used to authenticate.
**Note** It is not recommended to alter the Logout views. They are designed
specifically for token management, and to respond to Knox authentication.
Modified forms of the class may cause unpredictable results.

## TokenRefreshView
This view accepts only a post request with an empty body.
On a successful request, the token used to authenticate will be refreshed and its expiry will be extended by the amount set in `TOKEN_TTL`.

The response body includes an expiry key with a timestamp that represents the token's new expiry.

**Note**: To enable this view set `ENABLE_REFRESH_ENDPOINT` to `True`.
10 changes: 7 additions & 3 deletions knox/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ class TokenAuthentication(BaseAuthentication):
model = AuthToken

def authenticate(self, request):

# view = getattr(request, 'parser_context', None)['view']
view = request.parser_context['view']
auth = get_authorization_header(request).split()
prefix = knox_settings.AUTH_HEADER_PREFIX.encode()

Expand All @@ -50,10 +53,10 @@ def authenticate(self, request):
'Token string should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)

user, auth_token = self.authenticate_credentials(auth[1])
user, auth_token = self.authenticate_credentials(auth[1], view)
return (user, auth_token)

def authenticate_credentials(self, token):
def authenticate_credentials(self, token, view):
'''
Due to the random nature of hashing a salted value, this must inspect
each auth_token individually to find the correct one.
Expand All @@ -72,7 +75,8 @@ def authenticate_credentials(self, token):
except (TypeError, binascii.Error):
raise exceptions.AuthenticationFailed(msg)
if compare_digest(digest, auth_token.digest):
if knox_settings.AUTO_REFRESH and auth_token.expiry:
auto_refresh = getattr(view, 'auto_refresh', knox_settings.AUTO_REFRESH)
if auto_refresh and auth_token.expiry:
self.renew_token(auth_token)
return self.validate_user(auth_token)
raise exceptions.AuthenticationFailed(msg)
Expand Down
1 change: 1 addition & 0 deletions knox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
DEFAULTS = {
'SECURE_HASH_ALGORITHM': 'cryptography.hazmat.primitives.hashes.SHA512',
'AUTH_TOKEN_CHARACTER_LENGTH': 64,
'ENABLE_REFRESH_ENDPOINT': False,
'TOKEN_TTL': timedelta(hours=10),
'USER_SERIALIZER': None,
'TOKEN_LIMIT_PER_USER': None,
Expand Down
5 changes: 3 additions & 2 deletions knox/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

urlpatterns = [
url(r'login/', views.LoginView.as_view(), name='knox_login'),
url(r'logout/', views.LogoutView.as_view(), name='knox_logout'),
url(r'logoutall/', views.LogoutAllView.as_view(), name='knox_logoutall'),
url(r'logout/$', views.LogoutView.as_view(), name='knox_logout'),
url(r'logout/all/$', views.LogoutAllView.as_view(), name='knox_logout_all'),
url(r'refresh/$', views.TokenRefreshView.as_view(), name='knox_refresh')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about the refresh... I would like token/refresh better I think.
@belugame thoughts?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm for me depends on the outcome of our discussion :) creating a new one for me would not be a "refresh". If we keep the same token I would prefer the verb "extend". "extend" I feel would fit nicely in with the others logout, login

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍

]
27 changes: 27 additions & 0 deletions knox/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,30 @@ def post(self, request, format=None):
user_logged_out.send(sender=request.user.__class__,
request=request, user=request.user)
return Response(None, status=status.HTTP_204_NO_CONTENT)


class TokenRefreshView(APIView):

authentication_classes = (TokenAuthentication,)
permission_classes = (IsAuthenticated,)
auto_refresh = False

def post(self, request, format=None):

if not knox_settings.ENABLE_REFRESH_ENDPOINT:
return Response(
status=status.HTTP_403_FORBIDDEN
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe adding a message here would be nice:

return Response(
    data={"error": "This view is not enabled"},
    status=status.HTTP_403_FORBIDDEN,
)

Not sure about the data returned's format though...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I agree -- I dont think configuration specific messages for the library should be exposed to the users here, no value to enduser imo. Are you sure?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do agree that exposing config related stuff is not a good practice.
Also if I'd get a 403 from a view I'd start looking around first into my authentication / permission system not at my config...
I was trying to reduce a bit of the indirection here - not gonna block or anything - just wanted to make it easier to reason about.

)

if knox_settings.TOKEN_TTL is None:
raise ValueError(
'Value of \'TOKEN_TTL\' cannot be \'None\' in this context.'
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will trigger a 500, I think we should make a note about the error code in the doc.

Also I would rather make this check at application loading time so it blows when you try to run the server and not when someone tries to hit the view... I'm not sure how to achieve that easily.

I'm not going to block if it stays as it is with a note in the doc though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error is documented here: https://github.com/James1345/django-rest-knox/pull/158/files#diff-1819b1daaccb3d358620ade9c67e9118R66

But I will add a note saying it will cause a HTTP 500 if improperly configured. But I agree, I'd rather have it fail immediatly at runtime rather than when a user tries to refresh, but am unaware of how.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me, we can investigate settings checking at runtime later on.


request.auth.expiry = timezone.now() + knox_settings.TOKEN_TTL
request.auth.save()

return Response(
sphrak marked this conversation as resolved.
Show resolved Hide resolved
{'expiry': request.auth.expiry},
status=status.HTTP_200_OK
)
102 changes: 100 additions & 2 deletions tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import base64
from datetime import datetime, timedelta

import pytz
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.utils.six.moves import reload_module
Expand Down Expand Up @@ -45,6 +46,13 @@ def get_basic_auth_header(username, password):
token_no_expiration_knox = knox_settings.defaults.copy()
token_no_expiration_knox["TOKEN_TTL"] = None

refresh_endpoint_knox = knox_settings.defaults.copy()
refresh_endpoint_knox["ENABLE_REFRESH_ENDPOINT"] = True

token_ttl_is_none_knox = knox_settings.defaults.copy()
token_ttl_is_none_knox["ENABLE_REFRESH_ENDPOINT"] = True
token_ttl_is_none_knox["TOKEN_TTL"] = None


class AuthTestCase(TestCase):

Expand Down Expand Up @@ -119,7 +127,7 @@ def test_logout_all_deletes_keys(self):
instance, token = AuthToken.objects.create(user=self.user)
self.assertEqual(AuthToken.objects.count(), 10)

url = reverse('knox_logoutall')
url = reverse('knox_logout_all')
self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token))
self.client.post(url, {}, format='json')
self.assertEqual(AuthToken.objects.count(), 0)
Expand All @@ -131,7 +139,7 @@ def test_logout_all_deletes_only_targets_keys(self):
AuthToken.objects.create(user=self.user2)
self.assertEqual(AuthToken.objects.count(), 20)

url = reverse('knox_logoutall')
url = reverse('knox_logout_all')
self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token))
self.client.post(url, {}, format='json')
self.assertEqual(AuthToken.objects.count(), 10,
Expand Down Expand Up @@ -366,3 +374,93 @@ def test_expiry_is_present(self):
response.data['expiry'],
AuthToken.objects.first().expiry
)

def test_token_expiry_is_extended_via_refresh_endpoint(self):

ttl = knox_settings.TOKEN_TTL
original_time = datetime(2018, 7, 25, 0, 0, 0, 0, tzinfo=pytz.UTC)

with freeze_time(original_time):
instance, token = AuthToken.objects.create(user=self.user)

self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token))
five_hours_later = original_time + timedelta(hours=5)

with override_settings(REST_KNOX=refresh_endpoint_knox):
reload_module(views)
root_url = reverse('api-root')
url = reverse('knox_refresh')
with freeze_time(five_hours_later):
response = self.client.post(
url,
{},
format='json'
)
reload_module(views)

self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertIn(
'expiry',
response.data
)
expected_expiry = original_time + ttl + timedelta(hours=5)
self.assertEqual(
response.data['expiry'],
expected_expiry
)

new_expiry = AuthToken.objects.get().expiry
self.assertEqual(
new_expiry,
expected_expiry,
"Expiry time should have been extended to {} but is {}.".format(
expected_expiry,
new_expiry
)
)

# token works after original expiry:
after_original_expiry = original_time + ttl + timedelta(hours=1)
with freeze_time(after_original_expiry):
response = self.client.get(root_url, {}, format='json')
self.assertEqual(response.status_code, 200)

# token does not work after new expiry:
new_expiry = AuthToken.objects.get().expiry
with freeze_time(new_expiry + timedelta(seconds=1)):
response = self.client.get(root_url, {}, format='json')
self.assertEqual(response.status_code, 401)

def test_endpoint_not_enabled_403_forbidden(self):

sphrak marked this conversation as resolved.
Show resolved Hide resolved
instance, token = AuthToken.objects.create(user=self.user)

self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token))
url = reverse('knox_refresh')
response = self.client.post(
url,
{},
format='json'
)

self.assertEqual(response.status_code, 403)
self.assertEqual(response.data, None)

def test_endpoint_enabled_but_token_ttl_is_none(self):
"""raises valuerror"""

instance, token = AuthToken.objects.create(user=self.user)

self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token))

with override_settings(REST_KNOX=token_ttl_is_none_knox):
reload_module(views)
url = reverse('knox_refresh')
with self.assertRaises(ValueError):
self.client.post(
url,
{},
format='json'
)
reload_module(views)