cradmin_generic_token_with_metadata — Secure generic tokens with metadata

The purpose of the django_cradmin.apps.cradmin_generic_token_with_metadata app is to provide secure and unique tokens with attached metadata. The tokens are suitable for email confirmation workflows and public share urls.

Each token belongs to a user and an app. Tokens only live for a limited time, and this time can be configured on a per app basis.

Very useful for single-use URLs like password reset, account activation, etc.

How it works

Lets say you have an object and want to generate a unique token for that object:

from django_cradmin.apps.cradmin_generic_token_with_metadata.models import GenericTokenWithMetadata
from django.contrib.auth import get_user_model

myuser = get_user_model().get(...)  # The user is the object the token is for.
generictoken = GenericTokenWithMetadata.objects.generate(
    app='myapp', content_object=myuser,
    expiration_datetime=get_expiration_datetime_for_app('myapp'))
# Use generictoken.token

This creates a GenericTokenWithMetadata object with a token-attribute that contains a unique token. The app is provided for two reasons:

  • Makes it easier to debug/browse the data model because you know what app generated the token.
  • Makes it possible to configure different time to live for each app.
  • Isolation. Each app has their own “namespace” of tokens.

When you have a token, typically from part of an URL, and want to get the user owning the token, use:

generictoken = GenericTokenWithMetadata.objects.pop(app='myapp', token=token)
# Use generictoken.user and generictoken.metadata

This returns the GenericTokenWithMetadata, and deletes the GenericTokenWithMetadata from the database.

Use case — password reset email

Lets say you want to use GenericTokenWithMetadata to generate a password reset email.

First, we want to give the user an URL where they can go to reset the password:

