django-magic-link


Namedjango-magic-link JSON
Version 1.0.0 PyPI version JSON
download
home_pagehttps://github.com/yunojuno/django-magic-link
SummaryDjango app for managing tokenised 'magic link' logins.
upload_time2023-11-13 13:54:52
maintainerYunoJuno
docs_urlNone
authorYunoJuno
requires_python>=3.8,<4.0
licenseMIT
keywords
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage
            # Django Magic Link

Opinionated Django app for managing "magic link" logins.

**WARNING**

If you send a login link to the wrong person, they will gain full access
to the user's account. Use with extreme caution, and do not use this
package without reading the source code and ensuring that you are
comfortable with it. If you have an internal security team, ask them to
look at it before using it. If your clients have security sign-off on
your application, ask them to look at it before using it.

**/WARNING**

This app is not intended for general purpose URL tokenisation; it is
designed to support a single use case - so-called "magic link" logins.

There are lots of alternative apps that can support this use case,
including the project from which this has been extracted -
[`django-request-token`](https://github.com/yunojuno/django-request-token).
The reason for yet another one is to handle the real-world challenge of
URL caching / pre-fetch, where intermediaries use URLs with unintended
consequences.

This packages supports a very specific model:

1. User is sent a link to log them in automatically.
2. User clicks on the link, and which does a GET request to the URL.
3. User is presented with a confirmation page, but is _not_ logged in.
4. User clicks on a button and performs a POST to the same page.
5. The POST request authenticates the user, and deactivates the token.

The advantage of this is the email clients do not support POST links,
and any prefetch that attempts a POST will fail the CSRF checks.

The purpose is to ensure that someone actively, purposefully, clicked on
a link to authenticate themselves. This enables instant deactivation of
the token, so that it can no longer be used.

In practice, without this check, valid magic links may be requested a
number of times via GET request before the intended recipient even sees
the link. If you use a "max uses" restriction to lock down the link you
may find this limit is hit, and the end user then finds that the link is
inactive. The alternative to this is to remove the use limit and rely
instead on an expiry window. This risks leaving the token active even
after the user has logged in. This package is targeted at this
situation.

## Use

### Prerequisite: Update settings.py and urls.py

Add `magic_link` to INSTALLED_APPS in settings.py:

```python
INSTALLED_APPS = [
    ...
    'magic_link',
]
```

Add the `magic_link` urls to urls.py:

```python
from magic_link import urls as magic_link_urls


urlpatterns = [
    ...
    url(r'^magic_link/', include(magic_link_urls)),
]
```

### Prerequisite: Override the default templates.

This package has two HTML templates that must be overridden in your
local application.

**templates/magic_link/logmein.html**

This is the landing page that a user sees when they click on the magic
link. You can add any content you like to this page - the only
requirement is that must contains a simple form with a csrf token and a
submit button. This form must POST back to the link URL. The template
render context includes the `link` which has a `get_absolute_url` method
to simplify this:

```html
<form method="POST" action="{{ link.get_absolute_url }}">
    {% csrf_token %}
    <button type="submit">Log me in</button>
</form>
```

**templates/magic_link/error.html**

If the link has expired, been used, or is being accessed by someone who
is already logged in, then the `error.html` template will be rendered.
The template context includes `link` and `error`.

```html
<p>Error handling magic link {{ link }}: {{ error }}.</p>
```

### 1. Create a new login link

The first step in managing magic links is to create one. Links are bound
to a user, and can have a custom post-login redirect URL.

```python
# create a link with the default expiry and redirect
link = MagicLink.objects.create(user=user)

# create a link with a specific redirect
link = MagicLink.objects.create(user=user, redirect_to="/foo")

# construct a full URL from a MagicLink object and a Django HttpResponse
url = request.build_absolute_uri(link.get_absolute_url())
```

### 2. Send the link to the user

This package does not handle the sending on your behalf - it is your
responsibility to ensure that you send the link to the correct user. If
you send the link to the wrong user, they will have full access to the
link user's account. **YOU HAVE BEEN WARNED**.

## Auditing

A core requirement of this package is to be able to audit the use of
links - for monitoring and analysis. To enable this we have a second
model, `MagicLinkUse`, and we create a new object for every request to a
link URL, _regardless of outcome_. Questions that we want to have
answers for include:

-   How long does it take for users to click on a link?
-   How many times is a link used before the POST login?
-   How often is a link used _after_ a successful login?
-   How often does a link expire before a successful login?
-   Can we identify common non-user client requests (email caches, bots, etc)?
-   Should we disable links after X non-POST requests?

In order to facilitate this analysis we denormalise a number of
timestamps from the `MagicLinkUse` object back onto the `MagicLink`
itself:

-   `created_at` - when the record was created in the database
-   `accessed_at` - the first GET request to the link URL
-   `logged_in_at` - the successful POST
-   `expires_at` - the link expiry, set when the link is created.

Note that the expiry timestamp is **not** updated when the link is used.
This is by design, to retain the original expiry timestamp.

### Link validation

In addition to the timestamp fields, there is a separate boolean flag,
`is_active`. This acts as a "kill switch" that overrides any other
attribute, and it allows a link to be disabled without having to edit
(or destroy) existing timestamp values. You can deactivate all links in
one hit by calling `MagicLink.objects.deactivate()`.

A link's `is_valid` property combines both `is_active` and timestamp
data to return a bool value that defines whether a link can used, based
on the following criteria:

1. The link is active (`is_active`)
2. The link has not expired (`expires_at`)
3. The link has not already been used (`logged_in_at`)

In addition to checking the property `is_valid`, the `validate()` method
will raise an exception based on the specific condition that failed.
This is used by the link view to give feedback to the user on the nature
of the failure.

### Request authorization

If the link's `is_valid` property returns `True`, then the link _can_ be
used. However, this does not mean that the link can be used by anyone.
We do not allow authenticated users to login using someone else's magic
link. The `authorize()` method takes a `User` argument and determines
whether they are authorized to use the link. If the user is
authenticated, and does not match the `link.user`, then a
`PermissionDenied` exception is raised.

### Putting it together

Combining the validation, authorization and auditing, we get a
simplified flow that looks something like this:

```python
def get(request, token):
    """Render login page."""
    link = get_object_or_404(MagicLink, token=token)
    link.validate()
    link.authorize(request.user)
    link.audit()
    return render("logmein.html")

def post(request, token):
    """Handle the login POST."""
    link = get_object_or_404(MagicLink, token=token)
    link.validate()
    link.authorize(request.user)
    link.login(request)
    link.disable()
    return redirect(link.redirect_to)
```

## Settings

Settings are read from a `django.conf.settings` settings dictionary called `MAGIC_LINK`.

Default settings show below:

```python
# settings.py
MAGIC_LINK = {
    # link expiry, in seconds
    "DEFAULT_EXPIRY": 300,
    # default link redirect
    "DEFAULT_REDIRECT": "/",
    # the preferred authorization backend to use, in the case where you have more
    # than one specified in the `settings.AUTHORIZATION_BACKENDS` setting.
    "AUTHENTICATION_BACKEND": "django.contrib.auth.backends.ModelBackend",
    # SESSION_COOKIE_AGE override for magic-link logins - in seconds (default is 1 week)
    "SESSION_EXPIRY": 7 * 24 * 60 * 60
}
```

## Screenshots

**Default landing page (`logmein.html`)**

<img src="https://raw.githubusercontent.com/yunojuno/django-magic-link/master/screenshots/landing-page.png" width=600 alt="Screenshot of default landing page" />

**Default error page (`error.html`)**

<img src="https://raw.githubusercontent.com/yunojuno/django-magic-link/master/screenshots/error-page.png" width=600 alt="Screenshot of default error page" />

**Admin view of magic link uses**

<img src="https://raw.githubusercontent.com/yunojuno/django-magic-link/master/screenshots/admin-inline.png" width=600 alt="Screenshot of MagicLinkUseInline" />

            

Raw data

            {
    "_id": null,
    "home_page": "https://github.com/yunojuno/django-magic-link",
    "name": "django-magic-link",
    "maintainer": "YunoJuno",
    "docs_url": null,
    "requires_python": ">=3.8,<4.0",
    "maintainer_email": "code@yunojuno.com",
    "keywords": "",
    "author": "YunoJuno",
    "author_email": "code@yunojuno.com",
    "download_url": "https://files.pythonhosted.org/packages/f8/69/3d2828ee3d8ec3075bef70aa03e7de0d22f476be3e7ad9e5bad6a713db1f/django_magic_link-1.0.0.tar.gz",
    "platform": null,
    "description": "# Django Magic Link\n\nOpinionated Django app for managing \"magic link\" logins.\n\n**WARNING**\n\nIf you send a login link to the wrong person, they will gain full access\nto the user's account. Use with extreme caution, and do not use this\npackage without reading the source code and ensuring that you are\ncomfortable with it. If you have an internal security team, ask them to\nlook at it before using it. If your clients have security sign-off on\nyour application, ask them to look at it before using it.\n\n**/WARNING**\n\nThis app is not intended for general purpose URL tokenisation; it is\ndesigned to support a single use case - so-called \"magic link\" logins.\n\nThere are lots of alternative apps that can support this use case,\nincluding the project from which this has been extracted -\n[`django-request-token`](https://github.com/yunojuno/django-request-token).\nThe reason for yet another one is to handle the real-world challenge of\nURL caching / pre-fetch, where intermediaries use URLs with unintended\nconsequences.\n\nThis packages supports a very specific model:\n\n1. User is sent a link to log them in automatically.\n2. User clicks on the link, and which does a GET request to the URL.\n3. User is presented with a confirmation page, but is _not_ logged in.\n4. User clicks on a button and performs a POST to the same page.\n5. The POST request authenticates the user, and deactivates the token.\n\nThe advantage of this is the email clients do not support POST links,\nand any prefetch that attempts a POST will fail the CSRF checks.\n\nThe purpose is to ensure that someone actively, purposefully, clicked on\na link to authenticate themselves. This enables instant deactivation of\nthe token, so that it can no longer be used.\n\nIn practice, without this check, valid magic links may be requested a\nnumber of times via GET request before the intended recipient even sees\nthe link. If you use a \"max uses\" restriction to lock down the link you\nmay find this limit is hit, and the end user then finds that the link is\ninactive. The alternative to this is to remove the use limit and rely\ninstead on an expiry window. This risks leaving the token active even\nafter the user has logged in. This package is targeted at this\nsituation.\n\n## Use\n\n### Prerequisite: Update settings.py and urls.py\n\nAdd `magic_link` to INSTALLED_APPS in settings.py:\n\n```python\nINSTALLED_APPS = [\n    ...\n    'magic_link',\n]\n```\n\nAdd the `magic_link` urls to urls.py:\n\n```python\nfrom magic_link import urls as magic_link_urls\n\n\nurlpatterns = [\n    ...\n    url(r'^magic_link/', include(magic_link_urls)),\n]\n```\n\n### Prerequisite: Override the default templates.\n\nThis package has two HTML templates that must be overridden in your\nlocal application.\n\n**templates/magic_link/logmein.html**\n\nThis is the landing page that a user sees when they click on the magic\nlink. You can add any content you like to this page - the only\nrequirement is that must contains a simple form with a csrf token and a\nsubmit button. This form must POST back to the link URL. The template\nrender context includes the `link` which has a `get_absolute_url` method\nto simplify this:\n\n```html\n<form method=\"POST\" action=\"{{ link.get_absolute_url }}\">\n    {% csrf_token %}\n    <button type=\"submit\">Log me in</button>\n</form>\n```\n\n**templates/magic_link/error.html**\n\nIf the link has expired, been used, or is being accessed by someone who\nis already logged in, then the `error.html` template will be rendered.\nThe template context includes `link` and `error`.\n\n```html\n<p>Error handling magic link {{ link }}: {{ error }}.</p>\n```\n\n### 1. Create a new login link\n\nThe first step in managing magic links is to create one. Links are bound\nto a user, and can have a custom post-login redirect URL.\n\n```python\n# create a link with the default expiry and redirect\nlink = MagicLink.objects.create(user=user)\n\n# create a link with a specific redirect\nlink = MagicLink.objects.create(user=user, redirect_to=\"/foo\")\n\n# construct a full URL from a MagicLink object and a Django HttpResponse\nurl = request.build_absolute_uri(link.get_absolute_url())\n```\n\n### 2. Send the link to the user\n\nThis package does not handle the sending on your behalf - it is your\nresponsibility to ensure that you send the link to the correct user. If\nyou send the link to the wrong user, they will have full access to the\nlink user's account. **YOU HAVE BEEN WARNED**.\n\n## Auditing\n\nA core requirement of this package is to be able to audit the use of\nlinks - for monitoring and analysis. To enable this we have a second\nmodel, `MagicLinkUse`, and we create a new object for every request to a\nlink URL, _regardless of outcome_. Questions that we want to have\nanswers for include:\n\n-   How long does it take for users to click on a link?\n-   How many times is a link used before the POST login?\n-   How often is a link used _after_ a successful login?\n-   How often does a link expire before a successful login?\n-   Can we identify common non-user client requests (email caches, bots, etc)?\n-   Should we disable links after X non-POST requests?\n\nIn order to facilitate this analysis we denormalise a number of\ntimestamps from the `MagicLinkUse` object back onto the `MagicLink`\nitself:\n\n-   `created_at` - when the record was created in the database\n-   `accessed_at` - the first GET request to the link URL\n-   `logged_in_at` - the successful POST\n-   `expires_at` - the link expiry, set when the link is created.\n\nNote that the expiry timestamp is **not** updated when the link is used.\nThis is by design, to retain the original expiry timestamp.\n\n### Link validation\n\nIn addition to the timestamp fields, there is a separate boolean flag,\n`is_active`. This acts as a \"kill switch\" that overrides any other\nattribute, and it allows a link to be disabled without having to edit\n(or destroy) existing timestamp values. You can deactivate all links in\none hit by calling `MagicLink.objects.deactivate()`.\n\nA link's `is_valid` property combines both `is_active` and timestamp\ndata to return a bool value that defines whether a link can used, based\non the following criteria:\n\n1. The link is active (`is_active`)\n2. The link has not expired (`expires_at`)\n3. The link has not already been used (`logged_in_at`)\n\nIn addition to checking the property `is_valid`, the `validate()` method\nwill raise an exception based on the specific condition that failed.\nThis is used by the link view to give feedback to the user on the nature\nof the failure.\n\n### Request authorization\n\nIf the link's `is_valid` property returns `True`, then the link _can_ be\nused. However, this does not mean that the link can be used by anyone.\nWe do not allow authenticated users to login using someone else's magic\nlink. The `authorize()` method takes a `User` argument and determines\nwhether they are authorized to use the link. If the user is\nauthenticated, and does not match the `link.user`, then a\n`PermissionDenied` exception is raised.\n\n### Putting it together\n\nCombining the validation, authorization and auditing, we get a\nsimplified flow that looks something like this:\n\n```python\ndef get(request, token):\n    \"\"\"Render login page.\"\"\"\n    link = get_object_or_404(MagicLink, token=token)\n    link.validate()\n    link.authorize(request.user)\n    link.audit()\n    return render(\"logmein.html\")\n\ndef post(request, token):\n    \"\"\"Handle the login POST.\"\"\"\n    link = get_object_or_404(MagicLink, token=token)\n    link.validate()\n    link.authorize(request.user)\n    link.login(request)\n    link.disable()\n    return redirect(link.redirect_to)\n```\n\n## Settings\n\nSettings are read from a `django.conf.settings` settings dictionary called `MAGIC_LINK`.\n\nDefault settings show below:\n\n```python\n# settings.py\nMAGIC_LINK = {\n    # link expiry, in seconds\n    \"DEFAULT_EXPIRY\": 300,\n    # default link redirect\n    \"DEFAULT_REDIRECT\": \"/\",\n    # the preferred authorization backend to use, in the case where you have more\n    # than one specified in the `settings.AUTHORIZATION_BACKENDS` setting.\n    \"AUTHENTICATION_BACKEND\": \"django.contrib.auth.backends.ModelBackend\",\n    # SESSION_COOKIE_AGE override for magic-link logins - in seconds (default is 1 week)\n    \"SESSION_EXPIRY\": 7 * 24 * 60 * 60\n}\n```\n\n## Screenshots\n\n**Default landing page (`logmein.html`)**\n\n<img src=\"https://raw.githubusercontent.com/yunojuno/django-magic-link/master/screenshots/landing-page.png\" width=600 alt=\"Screenshot of default landing page\" />\n\n**Default error page (`error.html`)**\n\n<img src=\"https://raw.githubusercontent.com/yunojuno/django-magic-link/master/screenshots/error-page.png\" width=600 alt=\"Screenshot of default error page\" />\n\n**Admin view of magic link uses**\n\n<img src=\"https://raw.githubusercontent.com/yunojuno/django-magic-link/master/screenshots/admin-inline.png\" width=600 alt=\"Screenshot of MagicLinkUseInline\" />\n",
    "bugtrack_url": null,
    "license": "MIT",
    "summary": "Django app for managing tokenised 'magic link' logins.",
    "version": "1.0.0",
    "project_urls": {
        "Documentation": "https://github.com/yunojuno/django-magic-link",
        "Homepage": "https://github.com/yunojuno/django-magic-link",
        "Repository": "https://github.com/yunojuno/django-magic-link"
    },
    "split_keywords": [],
    "urls": [
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "5549825cef72f472f801e18b550329b4fcd062d0fa59e6c107bbe2fb457075ac",
                "md5": "8a5a1473c5cac5609fcd3303233ddd45",
                "sha256": "a8d457f30be81a29126d5db34f7a079c51d3673c97eb4fcf1435b865b662c83d"
            },
            "downloads": -1,
            "filename": "django_magic_link-1.0.0-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "8a5a1473c5cac5609fcd3303233ddd45",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": ">=3.8,<4.0",
            "size": 15288,
            "upload_time": "2023-11-13T13:54:50",
            "upload_time_iso_8601": "2023-11-13T13:54:50.796813Z",
            "url": "https://files.pythonhosted.org/packages/55/49/825cef72f472f801e18b550329b4fcd062d0fa59e6c107bbe2fb457075ac/django_magic_link-1.0.0-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "f8693d2828ee3d8ec3075bef70aa03e7de0d22f476be3e7ad9e5bad6a713db1f",
                "md5": "c112f94267fadd8c1f9963ecc6b0e606",
                "sha256": "7247db82f9671f155e5fa4fc412c94f891d0f8877036e377ca92b9b4a40c759c"
            },
            "downloads": -1,
            "filename": "django_magic_link-1.0.0.tar.gz",
            "has_sig": false,
            "md5_digest": "c112f94267fadd8c1f9963ecc6b0e606",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": ">=3.8,<4.0",
            "size": 14128,
            "upload_time": "2023-11-13T13:54:52",
            "upload_time_iso_8601": "2023-11-13T13:54:52.259153Z",
            "url": "https://files.pythonhosted.org/packages/f8/69/3d2828ee3d8ec3075bef70aa03e7de0d22f476be3e7ad9e5bad6a713db1f/django_magic_link-1.0.0.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2023-11-13 13:54:52",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "yunojuno",
    "github_project": "django-magic-link",
    "travis_ci": false,
    "coveralls": true,
    "github_actions": true,
    "tox": true,
    "lcname": "django-magic-link"
}
        
Elapsed time: 0.14290s