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 Refresh Tokens #327

Closed
wants to merge 16 commits 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
4 changes: 3 additions & 1 deletion docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ from yourapp.api.views import LoginView

urlpatterns = [
path(r'login/', LoginView.as_view(), name='knox_login'),
path(r'refresh/',knox_views.RefreshTokenView.as_view(),name='knox_refresh'),
path(r'logout/', knox_views.LogoutView.as_view(), name='knox_logout'),
path(r'logoutall/', knox_views.LogoutAllView.as_view(), name='knox_logoutall'),
]
Expand Down Expand Up @@ -100,7 +101,8 @@ from yourapp.api.views import LoginView

urlpatterns = [
path(r'login/', LoginView.as_view(), name='knox_login'),
path(r'refresh/',knox_views.RefreshTokenView.as_view(),name='knox_refresh'),
path(r'logout/', knox_views.LogoutView.as_view(), name='knox_logout'),
path(r'logoutall/', knox_views.LogoutAllView.as_view(), name='knox_logoutall'),
]
```
```
12 changes: 12 additions & 0 deletions docs/refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# AuthRefreshToken
Knox provides an option to [enable](settings.md#enable_refresh_token) refresh tokens which can be used as proof of ownership when your auth `token` expires.

When enabled `knox.LoginView` issues a `refresh_token` along with the regular auth `token`, whenever your auth `token` expires,
that `refresh_token` can later be used to issue new valid auth `token` by the `knox.views.RefreshTokenView`.
Tokens are rotated and kept track of, whenever an old `refresh_token` is used in an attempt to get a new auth `token`, if it
is within the `MAX_TOKEN_HISTORY` limit, the whole family of that `refresh_token` is invalidated.
The tracking is done by `knox.RefreshFamily` model where each `refresh_token` issued by `knox.LoginView` is a parent of the subseuent
`token` and `refresh_token` pairs issued by `knox.RefreshTokenView`.



45 changes: 45 additions & 0 deletions docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ REST_KNOX = {
'AUTH_HEADER_PREFIX': 'Token',
'EXPIRY_DATETIME_FORMAT': api_settings.DATETIME_FORMAT,
'TOKEN_MODEL': 'knox.AuthToken',

#if you want to use refresh tokens
'ENABLE_REFRESH_TOKEN': False,
'REFRESH_TOKEN_MODEL': getattr(settings, 'KNOX_REFRESH_TOKEN_MODEL', 'knox.AuthRefreshToken'),
'REFRESH_FAMILY_MODEL': getattr(settings, 'KNOX_REFRESH_FAMILY_MODEL', 'knox.RefreshFamily'),
"REFRESH_TOKEN_TTL" : timedelta(days=30),
"MIN_REFRESH_TOKEN_ISSUE_INTERVAL": timedelta(hours=10),
'MAX_TOKEN_HISTORY': 10
}
#...snip...
```
Expand Down Expand Up @@ -65,10 +73,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.

!!! note
RefreshToken also inherits this property as issuance of `token` and `refresh_token`
always happens together.

## TOKEN_LIMIT_PER_USER
This allows you to control how many valid tokens can be issued per user.
If the limit for valid tokens is reached, an error is returned at login.
By default this option is disabled and set to `None` -- thus no limit.
This setting is shared by RefreshToken if enabled.

## USER_SERIALIZER
This is the reference to the class used to serialize the `User` objects when
Expand All @@ -95,13 +108,45 @@ This is the reference to the model used as `AuthToken`. We can define a custom `
model in our project that extends `knox.AbstractAuthToken` and add our business logic to it.
The default is `knox.AuthToken`


[DATETIME_FORMAT]: https://www.django-rest-framework.org/api-guide/settings/#date-and-time-formatting
[strftime format]: https://docs.python.org/3/library/time.html#time.strftime

## TOKEN_PREFIX
This is the prefix for the generated token that is used in the Authorization header. The default is just an empty string.
It can be up to `CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH` long.

!!! note
These settings are only relevent if you have [refresh tokens](refresh.md) enabled.

## ENABLE_REFRESH_TOKEN
This enables refresh tokens if set to `True` which can be used to issue new auth tokens instead of having to log in manually
each time an auth `token` expires.

## REFRESH_TOKEN_TTL
This is the same as TOKEN_TTL with the exception that refresh tokens are usually valid for a longer timespan.
The default is set to `timedelta(days=30)`.

## REFRESH_TOKEN_MODEL
This is the reference to the model used as `AuthRefreshToken`. We can define a custom `AuthRefreshToken`
model in our project that extends `knox.AbstractAuthRefreshToken` and add our business logic to it.
The default is `knox.AuthRefreshToken`

## REFRESH_FAMILY_MODEL
This is the reference to the model used as `RefreshFamily`. We can define a custom `RefreshFamily`
model in our project that extends `knox.AbstractRefreshFamily` and add our business logic to it.
The default is `knox.RefreshFamily`

## MIN_REFRESH_TOKEN_ISSUE_INTERVAL
This defines the minimum time interval between issuing consecutive refresh tokens for users.
The default is `timedelta(hours=10)`.

## MAX_TOKEN_HISTORY
The maximum number of refresh tokens to keep track of with the parent token.
If a parent token has more than `MAX_TOKEN_HISTORY` associated refresh tokens, using any
token other than the latest one invalidates the entire family of refresh tokens.


# Constants `knox.settings`
Knox also provides some constants for information. These must not be changed in
external code; they are used in the model definitions in knox and an error will
Expand Down
2 changes: 2 additions & 0 deletions docs/urls.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ as the reference to the `User` model will cause the app to fail at import time.
The views would then accessible as:

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

they can also be looked up by name:

```python
reverse('knox_login')
reverse('knox_refresh')
reverse('knox_logout')
reverse('knox_logoutall')
```
26 changes: 26 additions & 0 deletions docs/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ helper methods:
- `get_expiry_datetime_format(self)`, to change the datetime format used for expiry
- `format_expiry_datetime(self, expiry)`, to format the expiry `datetime` object at your convenience
- `create_token(self)`, to create the `AuthToken` instance at your convenience
!!! note
These methods are only relevant if you have [refresh tokens](refresh.md) enabled.
- `get_refresh_token_ttl(self)`, to change the refresh token ttl
- `create_refresh_token(self)`, to create the `AuthRefreshToken` instance at your convenience
- `create_refresh_family(self, parent, refresh_token, token)`, to create the `RefreshFamily` instance at your convenience
- `add_refresh_token(self, data, token)`, to add `refresh_token` and `refresh_token_expiry` to the `LoginView` response data


Finally, if none of these helper methods are sufficient, you can also override `get_post_response_data`
to return a fully customized payload.
Expand All @@ -40,6 +47,8 @@ to return a fully customized payload.
request.user,
context=self.get_context()
).data
if knox_settings.ENABLE_REFRESH_TOKEN:
return self.add_refresh_token(self,data,token)
return data
...snip...
```
Expand All @@ -61,6 +70,23 @@ class can be used inside `REST_KNOX` settings by adding `knox.serializers.UserSe
Obviously, if your app uses a custom user model that does not have these fields,
a custom serializer must be used.

