Add email login (#5209)

* Add email login
FR] Add email link based logins Closes #3531

* fix reqs

* fix backend code

* Add tests for magic login
This commit is contained in:
Matthias Mair 2023-07-11 00:13:35 +02:00 committed by GitHub
parent 92b0a19270
commit a9a8ac1c70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 142 additions and 2 deletions

View File

@ -0,0 +1,75 @@
"""Functions for magic login."""
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import sesame.utils
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.views import APIView
def send_simple_login_email(user, link):
"""Send an email with the login link to this user."""
site = Site.objects.get_current()
context = {
"username": user.username,
"site_name": site.name,
"link": link,
}
email_plaintext_message = render_to_string("InvenTree/user_simple_login.txt", context)
send_mail(
_(f"[{site.name}] Log in to the app"),
email_plaintext_message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
)
class GetSimpleLoginSerializer(serializers.Serializer):
"""Serializer for the simple login view."""
email = serializers.CharField(label=_("Email"))
class GetSimpleLoginView(APIView):
"""View to send a simple login link."""
permission_classes = ()
serializer_class = GetSimpleLoginSerializer
def post(self, request, *args, **kwargs):
"""Get the token for the current user or fail."""
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
self.email_submitted(email=serializer.data["email"])
return Response({"status": "ok"})
def email_submitted(self, email):
"""Notify user about link."""
user = self.get_user(email)
if user is None:
print("user not found:", email)
return
link = self.create_link(user)
send_simple_login_email(user, link)
def get_user(self, email):
"""Find the user with this email address."""
try:
return User.objects.get(email=email)
except User.DoesNotExist:
return None
def create_link(self, user):
"""Create a login link for this user."""
link = reverse("sesame-login")
link = self.request.build_absolute_uri(link)
link += sesame.utils.get_query_string(user)
return link

View File

@ -64,6 +64,9 @@ class AuthRequiredMiddleware(object):
elif request.path_info.startswith('/accounts/'): elif request.path_info.startswith('/accounts/'):
authorized = True authorized = True
elif request.path_info.startswith('/platform/') or request.path_info == '/platform':
authorized = True
elif 'Authorization' in request.headers.keys() or 'authorization' in request.headers.keys(): elif 'Authorization' in request.headers.keys() or 'authorization' in request.headers.keys():
auth = request.headers.get('Authorization', request.headers.get('authorization')).strip() auth = request.headers.get('Authorization', request.headers.get('authorization')).strip()

View File

@ -276,6 +276,7 @@ AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [
'django.contrib.auth.backends.RemoteUserBackend', # proxy login 'django.contrib.auth.backends.RemoteUserBackend', # proxy login
'django.contrib.auth.backends.ModelBackend', 'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers 'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
"sesame.backends.ModelBackend", # Magic link login django-sesame
]) ])
DEBUG_TOOLBAR_ENABLED = DEBUG and get_setting('INVENTREE_DEBUG_TOOLBAR', 'debug_toolbar', False) DEBUG_TOOLBAR_ENABLED = DEBUG and get_setting('INVENTREE_DEBUG_TOOLBAR', 'debug_toolbar', False)
@ -601,6 +602,10 @@ DATABASES = {
REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False) REMOTE_LOGIN = get_boolean_setting('INVENTREE_REMOTE_LOGIN', 'remote_login_enabled', False)
REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER') REMOTE_LOGIN_HEADER = get_setting('INVENTREE_REMOTE_LOGIN_HEADER', 'remote_login_header', 'REMOTE_USER')
# Magic login django-sesame
SESAME_MAX_AGE = 300
LOGIN_REDIRECT_URL = "/platform/logged-in/"
# sentry.io integration for error reporting # sentry.io integration for error reporting
SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False) SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False)

View File

@ -0,0 +1,8 @@
Hello {{username}},
You requested that we send you a link to log in to our app:
{{link}}
Regards,
{{site_name}}

View File