url = 'http://example.com/resetpassword/{}'.format(
    GenericTokenWithMetadata.objects.generate(
        app='passwordreset',
        content_object=self.request.user,
        expiration_datetime=get_expiration_datetime_for_app('passwordreset'))

Since we are using Django, we will most likely want the url to be to a view, so this would most likely look more like this:

def start_password_reset_view(request):
    url = request.build_absolute_uri(reverse('my-reset-password-accept-view', kwargs={
        'token': GenericTokenWithMetadata.objects.generate(
            app='passwordreset', content_object=self.request.user,
            expiration_datetime=get_expiration_datetime_for_app('passwordreset'))
    }
    # ... send an email giving the receiver instructions to click the url

In the view that lives at the URL that the user clicks to confirm the password reset request, we do something like the following:

class ResetThePassword(View):
    def get(request, token):
        try:
            token = GenericTokenWithMetadata.objects.get_and_validate(app='passwordreset', token=token)
        except GenericTokenWithMetadata.DoesNotExist:
            return HttpResponse('Invalid password reset token.')
        except GenericTokenExpiredError:
            return HttpResponse('Your password reset token has expired.')
        else:
            # show a password reset form

    def post(request, token):
        try:
            token = GenericTokenWithMetadata.objects.pop(app='passwordreset', token=token)
        except GenericTokenWithMetadata.DoesNotExist:
            return HttpResponse('Invalid password reset token.')
        else:
            # reset the password

Configure

You can configure the time to live of the generated tokens using the DJANGO_CRADMIN_SECURE_USER_TOKEN_TIME_TO_LIVE_MINUTES setting:

DJANGO_CRADMIN_SECURE_USER_TOKEN_TIME_TO_LIVE_MINUTES = {
    'default': 1440,
    'myapp': 2500
}

It defaults to:

DJANGO_CRADMIN_SECURE_USER_TOKEN_TIME_TO_LIVE_MINUTES = {
    'default': 60*24*4
}

Delete expired tokens

To delete expired tokens, you can use:

GenericTokenWithMetadata.objects.delete_expired()

or the cradmin_generic_token_with_metadata_delete_expired management command:

$ python manage.py cradmin_generic_token_with_metadata_delete_expired

Note

You do not need to delete expired tokens very often unless you generate a lot of tokens. Expired tokens are not available through the GenericTokenWithMetadataBaseManager.pop() method. So if you use the API as intended, you will never use an expired token.

API

get_time_to_live_minutes(app)

Get the configured time to live in minutes for tokens for the given app.

get_expiration_datetime_for_app(app)

Get the expiration datetime of tokens for the given app relative to now.

If the given app is configured to with 60 minutes time to live, this will return a datetime object representing 60 minutes in the future.

generate_token()

Generate a token for the GenericTokenWithMetadata.token field.

Joins an UUID1 (unique uuid) with an UUID4 (random uuid), so the chance of this not beeing unique is very low, and guessing this is very hard.

Returns:A token that is very unlikely to not be unique.
exception GenericTokenExpiredError

Bases: Exception

Raised by GenericTokenWithMetadata.get_and_validate() when the token is found, but has expired.

class GenericTokenWithMetadataQuerySet(model=None, query=None, using=None, hints=None)

Bases: django.db.models.query.QuerySet

QuerySet for GenericTokenWithMetadata.

unsafe_pop(app, token)

Get the GenericTokenWithMetadata matching the given token and app. Removes the GenericTokenWithMetadata from the database, and returns the GenericTokenWithMetadata object.

You should normally use GenericTokenWithMetadataBaseManager.pop() instead of this.

Raises:
  • GenericTokenWithMetadata.DoesNotExist if no matching token is stored for
  • the given app.
filter_has_expired()

Return a queryset containing only the expired GenericTokenWithMetadata objects in the current queryset.

filter_not_expired()

Return a queryset containing only the un-expired GenericTokenWithMetadata objects in the current queryset.

filter_by_content_object(content_object)

Filter by GenericTokenWithMetadata.content_object.

Examples

Lets say the content_object is a User object, you can find all tokens for that user in the page_admin_invites app like this:

from django.contrib.auth import get_user_model
user = get_user_model()
GenericTokenWithMetadata.objects                    .filter(app='page_admin_invites')                    .filter_by_content_object(user)
filter_usable_by_content_object_in_app(content_object, app)

Filters only non-expired tokens with the given content_object and app.

class GenericTokenWithMetadataBaseManager

Bases: django.db.models.manager.Manager

Manager for GenericTokenWithMetadata.

Inherits all methods from GenericTokenWithMetadataQuerySet.

generate(app, expiration_datetime, content_object, metadata=None)

Generate and save a token for the given user and app.

Returns:A GenericTokenWithMetadata object with a token that is guaranteed to be unique.
pop(app, token)

Get the GenericTokenWithMetadata matching the given token and app. Removes the GenericTokenWithMetadata from the database, and returns the GenericTokenWithMetadata object.

Does not return expired tokens.

Raises:GenericTokenWithMetadata.DoesNotExist – If no matching token is stored for the given app, or if the token is expired.
delete_expired()

Delete all expired tokens.

get_and_validate(app, token)

Get the given token for the given app.

Raises:
class GenericTokenWithMetadata(*args, **kwargs)

Bases: django.db.models.base.Model

Provides a secure token with attached metadata suitable for email and sharing workflows like password reset, public share urls, etc.

app

The app that generated the token. You should set this to the name of the app the generated the token.

token

A unique and random token, set it using generate_token().

created_datetime

Datetime when the token was created.

expiration_datetime

Datetime when the token expires. This can be None, which means that the token does not expire.

single_use

Single use? If this is False, the token can be used an unlimited number of times.

metadata_json

JSON encoded metadata

content_type

The content-type of the content_object. Together with object_id this creates a generic foreign key to any Django model.

object_id

The object ID of the content_object. Together with content_type this creates a generic foreign key to any Django model.

content_object

A django.contrib.contenttypes.fields.GenericForeignKey to the object this token is for.

This generic relationship is used to associate the token with a specific object.

Use cases:

  • Password reset: Use the content_object to link to a User object when you create password reset tokens.
  • Invites: Use the content_object to link to the object that you are inviting users to. This enables you to filter on the content object to show pending shares.
is_expired()

Returns True if GenericTokenWithMetadata.expiration_datetime is in the past, and False if it is in the future or now.

exception DoesNotExist

Bases: django.core.exceptions.ObjectDoesNotExist

exception MultipleObjectsReturned

Bases: django.core.exceptions.MultipleObjectsReturned

metadata

Decode GenericTokenWithMetadata.metadata_json and return the result.

Return None if metadata_json is empty.