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/'):
|
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()
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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.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)
|
||||||
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user