@ -11,12 +11,15 @@ import django.core.exceptions as django_exceptions
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core import mail
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse
from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money from djmoney.money import Money
from sesame.utils import get_user
import InvenTree.conversion import InvenTree.conversion
import InvenTree.format import InvenTree.format
@ -1061,3 +1064,38 @@ class SanitizerTest(TestCase):
# Test that invalid string is cleanded # Test that invalid string is cleanded
self.assertNotEqual(dangerous_string, sanitize_svg(dangerous_string)) self.assertNotEqual(dangerous_string, sanitize_svg(dangerous_string))
class MagicLoginTest(InvenTreeTestCase):
"""Test magic login token generation."""
def test_generation(self):
"""Test that magic login tokens are generated correctly"""
# User does not exists
resp = self.client.post(reverse('sesame-generate'), {'email': 1})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {'status': 'ok'})
self.assertEqual(len(mail.outbox), 0)
# User exists
resp = self.client.post(reverse('sesame-generate'), {'email': self.user.email})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.data, {'status': 'ok'})
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, '[example.com] Log in to the app')
# Check that the token is in the email
self.assertTrue('http://testserver/api/email/login/' in mail.outbox[0].body)
token = mail.outbox[0].body.split('/')[-1].split('\n')[0][8:]
self.assertEqual(get_user(token), self.user)
# Log user off
self.client.logout()
# Check that the login works
resp = self.client.get(reverse('sesame-login') + '?sesame=' + token)
self.assertEqual(resp.status_code, 302)
self.assertEqual(resp.url, '/platform/logged-in/')
# And we should be logged in again
self.assertEqual(resp.wsgi_request.user, self.user)

View File

@ -7,11 +7,13 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.views.decorators.csrf import csrf_exempt
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from dj_rest_auth.registration.views import (SocialAccountDisconnectView, from dj_rest_auth.registration.views import (SocialAccountDisconnectView,
SocialAccountListView) SocialAccountListView)
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from sesame.views import LoginView
from build.api import build_api_urls from build.api import build_api_urls
from build.urls import build_urls from build.urls import build_urls
@ -33,6 +35,7 @@ from stock.urls import stock_urls
from users.api import user_urls from users.api import user_urls
from .api import APISearchView, InfoView, NotFoundView from .api import APISearchView, InfoView, NotFoundView
from .magic_login import GetSimpleLoginView
from .social_auth_urls import SocialProvierListView, social_auth_urlpatterns from .social_auth_urls import SocialProvierListView, social_auth_urlpatterns
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView, from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
CustomEmailView, CustomLoginView, CustomEmailView, CustomLoginView,
@ -82,6 +85,10 @@ apipatterns = [
path('auth/social/', SocialAccountListView.as_view(), name='social_account_list'), path('auth/social/', SocialAccountListView.as_view(), name='social_account_list'),
path('auth/social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'), path('auth/social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
# Magic login URLs
path("email/generate/", csrf_exempt(GetSimpleLoginView().as_view()), name="sesame-generate",),
path("email/login/", LoginView.as_view(), name="sesame-login"),
# Unknown endpoint # Unknown endpoint
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'), re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
] ]

View File

@ -21,6 +21,7 @@ django-mptt==0.11.0 # Modified Preorder Tree Traversal
django-redis>=5.0.0 # Redis integration django-redis>=5.0.0 # Redis integration
django-q2 # Background task scheduling django-q2 # Background task scheduling
django-q-sentry # sentry.io integration for django-q django-q-sentry # sentry.io integration for django-q
django-sesame # Magic link authentication
django-sql-utils # Advanced query annotation / aggregation django-sql-utils # Advanced query annotation / aggregation
django-sslserver # Secure HTTP development server django-sslserver # Secure HTTP development server
django-stdimage<6.0.0 # Advanced ImageField management # FIXED 2022-06-29 6.0.0 breaks serialization for django-q django-stdimage<6.0.0 # Advanced ImageField management # FIXED 2022-06-29 6.0.0 breaks serialization for django-q

View File

@ -66,6 +66,7 @@ django==3.2.20
# django-q2 # django-q2
# django-recurrence # django-recurrence
# django-redis # django-redis
# django-sesame
# django-sql-utils # django-sql-utils
# django-sslserver # django-sslserver
# django-stdimage # django-stdimage
@ -124,6 +125,8 @@ django-recurrence==1.11.1
# via django-ical # via django-ical
django-redis==5.3.0 django-redis==5.3.0
# via -r requirements.in # via -r requirements.in
django-sesame==3.1
# via -r requirements.in
django-sql-utils==0.6.1 django-sql-utils==0.6.1
# via -r requirements.in # via -r requirements.in
django-sslserver==0.22 django-sslserver==0.22
@ -164,7 +167,7 @@ icalendar==5.0.7
# via django-ical # via django-ical
idna==3.4 idna==3.4
# via requests # via requests
importlib-metadata==6.7.0 importlib-metadata==6.8.0
# via markdown # via markdown
inflection==0.5.1 inflection==0.5.1
# via drf-spectacular # via drf-spectacular
@ -306,7 +309,7 @@ xlrd==2.0.1
# via tablib # via tablib
xlwt==1.3.0 xlwt==1.3.0
# via tablib # via tablib
zipp==3.15.0 zipp==3.16.0
# via importlib-metadata # via importlib-metadata
zopfli==0.2.2 zopfli==0.2.2
# via fonttools # via fonttools