mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Add dj-rest-auth (#4187)
* Add dj-rest-auth [FR] User registration via API Fixes #3978 * add jwt support for API * check for old password * Add check if registration is allowed * make email mandatory if selected * lower postgres version? * update req * revert psql change * move form options out * Update reqs * Add handlers for most OAuth2 * refactor and add logging * make error message more actionable * add handler for twitter * add keycloak endpoint * warning for legacy apps * remove legacy twitter support * rename file * move url to sub * make JWT optional (default off) * Add var to config template * Add API endpoint to list available providers * fix url pattern
This commit is contained in:
parent
09cc654530
commit
b700b44c53
@ -21,6 +21,8 @@ from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText,
|
|||||||
PrependedText)
|
PrependedText)
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Field, Layout
|
from crispy_forms.layout import Field, Layout
|
||||||
|
from dj_rest_auth.registration.serializers import RegisterSerializer
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
@ -206,6 +208,11 @@ class CustomSignupForm(SignupForm):
|
|||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
def registration_enabled():
|
||||||
|
"""Determine whether user registration is enabled."""
|
||||||
|
return settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'))
|
||||||
|
|
||||||
|
|
||||||
class RegistratonMixin:
|
class RegistratonMixin:
|
||||||
"""Mixin to check if registration should be enabled."""
|
"""Mixin to check if registration should be enabled."""
|
||||||
|
|
||||||
@ -214,7 +221,7 @@ class RegistratonMixin:
|
|||||||
|
|
||||||
Configure the class variable `REGISTRATION_SETTING` to set which setting should be used, default: `LOGIN_ENABLE_REG`.
|
Configure the class variable `REGISTRATION_SETTING` to set which setting should be used, default: `LOGIN_ENABLE_REG`.
|
||||||
"""
|
"""
|
||||||
if settings.EMAIL_HOST and (InvenTreeSetting.get_setting('LOGIN_ENABLE_REG') or InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG')):
|
if registration_enabled():
|
||||||
return super().is_open_for_signup(request, *args, **kwargs)
|
return super().is_open_for_signup(request, *args, **kwargs)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -319,3 +326,20 @@ class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocial
|
|||||||
|
|
||||||
# Otherwise defer to the original allauth adapter.
|
# Otherwise defer to the original allauth adapter.
|
||||||
return super().login(request, user)
|
return super().login(request, user)
|
||||||
|
|
||||||
|
|
||||||
|
# override dj-rest-auth
|
||||||
|
class CustomRegisterSerializer(RegisterSerializer):
|
||||||
|
"""Override of serializer to use dynamic settings."""
|
||||||
|
email = serializers.EmailField()
|
||||||
|
|
||||||
|
def __init__(self, instance=None, data=..., **kwargs):
|
||||||
|
"""Check settings to influence which fields are needed."""
|
||||||
|
kwargs['email_required'] = InvenTreeSetting.get_setting('LOGIN_MAIL_REQUIRED')
|
||||||
|
super().__init__(instance, data, **kwargs)
|
||||||
|
|
||||||
|
def save(self, request):
|
||||||
|
"""Override to check if registration is open."""
|
||||||
|
if registration_enabled():
|
||||||
|
return super().save(request)
|
||||||
|
raise forms.ValidationError(_('Registration is disabled.'))
|
||||||
|
@ -245,6 +245,8 @@ INSTALLED_APPS = [
|
|||||||
'django_otp.plugins.otp_static', # Backup codes
|
'django_otp.plugins.otp_static', # Backup codes
|
||||||
|
|
||||||
'allauth_2fa', # MFA flow for allauth
|
'allauth_2fa', # MFA flow for allauth
|
||||||
|
'dj_rest_auth', # Authentication APIs - dj-rest-auth
|
||||||
|
'dj_rest_auth.registration', # Registration APIs - dj-rest-auth'
|
||||||
'drf_spectacular', # API documentation
|
'drf_spectacular', # API documentation
|
||||||
|
|
||||||
'django_ical', # For exporting calendars
|
'django_ical', # For exporting calendars
|
||||||
@ -380,6 +382,23 @@ if DEBUG:
|
|||||||
# Enable browsable API if in DEBUG mode
|
# Enable browsable API if in DEBUG mode
|
||||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer')
|
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'].append('rest_framework.renderers.BrowsableAPIRenderer')
|
||||||
|
|
||||||
|
# dj-rest-auth
|
||||||
|
# JWT switch
|
||||||
|
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
|
||||||
|
REST_USE_JWT = USE_JWT
|
||||||
|
OLD_PASSWORD_FIELD_ENABLED = True
|
||||||
|
REST_AUTH_REGISTER_SERIALIZERS = {'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'}
|
||||||
|
|
||||||
|
# JWT settings - rest_framework_simplejwt
|
||||||
|
if USE_JWT:
|
||||||
|
JWT_AUTH_COOKIE = 'inventree-auth'
|
||||||
|
JWT_AUTH_REFRESH_COOKIE = 'inventree-token'
|
||||||
|
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] + (
|
||||||
|
'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
|
||||||
|
)
|
||||||
|
INSTALLED_APPS.append('rest_framework_simplejwt')
|
||||||
|
|
||||||
|
# WSGI default setting
|
||||||
SPECTACULAR_SETTINGS = {
|
SPECTACULAR_SETTINGS = {
|
||||||
'TITLE': 'InvenTree API',
|
'TITLE': 'InvenTree API',
|
||||||
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
|
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
|
||||||
|
127
InvenTree/InvenTree/social_auth_urls.py
Normal file
127
InvenTree/InvenTree/social_auth_urls.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""API endpoints for social authentication with allauth."""
|
||||||
|
import logging
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
from django.urls import include, path, reverse
|
||||||
|
|
||||||
|
from allauth.socialaccount import providers
|
||||||
|
from allauth.socialaccount.models import SocialApp
|
||||||
|
from allauth.socialaccount.providers.keycloak.views import \
|
||||||
|
KeycloakOAuth2Adapter
|
||||||
|
from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter,
|
||||||
|
OAuth2LoginView)
|
||||||
|
from rest_framework.generics import ListAPIView
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
|
class GenericOAuth2ApiLoginView(OAuth2LoginView):
|
||||||
|
"""Api view to login a user with a social account"""
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""Dispatch the regular login view directly."""
|
||||||
|
return self.login(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class GenericOAuth2ApiConnectView(GenericOAuth2ApiLoginView):
|
||||||
|
"""Api view to connect a social account to the current user"""
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""Dispatch the connect request directly."""
|
||||||
|
|
||||||
|
# Override the request method be in connection mode
|
||||||
|
request.GET = request.GET.copy()
|
||||||
|
request.GET['process'] = 'connect'
|
||||||
|
|
||||||
|
# Resume the dispatch
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_oauth2(adapter: OAuth2Adapter):
|
||||||
|
"""Define urls for oauth2 endpoints."""
|
||||||
|
return [
|
||||||
|
path('login/', GenericOAuth2ApiLoginView.adapter_view(adapter), name=f'{provider.id}_api_login'),
|
||||||
|
path('connect/', GenericOAuth2ApiConnectView.adapter_view(adapter), name=f'{provider.id}_api_connect'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def handle_keycloak():
|
||||||
|
"""Define urls for keycloak."""
|
||||||
|
return [
|
||||||
|
path('login/', GenericOAuth2ApiLoginView.adapter_view(KeycloakOAuth2Adapter), name='keycloak_api_login'),
|
||||||
|
path('connect/', GenericOAuth2ApiConnectView.adapter_view(KeycloakOAuth2Adapter), name='keycloak_api_connet'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
legacy = {
|
||||||
|
'twitter': 'twitter_oauth2',
|
||||||
|
'bitbucket': 'bitbucket_oauth2',
|
||||||
|
'linkedin': 'linkedin_oauth2',
|
||||||
|
'vimeo': 'vimeo_oauth2',
|
||||||
|
'openid': 'openid_connect',
|
||||||
|
} # legacy connectors
|
||||||
|
|
||||||
|
|
||||||
|
# Collect urls for all loaded providers
|
||||||
|
social_auth_urlpatterns = []
|
||||||
|
|
||||||
|
provider_urlpatterns = []
|
||||||
|
for provider in providers.registry.get_list():
|
||||||
|
try:
|
||||||
|
prov_mod = import_module(provider.get_package() + ".views")
|
||||||
|
except ImportError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try to extract the adapter class
|
||||||
|
adapters = [cls for cls in prov_mod.__dict__.values() if isinstance(cls, type) and not cls == OAuth2Adapter and issubclass(cls, OAuth2Adapter)]
|
||||||
|
|
||||||
|
# Get urls
|
||||||
|
urls = []
|
||||||
|
if len(adapters) == 1:
|
||||||
|
urls = handle_oauth2(adapter=adapters[0])
|
||||||
|
else:
|
||||||
|
if provider.id in legacy:
|
||||||
|
logger.warning(f'`{provider.id}` is not supported on platform UI. Use `{legacy[provider.id]}` instead.')
|
||||||
|
continue
|
||||||
|
elif provider.id == 'keycloak':
|
||||||
|
urls = handle_keycloak()
|
||||||
|
else:
|
||||||
|
logger.error(f'Found handler that is not yet ready for platform UI: `{provider.id}`. Open an feature request on GitHub if you need it implemented.')
|
||||||
|
continue
|
||||||
|
provider_urlpatterns += [path(f'{provider.id}/', include(urls))]
|
||||||
|
|
||||||
|
|
||||||
|
social_auth_urlpatterns += provider_urlpatterns
|
||||||
|
|
||||||
|
|
||||||
|
class SocialProvierListView(ListAPIView):
|
||||||
|
"""List of available social providers."""
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Get the list of providers."""
|
||||||
|
provider_list = []
|
||||||
|
for provider in providers.registry.get_list():
|
||||||
|
provider_data = {
|
||||||
|
'id': provider.id,
|
||||||
|
'name': provider.name,
|
||||||
|
'login': request.build_absolute_uri(reverse(f'{provider.id}_api_login')),
|
||||||
|
'connect': request.build_absolute_uri(reverse(f'{provider.id}_api_connect')),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
provider_data['display_name'] = provider.get_app(request).name
|
||||||
|
except SocialApp.DoesNotExist:
|
||||||
|
provider_data['display_name'] = provider.name
|
||||||
|
|
||||||
|
provider_list.append(provider_data)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'sso_enabled': InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO'),
|
||||||
|
'sso_registration': InvenTreeSetting.get_setting('LOGIN_ENABLE_SSO_REG'),
|
||||||
|
'mfa_required': InvenTreeSetting.get_setting('LOGIN_ENFORCE_MFA'),
|
||||||
|
'providers': provider_list
|
||||||
|
}
|
||||||
|
return Response(data)
|
@ -9,6 +9,8 @@ from django.contrib import admin
|
|||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
from django.views.generic.base import RedirectView
|
from django.views.generic.base import RedirectView
|
||||||
|
|
||||||
|
from dj_rest_auth.registration.views import (SocialAccountDisconnectView,
|
||||||
|
SocialAccountListView)
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
|
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
|
||||||
|
|
||||||
from build.api import build_api_urls
|
from build.api import build_api_urls
|
||||||
@ -31,6 +33,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 .social_auth_urls import SocialProvierListView, social_auth_urlpatterns
|
||||||
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
|
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
|
||||||
CustomEmailView, CustomLoginView,
|
CustomEmailView, CustomLoginView,
|
||||||
CustomPasswordResetFromKeyView,
|
CustomPasswordResetFromKeyView,
|
||||||
@ -71,6 +74,14 @@ apipatterns = [
|
|||||||
# InvenTree information endpoint
|
# InvenTree information endpoint
|
||||||
path('', InfoView.as_view(), name='api-inventree-info'),
|
path('', InfoView.as_view(), name='api-inventree-info'),
|
||||||
|
|
||||||
|
# Third party API endpoints
|
||||||
|
path('auth/', include('dj_rest_auth.urls')),
|
||||||
|
path('auth/registration/', include('dj_rest_auth.registration.urls')),
|
||||||
|
path('auth/providers/', SocialProvierListView.as_view(), name='social_providers'),
|
||||||
|
path('auth/social/', include(social_auth_urlpatterns)),
|
||||||
|
path('auth/social/', SocialAccountListView.as_view(), name='social_account_list'),
|
||||||
|
path('auth/social/<int:pk>/disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'),
|
||||||
|
|
||||||
# Unknown endpoint
|
# Unknown endpoint
|
||||||
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
|
re_path(r'^.*$', NotFoundView.as_view(), name='api-404'),
|
||||||
]
|
]
|
||||||
|
@ -183,6 +183,11 @@ login_default_protocol: http
|
|||||||
remote_login_enabled: False
|
remote_login_enabled: False
|
||||||
remote_login_header: HTTP_REMOTE_USER
|
remote_login_header: HTTP_REMOTE_USER
|
||||||
|
|
||||||
|
# JWT tokens
|
||||||
|
# JWT can be used optionally to authenticate users. Turned off by default.
|
||||||
|
# Alternatively, use the environment variable INVENTREE_USE_JWT
|
||||||
|
# use_jwt: True
|
||||||
|
|
||||||
# Logout redirect configuration
|
# Logout redirect configuration
|
||||||
# This setting may be required if using remote / proxy login to redirect requests
|
# This setting may be required if using remote / proxy login to redirect requests
|
||||||
# during the logout process (default is 'index'). Please read the docs for more details
|
# during the logout process (default is 'index'). Please read the docs for more details
|
||||||
|
@ -28,7 +28,9 @@ django-taggit # Tagging support
|
|||||||
django-user-sessions # user sessions in DB
|
django-user-sessions # user sessions in DB
|
||||||
django-weasyprint # django weasyprint integration
|
django-weasyprint # django weasyprint integration
|
||||||
djangorestframework # DRF framework
|
djangorestframework # DRF framework
|
||||||
|
djangorestframework-simplejwt[crypto] # JWT authentication
|
||||||
django-xforwardedfor-middleware # IP forwarding metadata
|
django-xforwardedfor-middleware # IP forwarding metadata
|
||||||
|
dj-rest-auth # Authentication API endpoints
|
||||||
dulwich # pure Python git integration
|
dulwich # pure Python git integration
|
||||||
drf-spectacular # DRF API documentation
|
drf-spectacular # DRF API documentation
|
||||||
feedparser # RSS newsfeed parser
|
feedparser # RSS newsfeed parser
|
||||||
|
@ -35,6 +35,7 @@ coreschema==0.0.4
|
|||||||
cryptography==41.0.1
|
cryptography==41.0.1
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
|
# djangorestframework-simplejwt
|
||||||
# pyjwt
|
# pyjwt
|
||||||
cssselect2==0.7.0
|
cssselect2==0.7.0
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
@ -44,9 +45,12 @@ defusedxml==0.7.1
|
|||||||
# python3-openid
|
# python3-openid
|
||||||
diff-match-patch==20230430
|
diff-match-patch==20230430
|
||||||
# via django-import-export
|
# via django-import-export
|
||||||
|
dj-rest-auth==4.0.1
|
||||||
|
# via -r requirements.in
|
||||||
django==3.2.19
|
django==3.2.19
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
|
# dj-rest-auth
|
||||||
# django-allauth
|
# django-allauth
|
||||||
# django-allauth-2fa
|
# django-allauth-2fa
|
||||||
# django-cors-headers
|
# django-cors-headers
|
||||||
@ -74,6 +78,7 @@ django==3.2.19
|
|||||||
# django-weasyprint
|
# django-weasyprint
|
||||||
# django-xforwardedfor-middleware
|
# django-xforwardedfor-middleware
|
||||||
# djangorestframework
|
# djangorestframework
|
||||||
|
# djangorestframework-simplejwt
|
||||||
# drf-spectacular
|
# drf-spectacular
|
||||||
django-allauth==0.54.0
|
django-allauth==0.54.0
|
||||||
# via
|
# via
|
||||||
@ -140,7 +145,11 @@ django-xforwardedfor-middleware==2.0
|
|||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
|
# dj-rest-auth
|
||||||
|
# djangorestframework-simplejwt
|
||||||
# drf-spectacular
|
# drf-spectacular
|
||||||
|
djangorestframework-simplejwt[crypto]==5.2.2
|
||||||
|
# via -r requirements.in
|
||||||
drf-spectacular==0.26.2
|
drf-spectacular==0.26.2
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
dulwich==0.21.5
|
dulwich==0.21.5
|
||||||
@ -202,7 +211,9 @@ pycparser==2.21
|
|||||||
pydyf==0.6.0
|
pydyf==0.6.0
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
pyjwt[crypto]==2.7.0
|
pyjwt[crypto]==2.7.0
|
||||||
# via django-allauth
|
# via
|
||||||
|
# django-allauth
|
||||||
|
# djangorestframework-simplejwt
|
||||||
pyphen==0.14.0
|
pyphen==0.14.0
|
||||||
# via weasyprint
|
# via weasyprint
|
||||||
pypng==0.20220715.0
|
pypng==0.20220715.0
|
||||||
|
Loading…
Reference in New Issue
Block a user