Package: request-token
Supported versionsAs 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 TokenDjango app that uses JWT to manage one-time and expiring tokens to protect URLs.This app currently requires the use of PostgreSQL.CompatibilityThis library is now Python3 and Django1.11 and above only.BackgroundThis 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 debugIf 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 scenariosSome 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 CasesThis project supports three core use cases, each of which is modelled using
thelogin_modeattribute of a request token:Public link with payloadSingle authenticated requestAuto-loginPublic 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")functionview_func(request):# extract the affiliate id from an token _if_ one is suppliedaffiliate_id=(request.token.data['affiliate_id']ifhasattr(request,'token')elseNone)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")functionview_func(request):assertrequest.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"))ImplementationThe project contains middleware and a view function decorator that together
validate request tokens added to site URLs.request_token.models.RequestToken- stores the token detailsStep 1 is to create aRequestToken- 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. thetoken.scopemust 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")defincorrect_scope(request):pass# this will call the function as expected@use_request_token(scope="foo")defcorrect_scope(request):passThe token itself - the value that must be appended to links as a querystring
argument - is a JWT - and comes from theRequestToken.jwt()method. For example,
if you were sending out an email, you might render the email as an HTML template
like this:{% if token %}<ahref="{{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 thejwt.iowebsite. The token produced will include the following JWT claims (available as the propertyRequestToken.claims:max: maximum times the token can be usedsub: the scopemod: the login modejti: the token idaud: (optional) the user the token representsexp: (optional) the expiration time of the tokeniat: (optional) the time the token was issuedndf: (optional) the not-before-time of the tokenrequest_token.middleware.RequestTokenMiddleware- decodes and verifies tokensTheRequestTokenMiddlewarewill look for a querystring token value (the argument name defaults to ‘rt’ and can overridden using theJWT_QUERYSTRING_ARGsetting), 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 atokenattribute. This way you can add arbitrary data (stored on the token) to incoming requests.If the token has a user specified, then therequest.useris updated to
reflect this. The middleware must run after the Django auth middleware, and
before any custom middleware that inspects / monkey-patches therequest.user.If the token cannot be verified it returns a 403.request_token.decorators.use_request_token- applies token permissions to viewsA function decorator that takes one mandatory kwargs (scope) and one optional
kwargs (required). Thescopeis 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. Therequiredkwarg is used to indicate whether the viewmusthave
a token in order to be used, or not. This defaults to False - if a tokenisprovided, 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.InstallationDownload / install the app using pip:pipinstalldjango-request-tokenAdd the apprequest_tokento yourINSTALLED_APPSDjango setting:# settings.pyINSTALLED_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,afterthe standard authentication middleware,
and before any custom middleware that uses therequest.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 addRequestTokenobjects, either via the shell (or within your
app) or through the admin interface. Once you have added aRequestTokenyou
can add the token JWT to your URLs (using thejwt()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")functionfoo(request):passNB 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.SettingsSettings are read in from the environment or Django settings:os.getenv('SETTING_NAME')ordjango.conf.settings.get('SETTING_NAME',default)REQUEST_TOKEN_QUERYSTRINGThe querystring argument name used to extract the token from incoming
requests, defaults tort.REQUEST_TOKEN_EXPIRYSession 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 to10(minutes).REQUEST_TOKEN_403_TEMPLATESpecifying 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_ERRORSIf anInvalidTokenErroris raised by the decorator or middleware, the error
will be logged as aRequestTokenErrorLogobject. 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’.LoggingDebugging 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 forrequest_token.You can turn off formal logging in the database of token errors by using the
settingREQUEST_TOKEN_LOG_TOKEN_ERRORS.TestsThere is a set oftoxtests.LicenseMITContributingThis 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:Fork the repo to your own accountSubmit a pull requestAdd tests for any new codeFollow coding style of existing projectAcknowledgements@jpadilla forPyJWT