From b700b44c53e59060363a0bd24667c99f8918311f Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 20 Jun 2023 08:26:02 +0200 Subject: [PATCH] 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 --- InvenTree/InvenTree/forms.py | 26 ++++- InvenTree/InvenTree/settings.py | 19 ++++ InvenTree/InvenTree/social_auth_urls.py | 127 ++++++++++++++++++++++++ InvenTree/InvenTree/urls.py | 11 ++ InvenTree/config_template.yaml | 5 + requirements.in | 2 + requirements.txt | 13 ++- 7 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 InvenTree/InvenTree/social_auth_urls.py diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 9efa535c93..fb5c37ebdc 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -21,6 +21,8 @@ from crispy_forms.bootstrap import (AppendedText, PrependedAppendedText, PrependedText) from crispy_forms.helper import FormHelper 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 InvenTree.exceptions import log_error @@ -206,6 +208,11 @@ class CustomSignupForm(SignupForm): 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: """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`. """ - 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 False @@ -319,3 +326,20 @@ class CustomSocialAccountAdapter(CustomUrlMixin, RegistratonMixin, DefaultSocial # Otherwise defer to the original allauth adapter. 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.')) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index fc30387d6a..68d8d45659 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -245,6 +245,8 @@ INSTALLED_APPS = [ 'django_otp.plugins.otp_static', # Backup codes '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 'django_ical', # For exporting calendars @@ -380,6 +382,23 @@ if DEBUG: # Enable browsable API if in DEBUG mode 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 = { 'TITLE': 'InvenTree API', 'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system', diff --git a/InvenTree/InvenTree/social_auth_urls.py b/InvenTree/InvenTree/social_auth_urls.py new file mode 100644 index 0000000000..78fed2f7c1 --- /dev/null +++ b/InvenTree/InvenTree/social_auth_urls.py @@ -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) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index e98268db9c..0cf125f32a 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -9,6 +9,8 @@ from django.contrib import admin from django.urls import include, path, re_path from django.views.generic.base import RedirectView +from dj_rest_auth.registration.views import (SocialAccountDisconnectView, + SocialAccountListView) from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView from build.api import build_api_urls @@ -31,6 +33,7 @@ from stock.urls import stock_urls from users.api import user_urls from .api import APISearchView, InfoView, NotFoundView +from .social_auth_urls import SocialProvierListView, social_auth_urlpatterns from .views import (AboutView, AppearanceSelectView, CustomConnectionsView, CustomEmailView, CustomLoginView, CustomPasswordResetFromKeyView, @@ -71,6 +74,14 @@ apipatterns = [ # InvenTree information endpoint 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//disconnect/', SocialAccountDisconnectView.as_view(), name='social_account_disconnect'), + # Unknown endpoint re_path(r'^.*$', NotFoundView.as_view(), name='api-404'), ] diff --git a/InvenTree/config_template.yaml b/InvenTree/config_template.yaml index 7795bb694f..2acbbfbba1 100644 --- a/InvenTree/config_template.yaml +++ b/InvenTree/config_template.yaml @@ -183,6 +183,11 @@ login_default_protocol: http remote_login_enabled: False 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 # 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 diff --git a/requirements.in b/requirements.in index a1a6525479..be13dc3807 100644 --- a/requirements.in +++ b/requirements.in @@ -28,7 +28,9 @@ django-taggit # Tagging support django-user-sessions # user sessions in DB django-weasyprint # django weasyprint integration djangorestframework # DRF framework +djangorestframework-simplejwt[crypto] # JWT authentication django-xforwardedfor-middleware # IP forwarding metadata +dj-rest-auth # Authentication API endpoints dulwich # pure Python git integration drf-spectacular # DRF API documentation feedparser # RSS newsfeed parser diff --git a/requirements.txt b/requirements.txt index c1d35125d5..71a9ea8a32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,6 +35,7 @@ coreschema==0.0.4 cryptography==41.0.1 # via # -r requirements.in + # djangorestframework-simplejwt # pyjwt cssselect2==0.7.0 # via weasyprint @@ -44,9 +45,12 @@ defusedxml==0.7.1 # python3-openid diff-match-patch==20230430 # via django-import-export +dj-rest-auth==4.0.1 + # via -r requirements.in django==3.2.19 # via # -r requirements.in + # dj-rest-auth # django-allauth # django-allauth-2fa # django-cors-headers @@ -74,6 +78,7 @@ django==3.2.19 # django-weasyprint # django-xforwardedfor-middleware # djangorestframework + # djangorestframework-simplejwt # drf-spectacular django-allauth==0.54.0 # via @@ -140,7 +145,11 @@ django-xforwardedfor-middleware==2.0 djangorestframework==3.14.0 # via # -r requirements.in + # dj-rest-auth + # djangorestframework-simplejwt # drf-spectacular +djangorestframework-simplejwt[crypto]==5.2.2 + # via -r requirements.in drf-spectacular==0.26.2 # via -r requirements.in dulwich==0.21.5 @@ -202,7 +211,9 @@ pycparser==2.21 pydyf==0.6.0 # via weasyprint pyjwt[crypto]==2.7.0 - # via django-allauth + # via + # django-allauth + # djangorestframework-simplejwt pyphen==0.14.0 # via weasyprint pypng==0.20220715.0