diff --git a/InvenTree/InvenTree/magic_login.py b/InvenTree/InvenTree/magic_login.py new file mode 100644 index 0000000000..b48c3f3547 --- /dev/null +++ b/InvenTree/InvenTree/magic_login.py @@ -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 diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py index a8a51404d2..8e28afc4e8 100644 --- a/InvenTree/InvenTree/middleware.py +++ b/InvenTree/InvenTree/middleware.py @@ -64,6 +64,9 @@ class AuthRequiredMiddleware(object): elif request.path_info.startswith('/accounts/'): 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(): auth = request.headers.get('Authorization', request.headers.get('authorization')).strip() diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 5692fd4575..33428d64f4 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -276,6 +276,7 @@ AUTHENTICATION_BACKENDS = CONFIG.get('authentication_backends', [ 'django.contrib.auth.backends.RemoteUserBackend', # proxy login 'django.contrib.auth.backends.ModelBackend', '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) @@ -601,6 +602,10 @@ DATABASES = { 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') +# Magic login django-sesame +SESAME_MAX_AGE = 300 +LOGIN_REDIRECT_URL = "/platform/logged-in/" + # sentry.io integration for error reporting SENTRY_ENABLED = get_boolean_setting('INVENTREE_SENTRY_ENABLED', 'sentry_enabled', False) diff --git a/InvenTree/InvenTree/templates/InvenTree/user_simple_login.txt b/InvenTree/InvenTree/templates/InvenTree/user_simple_login.txt new file mode 100644 index 0000000000..e00a971a10 --- /dev/null +++ b/InvenTree/InvenTree/templates/InvenTree/user_simple_login.txt @@ -0,0 +1,8 @@ +Hello {{username}}, + +You requested that we send you a link to log in to our app: + + {{link}} + +Regards, +{{site_name}} diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index a68f7c4739..6b979fed51 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -11,12 +11,15 @@ import django.core.exceptions as django_exceptions from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.sites.models import Site +from django.core import mail from django.core.exceptions import ValidationError from django.test import TestCase, override_settings +from django.urls import reverse from djmoney.contrib.exchange.exceptions import MissingRate from djmoney.contrib.exchange.models import Rate, convert_money from djmoney.money import Money +from sesame.utils import get_user import InvenTree.conversion import InvenTree.format @@ -1061,3 +1064,38 @@ class SanitizerTest(TestCase): # Test that invalid string is cleanded 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) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 4098359bc7..7b3c0b25ac 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -7,11 +7,13 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path, re_path +from django.views.decorators.csrf import csrf_exempt from django.views.generic.base import RedirectView from dj_rest_auth.registration.views import (SocialAccountDisconnectView, SocialAccountListView) from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView +from sesame.views import LoginView from build.api import build_api_urls from build.urls import build_urls @@ -33,6 +35,7 @@ from stock.urls import stock_urls from users.api import user_urls from .api import APISearchView, InfoView, NotFoundView +from .magic_login import GetSimpleLoginView from .social_auth_urls import SocialProvierListView, social_auth_urlpatterns from .views import (AboutView, AppearanceSelectView, CustomConnectionsView, CustomEmailView, CustomLoginView, @@ -82,6 +85,10 @@ apipatterns = [ path('auth/social/', SocialAccountListView.as_view(), name='social_account_list'), path('auth/social//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 re_path(r'^.*$', NotFoundView.as_view(), name='api-404'), ] diff --git a/requirements.in b/requirements.in index 2c906d380c..3f6350b105 100644 --- a/requirements.in +++ b/requirements.in @@ -21,6 +21,7 @@ django-mptt==0.11.0 # Modified Preorder Tree Traversal django-redis>=5.0.0 # Redis integration django-q2 # Background task scheduling django-q-sentry # sentry.io integration for django-q +django-sesame # Magic link authentication django-sql-utils # Advanced query annotation / aggregation 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 diff --git a/requirements.txt b/requirements.txt index 1234066fae..2b6d481ba7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -66,6 +66,7 @@ django==3.2.20 # django-q2 # django-recurrence # django-redis + # django-sesame # django-sql-utils # django-sslserver # django-stdimage @@ -124,6 +125,8 @@ django-recurrence==1.11.1 # via django-ical django-redis==5.3.0 # via -r requirements.in +django-sesame==3.1 + # via -r requirements.in django-sql-utils==0.6.1 # via -r requirements.in django-sslserver==0.22 @@ -164,7 +167,7 @@ icalendar==5.0.7 # via django-ical idna==3.4 # via requests -importlib-metadata==6.7.0 +importlib-metadata==6.8.0 # via markdown inflection==0.5.1 # via drf-spectacular @@ -306,7 +309,7 @@ xlrd==2.0.1 # via tablib xlwt==1.3.0 # via tablib -zipp==3.15.0 +zipp==3.16.0 # via importlib-metadata zopfli==0.2.2 # via fonttools