Skip to main content

JWT-backed Django app for managing querystring tokens.

Project description

https://badge.fury.io/py/django-request-token.svg https://travis-ci.org/yunojuno/django-request-token.svg

Supported versions

As of v0.10 this project supports Django 2.2 and above, and Python 3.7/3.8 only on the master branch. For previous verions see the relevant tag / branch.

Django Request Token

Django app that uses JWT to manage one-time and expiring tokens to protect URLs.

This app currently requires the use of PostgreSQL.

Compatibility

This library is now Python3 and Django1.11 and above only.

Background

This project was borne out of our experiences at YunoJuno with ‘expiring links’ - which is a common use case of providing users with a URL that performs a single action, and may bypass standard authentication. A well-known use of of this is the ubiquitous ‘unsubscribe’ link you find at the bottom of newsletters. You click on the link and it immediately unsubscribes you, irrespective of whether you are already authenticated or not.

If you google “temporary url”, “one-time link” or something similar you will find lots of StackOverflow articles on supporting this in Django - it’s pretty obvious, you have a dedicated token url, and you store the tokens in a model - when they are used you expire the token, and it can’t be used again. This works well, but it falls down in a number of areas:

  • Hard to support multiple endpoints (views)

If you want to support the same functionality (expiring links) for more than one view in your project, you either need to have multiple models and token handlers, or you need to store the specific view function and args in the model; neither of these is ideal.

  • Hard to debug

If you use have a single token url view that proxies view functions, you need to store the function name, args and it then becomes hard to support - when someone claims that they clicked on example.com/t/<token>, you can’t tell what that would resolve to without looking it up in the database - which doesn’t work for customer support.

  • Hard to support multiple scenarios

Some links expire, others have usage quotas - some have both. Links may be for use by a single user, or multiple users.

This project is intended to provide an easy-to-support mechanism for ‘tokenising’ URLs without having to proxy view functions - you can build well-formed Django URLs and views, and then add request token support afterwards.

Use Cases

This project supports three core use cases, each of which is modelled using the login_mode attribute of a request token:

  1. Public link with payload

  2. Single authenticated request

  3. Auto-login

Public Link (RequestToken.LOGIN_MODE_NONE)

In this mode (the default for a new token), there is no authentication, and no assigned user. The token is used as a mechanism for attaching a payload to the link. An example of this might be a custom registration or affiliate link, that renders the standard template with additional information extracted from the token - e.g. the name of the affiliate, or the person who invited you to register.

# a token that can be used to access a public url, without authenticating
# as a user, but carrying a payload (affiliate_id).
token = RequestToken.objects.create_token(
    scope="foo",
    login_mode=RequestToken.LOGIN_MODE_NONE,
    data={
        'affiliate_id': 1
    }
)

...

@use_request_token(scope="foo")
function view_func(request):
    # extract the affiliate id from an token _if_ one is supplied
    affiliate_id = (
        request.token.data['affiliate_id']
        if hasattr(request, 'token')
        else None
    )

Single Request (RequestToken.LOGIN_MODE_REQUEST)

In Request mode, the request.user property is overridden by the user specified in the token, but only for a single request. This is useful for responding to a single action (e.g. RSVP, unsubscribe). If the user then navigates onto another page on the site, they will not be authenticated. If the user is already authenticated, but as a different user to the one in the token, then they will receive a 403 response.

# this token will identify the request.user as a given user, but only for
# a single request - not the entire session.
token = RequestToken.objects.create_token(
    scope="foo",
    login_mode=RequestToken.LOGIN_MODE_REQUEST,
    user=User.objects.get(username="hugo")
)

...

@use_request_token(scope="foo")
function view_func(request):
    assert request.user == User.objects.get(username="hugo")

Auto-login (RequestToken.LOGIN_MODE_SESSION)

This is the nuclear option, and must be treated with extreme care. Using a Session token will automatically log the user in for an entire session, giving the user who clicks on the link full access the token user’s account. This is useful for automatic logins. A good example of this is the email login process on medium.com, which takes an email address (no password) and sends out a login link.

Session tokens must be single-use, and have a fixed expiry of one minute.

# this token will log in as the given user for the entire session -
# NB use with caution.
token = RequestToken.objects.create_token(
    scope="foo",
    login_mode=RequestToken.LOGIN_MODE_SESSION,
    user=User.objects.get(username="hugo")
)

Implementation

The project contains middleware and a view function decorator that together validate request tokens added to site URLs.

request_token.models.RequestToken - stores the token details

Step 1 is to create a RequestToken - this has various attributes that can be used to modify its behaviour, and mandatory property - scope. This is a text value - it can be anything you like - it is used by the function decorator (described below) to confirm that the token given matches the function being called - i.e. the token.scope must match the function decorator scope kwarg:

token = RequestToken(scope="foo")

# this will raise a 403 without even calling the function
@use_request_token(scope="bar")
def incorrect_scope(request):
    pass

# this will call the function as expected
@use_request_token(scope="foo")
def correct_scope(request):
    pass

The token itself - the value that must be appended to links as a querystring argument - is a JWT - and comes from the RequestToken.jwt() method. For example, if you were sending out an email, you might render the email as an HTML template like this:

{% if token %}
    <a href="{{url}}?rt={{token.jwt}}>click here</a>
{% else %}
    <a href="{{url}}">click here</a>
{% endif %}

