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:

+ + +

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