Skip to content

Commit

Permalink
Add login failed template
Browse files Browse the repository at this point in the history
  • Loading branch information
pyepye committed Jul 25, 2020
1 parent 5999eb5 commit c072f79
Show file tree
Hide file tree
Showing 9 changed files with 172 additions and 78 deletions.
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The setup of the app is simple but has a few steps and a few templates that need
1. [Configure the app](#configuration) adding urls and settings. There are also a number of [additional configuration settings](#configuration-settings)
1. [Set up the login page](#login-page) by overriding the login page template
1. [Override the login sent page HTML](#login-sent-page)
1. [Customise the login failed page](#login-failed-page)
1. [Set up the magic link email](#magic-link-email) (optional) by setting the email logo and colours. It's also possible to override the email templates
1. [Create a signup page](#signup-page) (optional) depending on your settings configuration

Expand Down Expand Up @@ -67,7 +68,7 @@ AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
)
```
*Note: MagicLinkBackend should be placed at the top of AUTHENTICATION_BACKENDS* to ensure it is used
*Note: MagicLinkBackend should be placed at the top of AUTHENTICATION_BACKENDS* to ensure it is used as the primary login backend.


Add the following settings to your `settings.py` (you will need to replace the template names in the below steps):
Expand All @@ -77,6 +78,7 @@ LOGIN_URL = 'magiclink:login'

MAGICLINK_LOGIN_TEMPLATE_NAME = 'magiclink/login.html'
MAGICLINK_LOGIN_SENT_TEMPLATE_NAME = 'magiclink/login_sent.html'
MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = 'magiclink/login_failed.html'

# Optional:
# If this setting is set to False a user account will be created the first
Expand Down Expand Up @@ -113,6 +115,24 @@ After the user has requested a magic link, they will be redirected to a success
<p>Please click the link to be logged in automatically</p>
```


#### Login failed page

If the user tries to use an invalid magic token they will be shown a custom error page. To override the HTML for this page you can set the `MAGICLINK_LOGIN_SENT_TEMPLATE_NAME` setting. If you would like to return a 404 page you can set this setting to a empty string (or any falsy value).

To help tailor the error page and explain the possible reasons the user could not login the following context variables are provided:

* `{{ one_token_per_user }}` - The value of MAGICLINK_ONE_TOKEN_PER_USER
* `{{ require_same_browser }}` - The value of MAGICLINK_REQUIRE_SAME_BROWSER
* `{{ require_same_ip }}` - The value of MAGICLINK_REQUIRE_SAME_IP
* `{{ allow_superuser_login }}` - The value of MAGICLINK_ALLOW_SUPERUSER_LOGIN
* `{{ allow_staff_login }}` - The value of MAGICLINK_ALLOW_STAFF_LOGIN

*Note: The reason the login request failed is not provided in the context*

For an example of this page see the [default login failed template](https://github.com/pyepye/django-magiclink/blob/master/magiclink/templates/magiclink/login_sent.html)


#### Magic link email

The login email which includes the magic link needs to be configured. By default, a simple HTML template is used which can be adapted to your own branding using the `MAGICLINK_EMAIL_STYLES` setting, or you can override the template (see below)
Expand Down Expand Up @@ -182,6 +202,11 @@ MAGICLINK_LOGIN_TEMPLATE_NAME = 'myapp/login.html'
# Override the login page template. See 'Login sent page' in the Setup section
MAGICLINK_LOGIN_SENT_TEMPLATE_NAME = 'myapp/login_sent.html'

# Override the template that shows when the user tries to login with a
# magic link that is not valid. See 'Login failed page' in the Setup section
MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = 'magiclink/login_failed.html'


# If this setting is set to False a user account will be created the first time
# a user requests a login link.
MAGICLINK_REQUIRE_SIGNUP = True
Expand Down
4 changes: 4 additions & 0 deletions magiclink/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ class MagicLinkBackend():
def authenticate(self, request, token=None, email=None):
log.debug(f'MagicLink authenticate token: {token} - email: {email}')

if not token:
log.warning('Token missing from authentication')
return

if settings.VERIFY_INCLUDE_EMAIL and not email:
log.warning('Email address not supplied with token')
return
Expand Down
1 change: 1 addition & 0 deletions magiclink/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

LOGIN_TEMPLATE_NAME = getattr(settings, 'MAGICLINK_LOGIN_TEMPLATE_NAME', 'magiclink/login.html') # NOQA: E501
LOGIN_SENT_TEMPLATE_NAME = getattr(settings, 'MAGICLINK_LOGIN_SENT_TEMPLATE_NAME', 'magiclink/login_sent.html') # NOQA: E501
LOGIN_FAILED_TEMPLATE_NAME = getattr(settings, 'MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME', 'magiclink/login_failed.html') # NOQA: E501

# If this setting is set to False a user account will be created the first time
# a user requests a login link.
Expand Down
36 changes: 36 additions & 0 deletions magiclink/templates/magiclink/login_failed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">

<title>Django MagicLink Login sent - Please override this template</title>
<meta name="description" content="Django MagicLink">
<meta name="author" content="Django MagicLink">
</head>

<body>
<h1>Login failed</h1>
<p>It was not possible to log you in</p>
<p>This could be due to one of the following reasons:</p>
<ul>
<li>The URL you are trying to use is malformed</li>
<li>The magic link you tried to use has expired</li>
<li>You have already used this magic link to login before</li>
{% if ONE_TOKEN_PER_USER %}
<li>You have requested another magic causing this magic link to become invalid</li>
{% endif %}
{% if REQUIRE_SAME_BROWSER %}
<li>You are using a different browser to when you requested the login link</li>
{% endif %}
{% if REQUIRE_SAME_IP %}
<li>You are trying to login from a different location or machine that you requested the login link from</li>
{% endif %}
{% if not ALLOW_SUPERUSER_LOGIN or not ALLOW_STAFF_LOGIN %}
<li>You are a superuser or staff user</li>
{% endif %}
</ul>

<p>If you are seeing this you have not yet overridden the 'MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME' setting yet.</p>
<p>Please see the <a href="https://github.com/pyepye/django-magiclink">README on Github</a> for more details on setting up django-magiclink correctly</p>
</body>
</html>
17 changes: 12 additions & 5 deletions magiclink/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,24 @@ class LoginSent(TemplateView):
template_name = settings.LOGIN_SENT_TEMPLATE_NAME


class LoginVerify(RedirectView):
class LoginVerify(TemplateView):
template_name = settings.LOGIN_FAILED_TEMPLATE_NAME

def get(self, request, *args, **kwargs):
token = request.GET.get('token')
if not token:
raise Http404()

email = request.GET.get('email')
user = authenticate(request, token=token, email=email)
if not user:
raise Http404()
if settings.LOGIN_FAILED_TEMPLATE_NAME:
context = self.get_context_data(**kwargs)
context['ONE_TOKEN_PER_USER'] = settings.ONE_TOKEN_PER_USER
context['REQUIRE_SAME_BROWSER'] = settings.REQUIRE_SAME_BROWSER
context['REQUIRE_SAME_IP'] = settings.REQUIRE_SAME_IP
context['ALLOW_SUPERUSER_LOGIN'] = settings.ALLOW_SUPERUSER_LOGIN # NOQA: E501
context['ALLOW_STAFF_LOGIN'] = settings.ALLOW_STAFF_LOGIN
return self.render_to_response(context)
else:
raise Http404()

login(request, user)
log.info(f'Login successful for {email}')
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "django-magiclink"
packages = [
{include = "magiclink"}
]
version = "0.7.0"
version = "0.8.0"
description = "Passwordless Authentication for Django with Magic Links"
authors = ["Matt Pye <pyematt@gmail.com>"]
readme = "README.md"
Expand Down
71 changes: 0 additions & 71 deletions tests/test_login.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
from importlib import reload
from urllib.parse import urlencode

import pytest
from django.contrib.auth import get_user_model
from django.http import HttpRequest
from django.http.cookie import SimpleCookie
from django.urls import reverse

from magiclink.models import MagicLink
Expand Down Expand Up @@ -159,72 +157,3 @@ def test_login_too_many_tokens(client, user, magic_link): # NOQA: F811
assert response.status_code == 200
error = ['Too many magic login requests']
assert response.context_data['login_form'].errors['email'] == error


@pytest.mark.django_db
def test_login_verify(client, settings, user, magic_link): # NOQA: F811
url = reverse('magiclink:login_verify')
request = HttpRequest()
ml = magic_link(request)
ml.ip_address = '127.0.0.1' # This is a little hacky
ml.save()

params = {'token': ml.token}
params['email'] = ml.email
query = urlencode(params)
url = f'{url}?{query}'

cookie_name = f'magiclink{ml.pk}'
client.cookies = SimpleCookie({cookie_name: ml.cookie_value})
response = client.get(url)
assert response.status_code == 302
assert response.url == reverse(settings.LOGIN_REDIRECT_URL)
assert client.cookies[cookie_name].value == ''
assert client.cookies[cookie_name]['expires'].startswith('Thu, 01 Jan 1970') # NOQA: E501

needs_login_url = reverse('needs_login')
needs_login_response = client.get(needs_login_url)
assert needs_login_response.status_code == 200


@pytest.mark.django_db
def test_login_verify_with_redirect(client, settings, user, magic_link): # NOQA: F811, E501
url = reverse('magiclink:login_verify')
request = HttpRequest()
request.META['SERVER_NAME'] = '127.0.0.1'
request.META['SERVER_PORT'] = 80
ml = magic_link(request)
ml.ip_address = '127.0.0.1' # This is a little hacky
redirect_url = reverse('no_login')
ml.redirect_url = redirect_url
ml.save()
url = ml.generate_url(request)

client.cookies = SimpleCookie({f'magiclink{ml.pk}': ml.cookie_value})
response = client.get(url)
assert response.status_code == 302
assert response.url == redirect_url


@pytest.mark.django_db
def test_login_verify_authentication_fail(client, settings, user, magic_link): # NOQA: F811, E501
url = reverse('magiclink:login_verify')
request = HttpRequest()
ml = magic_link(request)
ml.ip_address = '127.0.0.1' # This is a little hacky
ml.save()

params = {'token': ml.token}
query = urlencode(params)
url = f'{url}?{query}'

client.cookies = SimpleCookie({f'magiclink{ml.pk}': ml.cookie_value})
response = client.get(url)
assert response.status_code == 404


@pytest.mark.django_db
def test_login_verify_no_token(client):
url = reverse('magiclink:login_verify')
response = client.get(url)
assert response.status_code == 404
85 changes: 85 additions & 0 deletions tests/test_login_verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from importlib import reload
from urllib.parse import urlencode

import pytest
from django.contrib.auth import get_user_model
from django.http import HttpRequest
from django.http.cookie import SimpleCookie
from django.urls import reverse

from .fixtures import magic_link, user # NOQA: F401

User = get_user_model()


@pytest.mark.django_db
def test_login_verify(client, settings, user, magic_link): # NOQA: F811
url = reverse('magiclink:login_verify')
request = HttpRequest()
ml = magic_link(request)
ml.ip_address = '127.0.0.1' # This is a little hacky
ml.save()

params = {'token': ml.token}
params['email'] = ml.email
query = urlencode(params)
url = f'{url}?{query}'

cookie_name = f'magiclink{ml.pk}'
client.cookies = SimpleCookie({cookie_name: ml.cookie_value})
response = client.get(url)
assert response.status_code == 302
assert response.url == reverse(settings.LOGIN_REDIRECT_URL)
assert client.cookies[cookie_name].value == ''
assert client.cookies[cookie_name]['expires'].startswith('Thu, 01 Jan 1970') # NOQA: E501

needs_login_url = reverse('needs_login')
needs_login_response = client.get(needs_login_url)
assert needs_login_response.status_code == 200


@pytest.mark.django_db
def test_login_verify_with_redirect(client, settings, user, magic_link): # NOQA: F811, E501
url = reverse('magiclink:login_verify')
request = HttpRequest()
request.META['SERVER_NAME'] = '127.0.0.1'
request.META['SERVER_PORT'] = 80
ml = magic_link(request)
ml.ip_address = '127.0.0.1' # This is a little hacky
redirect_url = reverse('no_login')
ml.redirect_url = redirect_url
ml.save()
url = ml.generate_url(request)

client.cookies = SimpleCookie({f'magiclink{ml.pk}': ml.cookie_value})
response = client.get(url)
assert response.status_code == 302
assert response.url == redirect_url


@pytest.mark.django_db
def test_login_verify_failed(client, settings):
settings.MAGICLINK_MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = 'magiclink/login_failed.html' # NOQA: E501
from magiclink import settings as mlsettings
reload(mlsettings)

url = reverse('magiclink:login_verify')
response = client.get(url)
assert response.status_code == 200
context = response.context_data
assert context['ONE_TOKEN_PER_USER'] == mlsettings.ONE_TOKEN_PER_USER
assert context['REQUIRE_SAME_BROWSER'] == mlsettings.REQUIRE_SAME_BROWSER
assert context['REQUIRE_SAME_IP'] == mlsettings.REQUIRE_SAME_IP
assert context['ALLOW_SUPERUSER_LOGIN'] == mlsettings.ALLOW_SUPERUSER_LOGIN
assert context['ALLOW_STAFF_LOGIN'] == mlsettings.ALLOW_STAFF_LOGIN


@pytest.mark.django_db
def test_login_verify_no_token_404(client, settings):
settings.MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = ''
from magiclink import settings as mlsettings
reload(mlsettings)

url = reverse('magiclink:login_verify')
response = client.get(url)
assert response.status_code == 404
7 changes: 7 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ def test_login_sent_template_name(settings):
assert mlsettings.LOGIN_SENT_TEMPLATE_NAME == settings.MAGICLINK_LOGIN_SENT_TEMPLATE_NAME # NOQA: E501


def test_login_failed_template_name(settings):
settings.MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = 'login_failed.html'
from magiclink import settings as mlsettings
reload(mlsettings)
assert mlsettings.LOGIN_FAILED_TEMPLATE_NAME == settings.MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME # NOQA: E501


def test_signup_template_name(settings):
settings.MAGICLINK_SIGNUP_TEMPLATE_NAME = 'signup.html'
from magiclink import settings as mlsettings
Expand Down

0 comments on commit c072f79

Please sign in to comment.