diff --git a/README.md b/README.md
index b255ce6..ecda38c 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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):
@@ -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
@@ -113,6 +115,24 @@ After the user has requested a magic link, they will be redirected to a success
Please click the link to be logged in automatically
```
+
+#### 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)
@@ -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
diff --git a/magiclink/backends.py b/magiclink/backends.py
index 723b190..253f218 100644
--- a/magiclink/backends.py
+++ b/magiclink/backends.py
@@ -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
diff --git a/magiclink/settings.py b/magiclink/settings.py
index 3bae7db..f5ad389 100644
--- a/magiclink/settings.py
+++ b/magiclink/settings.py
@@ -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.
diff --git a/magiclink/templates/magiclink/login_failed.html b/magiclink/templates/magiclink/login_failed.html
new file mode 100644
index 0000000..02bbcb1
--- /dev/null
+++ b/magiclink/templates/magiclink/login_failed.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ Django MagicLink Login sent - Please override this template
+
+
+
+
+
+ Login failed
+ It was not possible to log you in
+ This could be due to one of the following reasons:
+
+ - The URL you are trying to use is malformed
+ - The magic link you tried to use has expired
+ - You have already used this magic link to login before
+ {% if ONE_TOKEN_PER_USER %}
+ - You have requested another magic causing this magic link to become invalid
+ {% endif %}
+ {% if REQUIRE_SAME_BROWSER %}
+ - You are using a different browser to when you requested the login link
+ {% endif %}
+ {% if REQUIRE_SAME_IP %}
+ - You are trying to login from a different location or machine that you requested the login link from
+ {% endif %}
+ {% if not ALLOW_SUPERUSER_LOGIN or not ALLOW_STAFF_LOGIN %}
+ - You are a superuser or staff user
+ {% endif %}
+
+
+ If you are seeing this you have not yet overridden the 'MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME' setting yet.
+ Please see the README on Github for more details on setting up django-magiclink correctly
+
+
diff --git a/magiclink/views.py b/magiclink/views.py
index a193b9d..5831daf 100644
--- a/magiclink/views.py
+++ b/magiclink/views.py
@@ -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}')
diff --git a/pyproject.toml b/pyproject.toml
index a22b909..ead67e1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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 "]
readme = "README.md"
diff --git a/tests/test_login.py b/tests/test_login.py
index 3d15cc3..f51e7b8 100644
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -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
@@ -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
diff --git a/tests/test_login_verify.py b/tests/test_login_verify.py
new file mode 100644
index 0000000..3e0dc8d
--- /dev/null
+++ b/tests/test_login_verify.py
@@ -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
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 05ab5fc..413365a 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -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