!!! note
This view is only relevant if you have [refresh tokens](refresh.md) enabled.


## RefreshTokenView
This view accepts a post request with `refresh_token` value passed in its body.

The RefreshTokenView accepts `TokenAuthentication` as the only authentication class as
this view is only relevant if you are using token authentication.

The RefreshTokenView accepts any post request and the passed `refresh_token` token value
is used as the authentication credential that is validated the same way as an authentication token.


RefreshTokenView inherits from `LoginView` and shares all the basic behaviour with it.


## LogoutView
This view accepts only a post request with an empty body.
It responds to Knox Token Authentication. On a successful request,
Expand Down
9 changes: 7 additions & 2 deletions knox/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@

@admin.register(models.AuthToken)
class AuthTokenAdmin(admin.ModelAdmin):
list_display = ('digest', 'user', 'created', 'expiry',)
list_display = (
"digest",
"user",
"created",
"expiry",
)
fields = ()
raw_id_fields = ('user',)
raw_id_fields = ("user",)
101 changes: 82 additions & 19 deletions knox/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
)

from knox.crypto import hash_token
from knox.models import get_token_model
from knox.models import (
get_refresh_family_model, get_refresh_token_model, get_token_model,
)
from knox.settings import CONSTANTS, knox_settings
from knox.signals import token_expired
from knox.signals import refresh_token_expired, token_expired


class TokenAuthentication(BaseAuthentication):
'''
"""
This authentication scheme uses Knox AuthTokens for authentication.

Similar to DRF's TokenAuthentication, it overrides a large amount of that
Expand All @@ -25,7 +27,7 @@ class TokenAuthentication(BaseAuthentication):
If successful
- `request.user` will be a django `User` instance
- `request.auth` will be an `AuthToken` instance
'''
"""

def authenticate(self, request):
auth = get_authorization_header(request).split()
Expand All @@ -37,30 +39,29 @@ def authenticate(self, request):
# Authorization header is possibly for another backend
return None
if len(auth) == 1:
msg = _('Invalid token header. No credentials provided.')
msg = _("Invalid token header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid token header. '
'Token string should not contain spaces.')
msg = _("Invalid token header. " "Token string should not contain spaces.")
raise exceptions.AuthenticationFailed(msg)

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

def authenticate_credentials(self, token):
'''
"""
Due to the random nature of hashing a value, this must inspect
each auth_token individually to find the correct one.