If you haven’t come across JWT before you can find out more on the jwt.io website. The token produced will include the following JWT claims (available as the property RequestToken.claims:

  • max: maximum times the token can be used

  • sub: the scope

  • mod: the login mode

  • jti: the token id

  • aud: (optional) the user the token represents

  • exp: (optional) the expiration time of the token

  • iat: (optional) the time the token was issued

  • ndf: (optional) the not-before-time of the token

request_token.middleware.RequestTokenMiddleware - decodes and verifies tokens

The RequestTokenMiddleware will look for a querystring token value (the argument name defaults to ‘rt’ and can overridden using the JWT_QUERYSTRING_ARG setting), and if it finds one it will verify the token (using the JWT decode verification). If the token is verified, it will fetch the token object from the database and perform additional validation against the token attributes. If the token checks out it is added to the incoming request as a token attribute. This way you can add arbitrary data (stored on the token) to incoming requests.

If the token has a user specified, then the request.user is updated to reflect this. The middleware must run after the Django auth middleware, and before any custom middleware that inspects / monkey-patches the request.user.

If the token cannot be verified it returns a 403.

request_token.decorators.use_request_token - applies token permissions to views

A function decorator that takes one mandatory kwargs (scope) and one optional kwargs (required). The scope is used to match tokens to view functions - it’s just a straight text match - the value can be anything you like, but if the token scope is ‘foo’, then the corresponding view function decorator scope must match. The required kwarg is used to indicate whether the view must have a token in order to be used, or not. This defaults to False - if a token is provided, then it will be validated, if not, the view function is called as is.

If the scopes do not match then a 403 is returned.

If required is True and no token is provided the a 403 is returned.

Installation

Download / install the app using pip:

pip install django-request-token

Add the app request_token to your INSTALLED_APPS Django setting:

# settings.py
INSTALLED_APPS = (
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'request_token',
    ...
)

Add the middleware to your settings, after the standard authentication middleware, and before any custom middleware that uses the request.user.

MIDDLEWARE_CLASSES = [
    # default django middleware
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'request_token.middleware.RequestTokenMiddleware',
]

You can now add RequestToken objects, either via the shell (or within your app) or through the admin interface. Once you have added a RequestToken you can add the token JWT to your URLs (using the jwt() method):

>>> token = RequestToken.objects.create_token(scope="foo")
>>> url = "https://example.com/foo?rt=" + token.jwt()

You now have a request token enabled URL. You can use this token to protect a view function using the view decorator:

@use_request_token(scope="foo")
function foo(request):
    pass

NB The ‘scope’ argument to the decorator is used to bind the function to the incoming token - if someone tries to use a valid token on another URL, this will return a 403.

NB this currently supports only view functions - not class-based views.

Settings

Settings are read in from the environment or Django settings:

os.getenv('SETTING_NAME') or django.conf.settings.get('SETTING_NAME', default)
  • REQUEST_TOKEN_QUERYSTRING

The querystring argument name used to extract the token from incoming requests, defaults to rt.

  • REQUEST_TOKEN_EXPIRY

Session tokens have a default expiry interval, specified in minutes. The primary use case (above) dictates that the expiry should be no longer than it takes to receive and open an email, defaults to 10 (minutes).

  • REQUEST_TOKEN_403_TEMPLATE

Specifying the 403-template so that for prettyfying the 403-response, in production with a setting like:

FOUR03_TEMPLATE = os.path.join(BASE_DIR,'...','403.html')
  • REQUEST_TOKEN_LOG_TOKEN_ERRORS

If an InvalidTokenError is raised by the decorator or middleware, the error will be logged as a RequestTokenErrorLog object. This makes debugging easier, which is important in production as often the first you will know about a token problem is an angry customer who says “my link doesn’t work”. Being able to diagnose issues from the admin site is useful, however if the volume or errors is a problem this can be disabled by setting this value to anything other than ‘True’ or ‘1’.

Logging

Debugging middleware and decorators can be complex, so the project is verbose in its logging (by design). If you feel it’s providing too much logging, you can adjust it by setting the standard Django logging for request_token.

You can turn off formal logging in the database of token errors by using the setting REQUEST_TOKEN_LOG_TOKEN_ERRORS.

Tests

There is a set of tox tests.

License

MIT

Contributing

This is by no means complete, however, it’s good enough to be of value, hence releasing it. If you would like to contribute to the project, usual Github rules apply:

  1. Fork the repo to your own account

  2. Submit a pull request

  3. Add tests for any new code

  4. Follow coding style of existing project

Acknowledgements

@jpadilla for PyJWT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

request-token-0.10.tar.gz (25.1 kB view details)

Uploaded Source

Built Distribution

request_token-0.10-py3-none-any.whl (27.9 kB view details)

Uploaded Python 3

File details

Details for the file request-token-0.10.tar.gz.

File metadata

  • Download URL: request-token-0.10.tar.gz
  • Upload date:
  • Size: 25.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.0.0 CPython/3.7.4 Darwin/19.2.0

File hashes

Hashes for request-token-0.10.tar.gz
Algorithm Hash digest
SHA256 0a617f29fd327460433324c720057d4fabde670cfd3accfee0e57f65908376d9
MD5 c591c71e034732f26f8057da4fed1fe2
BLAKE2b-256 83bc04fdfe1c740f7e010bab80bed47cba81b17e49ab7359b6b0db357d95588e

See more details on using hashes here.

File details

Details for the file request_token-0.10-py3-none-any.whl.

File metadata

  • Download URL: request_token-0.10-py3-none-any.whl
  • Upload date:
  • Size: 27.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.0.0 CPython/3.7.4 Darwin/19.2.0

File hashes

Hashes for request_token-0.10-py3-none-any.whl
Algorithm Hash digest
SHA256 ea7caeca6b074bdad0953f448e10b02611fb4e998810a3992c3cd4a42fd1a903
MD5 2ded268e744825c6c0aeda1b01010baf
BLAKE2b-256 d122f6c42b981bd2c3ab293b0655cec315bf7c82d1040dc59f2ab64df170a8a9

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page