mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
92b0a19270
commit
a9a8ac1c70
75
InvenTree/InvenTree/magic_login.py
Normal file
75
InvenTree/InvenTree/magic_login.py
Normal 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
|
@ -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()
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -0,0 +1,8 @@
|
||||
Hello {{username}},
|
||||
|
||||
You requested that we send you a link to log in to our app:
|
||||
|
||||
{{link}}
|
||||
|
||||
Regards,
|
||||
{{site_name}}
|
@ -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)
|
||||
|
@ -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/<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
|
||||
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user