Tokens that have expired will be deleted and skipped
'''
msg = _('Invalid token.')
"""
msg = _("Invalid token.")
token = token.decode("utf-8")
for auth_token in get_token_model().objects.filter(
token_key=token[:CONSTANTS.TOKEN_KEY_LENGTH]):
token_key=token[: CONSTANTS.TOKEN_KEY_LENGTH]
):
if self._cleanup_token(auth_token):
continue

try:
digest = hash_token(token)
except (TypeError, binascii.Error):
Expand All @@ -71,37 +72,99 @@ def authenticate_credentials(self, token):
return self.validate_user(auth_token)
raise exceptions.AuthenticationFailed(msg)

def authenticate_refresh_token(self, token):
"""
Due to the random nature of hashing a value, this must inspect
each refresh_token individually to find the correct one.

Tokens that have expired will be deleted and skipped
"""
msg = _("Invalid token.")
if isinstance(token, bytes):
token = token.decode("utf-8")
for refresh_token in get_refresh_token_model().objects.filter(
token_key=token[: CONSTANTS.TOKEN_KEY_LENGTH]
):
if self._cleanup_token(refresh_token):
continue
try:
digest = hash_token(token)
except (TypeError, binascii.Error):
raise exceptions.AuthenticationFailed(msg)
if compare_digest(digest, refresh_token.digest):
return self.validate_user(refresh_token)
raise exceptions.AuthenticationFailed(msg)

def renew_token(self, auth_token) -> None:
current_expiry = auth_token.expiry
new_expiry = timezone.now() + knox_settings.TOKEN_TTL
auth_token.expiry = new_expiry
# Throttle refreshing of token to avoid db writes
delta = (new_expiry - current_expiry).total_seconds()
if delta > knox_settings.MIN_REFRESH_INTERVAL:
auth_token.save(update_fields=('expiry',))
auth_token.save(update_fields=("expiry",))

def validate_user(self, auth_token):
if not auth_token.user.is_active:
raise exceptions.AuthenticationFailed(
_('User inactive or deleted.'))
raise exceptions.AuthenticationFailed(_("User inactive or deleted."))
return (auth_token.user, auth_token)

def authenticate_header(self, request):
return knox_settings.AUTH_HEADER_PREFIX

def _cleanup_token(self, auth_token) -> bool:
"""
This works for both classes as it would only skip either a token
or a refresh token instance depending on what the argument was
"""
for other_token in auth_token.user.auth_token_set.all():
if other_token.digest != auth_token.digest and other_token.expiry:
if other_token.expiry < timezone.now():
other_token.delete()
username = other_token.user.get_username()
token_expired.send(sender=self.__class__,
username=username, source="other_token")
token_expired.send(
sender=self.__class__, username=username, source="other_token"
)

if knox_settings.ENABLE_REFRESH_TOKEN:
for other_token in auth_token.user.refresh_token_set.all():
if other_token.digest != auth_token.digest and other_token.expiry:
if other_token.expiry < timezone.now():
refresh_family_model = get_refresh_family_model()
parent = (
refresh_family_model.objects.filter(
refresh_token=other_token.token_key
)
.first()
.parent
)
family = refresh_family_model.objects.filter(
parent=parent
).order_by("-created")
family.delete()
other_token.delete()
username = other_token.user.get_username()
refresh_token_expired.send(
sender=self.__class__,
username=username,
source="other_token",
)

if auth_token.expiry is not None:
if auth_token.expiry < timezone.now():
username = auth_token.user.get_username()

if knox_settings.ENABLE_REFRESH_TOKEN and isinstance(
auth_token, get_refresh_token_model()
):
auth_token.delete()
refresh_token_expired.send(
sender=self.__class__, username=username, source="refresh_token"
)
return True
auth_token.delete()
token_expired.send(sender=self.__class__,
username=username, source="auth_token")
token_expired.send(
sender=self.__class__, username=username, source="auth_token"
)
return True
return False
2 changes: 1 addition & 1 deletion knox/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def make_hex_compatible(token: str) -> bytes:
We need to make sure that the token, that is send is hex-compatible.
When a token prefix is used, we cannot guarantee that.
"""
return binascii.unhexlify(binascii.hexlify(bytes(token, 'utf-8')))
return binascii.unhexlify(binascii.hexlify(bytes(token, "utf-8")))


def hash_token(token: str) -> str:
Expand Down
Loading