diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index 7017e33be4..45714832f6 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -380,6 +380,7 @@ InvenTree provides allowance for additional sign-in options. The following optio | Environment Variable | Configuration File | Description | Default | | --- | --- | --- | --- | | INVENTREE_MFA_ENABLED | mfa_enabled | Enable or disable multi-factor authentication support for the InvenTree server | True | +| INVENTREE_MFA_SUPPORTED_TYPES | mfa_supported_types | List of supported multi-factor authentication types | recovery_codes,totp | ### Single Sign On diff --git a/src/backend/InvenTree/InvenTree/AllUserRequire2FAMiddleware.py b/src/backend/InvenTree/InvenTree/AllUserRequire2FAMiddleware.py new file mode 100644 index 0000000000..d0db070a9d --- /dev/null +++ b/src/backend/InvenTree/InvenTree/AllUserRequire2FAMiddleware.py @@ -0,0 +1,74 @@ +"""Middleware to require 2FA for users.""" + +from django.http import HttpRequest, HttpResponse, JsonResponse +from django.utils.deprecation import MiddlewareMixin +from django.utils.translation import gettext_lazy as _ + +from allauth.account.authentication import get_authentication_records +from allauth.mfa.utils import is_mfa_enabled +from allauth.mfa.webauthn.internal.flows import did_use_passwordless_login + + +def is_multifactor_logged_in(request: HttpRequest) -> bool: + """Check if the user is logged in with multifactor authentication.""" + authns = get_authentication_records(request) + + return is_mfa_enabled(request.user) and ( + did_use_passwordless_login(request) + or any(record.get('method') == 'mfa' for record in authns) + ) + + +class AllUserRequire2FAMiddleware(MiddlewareMixin): + """Ensure that users have two-factor authentication enabled before they have access restricted endpoints. + + Adapted from https://github.com/pennersr/django-allauth/issues/3649 + """ + + allowed_pages = [ + 'api-user-meta', + 'api-user-me', + 'api-user-roles', + 'api-inventree-info', + 'api-token', + # web platform urls + 'password_reset_confirm', + 'platform', + 'platform-wildcard', + 'web-assets', + ] + app_names = ['headless'] + require_2fa_message = _( + 'You must enable two-factor authentication before doing anything else.' + ) + + def on_require_2fa(self, request: HttpRequest) -> HttpResponse: + """Force user to mfa activation.""" + return JsonResponse({'id': 'mfa_register'}, status=401) + + def is_allowed_page(self, request: HttpRequest) -> bool: + """Check if the current page can be accessed without mfa.""" + return ( + any(ref in self.app_names for ref in request.resolver_match.app_names) + or request.resolver_match.url_name in self.allowed_pages + or request.resolver_match.route == 'favicon.ico' + ) + + def enforce_2fa(self, request): + """Check if 2fa should be enforced for this request.""" + return True + + def process_view( + self, request: HttpRequest, view_func, view_args, view_kwargs + ) -> HttpResponse: + """If set up enforce 2fa registration.""" + if request.user.is_anonymous: + return None + if self.is_allowed_page(request): + return None + if is_multifactor_logged_in(request): + return None + + if self.enforce_2fa(request): + return self.on_require_2fa(request) + return None diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 9b60db5693..f4d528e5ad 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -20,8 +20,11 @@ from rest_framework.views import APIView import InvenTree.version import users.models +from common.settings import get_global_setting from InvenTree import helpers +from InvenTree.auth_overrides import registration_enabled from InvenTree.mixins import ListCreateAPI +from InvenTree.sso import sso_registration_enabled from part.models import Part from plugin.serializers import MetadataSerializer from users.models import ApiToken @@ -197,6 +200,13 @@ class VersionTextView(ListAPI): class InfoApiSerializer(serializers.Serializer): """InvenTree server information - some information might be blanked if called without elevated credentials.""" + class SettingsSerializer(serializers.Serializer): + """Serializer for InfoApiSerializer.""" + + sso_registration = serializers.BooleanField() + registration_enabled = serializers.BooleanField() + password_forgotten_enabled = serializers.BooleanField() + class CustomizeSerializer(serializers.Serializer): """Serializer for customize field.""" @@ -229,6 +239,7 @@ class InfoApiSerializer(serializers.Serializer): installer = serializers.CharField(read_only=True) target = serializers.CharField(read_only=True) django_admin = serializers.CharField(read_only=True) + settings = SettingsSerializer(read_only=True, many=False) class InfoView(APIView): @@ -288,6 +299,13 @@ class InfoView(APIView): 'django_admin': settings.INVENTREE_ADMIN_URL if (is_staff and settings.INVENTREE_ADMIN_ENABLED) else None, + 'settings': { + 'sso_registration': sso_registration_enabled(), + 'registration_enabled': registration_enabled(), + 'password_forgotten_enabled': get_global_setting( + 'LOGIN_ENABLE_PWD_FORGOT' + ), + }, } return JsonResponse(data) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f06d7958e8..91d3ccfe15 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 313 +INVENTREE_API_VERSION = 314 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v314 - 2025-02-17 : https://github.com/inventree/InvenTree/pull/6293 + - Removes a considerable amount of old auth endpoints + - Introduces allauth-provided auth endpoints + v313 - 2025-02-17 : https://github.com/inventree/InvenTree/pull/9087 - Adds instance id optionally to the info view endpoint diff --git a/src/backend/InvenTree/InvenTree/auth_override_views.py b/src/backend/InvenTree/InvenTree/auth_override_views.py deleted file mode 100644 index 3362c3bf07..0000000000 --- a/src/backend/InvenTree/InvenTree/auth_override_views.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Overrides for registration view.""" - -from django.utils.translation import gettext_lazy as _ - -from allauth.account import app_settings as allauth_account_settings -from dj_rest_auth.app_settings import api_settings -from dj_rest_auth.registration.views import RegisterView - - -class CustomRegisterView(RegisterView): - """Registers a new user. - - Accepts the following POST parameters: username, email, password1, password2. - """ - - # Fixes https://github.com/inventree/InvenTree/issues/8707 - # This contains code from dj-rest-auth 7.0 - therefore the version was pinned - def get_response_data(self, user): - """Override to fix check for auth_model.""" - if ( - allauth_account_settings.EMAIL_VERIFICATION - == allauth_account_settings.EmailVerificationMethod.MANDATORY - ): - return {'detail': _('Verification e-mail sent.')} - - if api_settings.USE_JWT: - data = { - 'user': user, - 'access': self.access_token, - 'refresh': self.refresh_token, - } - return api_settings.JWT_SERIALIZER( - data, context=self.get_serializer_context() - ).data - elif self.token_model: - # Only change in this block is below - return api_settings.TOKEN_SERIALIZER( - user.api_tokens.last(), context=self.get_serializer_context() - ).data - return None diff --git a/src/backend/InvenTree/InvenTree/auth_overrides.py b/src/backend/InvenTree/InvenTree/auth_overrides.py index 2aa7283ae4..7e3e4a414f 100644 --- a/src/backend/InvenTree/InvenTree/auth_overrides.py +++ b/src/backend/InvenTree/InvenTree/auth_overrides.py @@ -1,10 +1,9 @@ """Overrides for allauth and adjacent packages to enforce InvenTree specific auth settings and restirctions.""" -from urllib.parse import urlencode - from django import forms from django.conf import settings from django.contrib.auth.models import Group +from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -12,20 +11,15 @@ from django.utils.translation import gettext_lazy as _ import structlog from allauth.account.adapter import DefaultAccountAdapter from allauth.account.forms import LoginForm, SignupForm, set_form_field_order -from allauth.core.exceptions import ImmediateHttpResponse +from allauth.headless.adapter import DefaultHeadlessAdapter +from allauth.headless.tokens.sessions import SessionTokenStrategy from allauth.socialaccount.adapter import DefaultSocialAccountAdapter -from allauth_2fa.adapter import OTPAdapter -from allauth_2fa.forms import TOTPDeviceForm -from allauth_2fa.utils import user_has_valid_totp_device -from dj_rest_auth.registration.serializers import ( - RegisterSerializer as DjRestRegisterSerializer, -) -from rest_framework import serializers import InvenTree.helpers_model import InvenTree.sso from common.settings import get_global_setting from InvenTree.exceptions import log_error +from users.models import ApiToken logger = structlog.get_logger('inventree') @@ -92,16 +86,6 @@ class CustomSignupForm(SignupForm): return cleaned_data -class CustomTOTPDeviceForm(TOTPDeviceForm): - """Ensure that db registration is enabled.""" - - def __init__(self, user, metadata=None, **kwargs): - """Override to check if registration is open.""" - if not settings.MFA_ENABLED: - raise forms.ValidationError(_('MFA Registration is disabled.')) - super().__init__(user, metadata, **kwargs) - - def registration_enabled(): """Determine whether user registration is enabled.""" if ( @@ -177,19 +161,7 @@ class RegistrationMixin: return user -class CustomUrlMixin: - """Mixin to set urls.""" - - def get_email_confirmation_url(self, request, emailconfirmation): - """Custom email confirmation (activation) url.""" - url = reverse('account_confirm_email', args=[emailconfirmation.key]) - - return InvenTree.helpers_model.construct_absolute_url(url) - - -class CustomAccountAdapter( - CustomUrlMixin, RegistrationMixin, OTPAdapter, DefaultAccountAdapter -): +class CustomAccountAdapter(RegistrationMixin, DefaultAccountAdapter): """Override of adapter to use dynamic settings.""" def send_mail(self, template_prefix, email, context): @@ -207,16 +179,14 @@ class CustomAccountAdapter( return False - def get_email_confirmation_url(self, request, emailconfirmation): - """Construct the email confirmation url.""" - url = super().get_email_confirmation_url(request, emailconfirmation) - url = InvenTree.helpers_model.construct_absolute_url(url) - return url + def send_password_reset_mail(self, user, email, context): + """Send the password reset mail.""" + if not get_global_setting('LOGIN_ENABLE_PWD_FORGOT'): + raise PermissionDenied('Password reset is disabled') + return super().send_password_reset_mail(user, email, context) -class CustomSocialAccountAdapter( - CustomUrlMixin, RegistrationMixin, DefaultSocialAccountAdapter -): +class CustomSocialAccountAdapter(RegistrationMixin, DefaultSocialAccountAdapter): """Override of adapter to use dynamic settings.""" def is_auto_signup_allowed(self, request, sociallogin): @@ -225,29 +195,6 @@ class CustomSocialAccountAdapter( return super().is_auto_signup_allowed(request, sociallogin) return False - # from OTPAdapter - def has_2fa_enabled(self, user): - """Returns True if the user has 2FA configured.""" - return user_has_valid_totp_device(user) - - def login(self, request, user): - """Ensure user is send to 2FA before login if enabled.""" - # Require two-factor authentication if it has been configured. - if self.has_2fa_enabled(user): - # Cast to string for the case when this is not a JSON serializable - # object, e.g. a UUID. - request.session['allauth_2fa_user_id'] = str(user.id) - - redirect_url = reverse('two-factor-authenticate') - # Add GET parameters to the URL if they exist. - if request.GET: - redirect_url += '?' + urlencode(request.GET) - - raise ImmediateHttpResponse(response=HttpResponseRedirect(redirect_url)) - - # Otherwise defer to the original allauth adapter. - return super().login(request, user) - def authentication_error( self, request, provider_id, error=None, exception=None, extra_context=None ): @@ -264,15 +211,37 @@ class CustomSocialAccountAdapter( log_error(path, error_name=error, error_data=exception) logger.error("SSO error for provider '%s' - check admin error log", provider_id) + def get_connect_redirect_url(self, request, socialaccount): + """Redirect to the frontend after connecting an account.""" + return request.build_absolute_uri(f'/{settings.FRONTEND_URL_BASE}/') -# override dj-rest-auth -class RegisterSerializer(DjRestRegisterSerializer): - """Registration requires email, password (twice) and username.""" - email = serializers.EmailField() +class CustomHeadlessAdapter(DefaultHeadlessAdapter): + """Override of adapter to use dynamic settings.""" - def save(self, request): - """Override to check if registration is open.""" - if registration_enabled(): - return super().save(request) - raise forms.ValidationError(_('Registration is disabled.')) + def get_frontend_url(self, urlname, **kwargs): + """Get the frontend URL for the given URL name respecting the request.""" + HEADLESS_FRONTEND_URLS = { + 'account_confirm_email': 'verify-email/{key}', + 'account_reset_password': 'reset-password', + 'account_reset_password_from_key': 'set-password?key={key}', + 'account_signup': 'register', + 'socialaccount_login_error': 'social-login-error', + } + if urlname not in HEADLESS_FRONTEND_URLS: + raise ValueError( + f'URL name "{urlname}" not found in HEADLESS_FRONTEND_URLS' + ) + + return self.request.build_absolute_uri( + f'/{settings.FRONTEND_URL_BASE}/{HEADLESS_FRONTEND_URLS[urlname].format(**kwargs)}' + ) + + +class DRFTokenStrategy(SessionTokenStrategy): + """Strategy that InvenTrees own included Token model.""" + + def create_access_token(self, request): + """Create a new access token for the user.""" + token, _ = ApiToken.objects.get_or_create(user=request.user) + return token.key diff --git a/src/backend/InvenTree/InvenTree/magic_login.py b/src/backend/InvenTree/InvenTree/magic_login.py index bc4e7c6ece..fb4e9a86ce 100644 --- a/src/backend/InvenTree/InvenTree/magic_login.py +++ b/src/backend/InvenTree/InvenTree/magic_login.py @@ -8,12 +8,15 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ import sesame.utils +import structlog from rest_framework import serializers from rest_framework.generics import GenericAPIView from rest_framework.response import Response import InvenTree.version +logger = structlog.get_logger('inventree') + def send_simple_login_email(user, link): """Send an email with the login link to this user.""" @@ -55,7 +58,7 @@ class GetSimpleLoginView(GenericAPIView): """Notify user about link.""" user = self.get_user(email) if user is None: - print('user not found:', email) + logger.warning('User email not found: %s', email) return link = self.create_link(user) send_simple_login_email(user, link) diff --git a/src/backend/InvenTree/InvenTree/management/commands/schema.py b/src/backend/InvenTree/InvenTree/management/commands/schema.py new file mode 100644 index 0000000000..ceff5c4f40 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/management/commands/schema.py @@ -0,0 +1,110 @@ +"""Check if there are any pending database migrations, and run them.""" + +from pathlib import Path +from typing import TypeVar + +from django.conf import settings + +import structlog +import yaml +from drf_spectacular.management.commands import spectacular + +T = TypeVar('T') +logger = structlog.get_logger('inventree') + + +dja_path_prefix = '/_allauth/{client}/v1/' +dja_ref_prefix = 'allauth' +dja_clean_params = [ + '#/components/parameters/allauth.SessionToken', + '#/components/parameters/allauth.Client', +] + + +def prep_name(ref): + """Prepend django-allauth to all ref names.""" + return f'{dja_ref_prefix}.{ref}' + + +def sub_component_name(name: T) -> T: + """Clean up component references.""" + if not isinstance(name, str): + return name + s = name.split('/') + if len(s) == 4 and s[1] == 'components': + s[3] = prep_name(s[3]) + return '/'.join(s) + + +def clean_params(params): + """Clean refs of unwanted parameters. + + We don't use them in our API, we only support allauths browser APIs endpoints. + """ + return [p for p in params if p['$ref'] not in dja_clean_params] + + +class Command(spectacular.Command): + """Overwritten command to include django-allauth schemas.""" + + def proccess_refs(self, value): + """Prepend ref names.""" + if isinstance(value, str): + return sub_component_name(value) + elif isinstance(value, list): + return [self.proccess_refs(v) for v in value] + elif isinstance(value, dict): + return {k: self.proccess_refs(v) for k, v in value.items()} + return value + + def handle(self, *args, **kwargs): + """Extended schema generation that patches in django-allauth schemas.""" + from allauth.headless.spec.internal import schema + + # gather paths + org_path = Path(schema.__file__).parent.parent / 'doc/openapi.yaml' + with open(org_path, 'rb') as f: + spec = yaml.safe_load(f) + + paths = {} + # Reformat paths + for p_name, p_spec in spec['paths'].items(): + # strip path name + p_name = p_name.removeprefix(dja_path_prefix).removeprefix( + '/_allauth/browser/v1/' + ) + + # fix refs + for m_name, m_spec in p_spec.items(): + if m_spec.get('operationId', None) is None: + m_spec['operationId'] = ( + f'{dja_ref_prefix}_{p_name.replace("/", "_")}_{m_name}' + ) + # update all refs + for key, value in m_spec.items(): + if key in ['parameters', 'responses', 'requestBody']: + m_spec[key] = self.proccess_refs(value) + + # patch out unwanted parameters - we don't use it + if params := m_spec.get('parameters', None): + m_spec['parameters'] = clean_params(params) + + # prefix path name + paths[f'/api/auth/v1/{p_name}'] = p_spec + settings.SPECTACULAR_SETTINGS['APPEND_PATHS'] = paths + + components = {} + # Reformat components + for c_name, c_spec in spec['components'].items(): + new_component = {} + for sc_name, sc_spec in c_spec.items(): + new_component[prep_name(sc_name)] = self.proccess_refs(sc_spec) + components[c_name] = new_component + + # Remove unused parameters + for p in dja_clean_params: + components['parameters'].pop(p.replace('#/components/parameters/', '')) + + settings.SPECTACULAR_SETTINGS['APPEND_COMPONENTS'] = components + + super().handle(*args, **kwargs) diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index 2d9f27a677..a5da9fcf96 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -6,16 +6,15 @@ from django.conf import settings from django.contrib.auth.middleware import PersistentRemoteUserMiddleware from django.http import HttpResponse from django.shortcuts import redirect -from django.urls import Resolver404, include, path, resolve, reverse_lazy +from django.urls import resolve, reverse_lazy from django.utils.deprecation import MiddlewareMixin import structlog -from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware from error_report.middleware import ExceptionProcessor from common.settings import get_global_setting +from InvenTree.AllUserRequire2FAMiddleware import AllUserRequire2FAMiddleware from InvenTree.cache import create_session_cache, delete_session_cache -from InvenTree.urls import frontendpatterns from users.models import ApiToken logger = structlog.get_logger('inventree') @@ -137,32 +136,12 @@ class AuthRequiredMiddleware: return response -url_matcher = path('', include(frontendpatterns)) +class Check2FAMiddleware(AllUserRequire2FAMiddleware): + """Ensure that mfa is enforced if set so.""" - -class Check2FAMiddleware(BaseRequire2FAMiddleware): - """Check if user is required to have MFA enabled.""" - - def require_2fa(self, request): - """Use setting to check if MFA should be enforced for frontend page.""" - try: - if url_matcher.resolve(request.path[1:]): - return get_global_setting('LOGIN_ENFORCE_MFA') - except Resolver404: - pass - return False - - -class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware): - """This function ensures only frontend code triggers the MFA auth cycle.""" - - def process_request(self, request): - """Check if requested url is frontend and enforce MFA check.""" - try: - if not url_matcher.resolve(request.path[1:]): - super().process_request(request) - except Resolver404: - pass + def enforce_2fa(self, request): + """Use setting to check if MFA should be enforced.""" + return get_global_setting('LOGIN_ENFORCE_MFA') class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware): diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 7e00be9fac..e5029cb385 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -18,7 +18,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import django.conf.locale import django.core.exceptions from django.core.validators import URLValidator -from django.http import Http404 +from django.http import Http404, HttpResponseGone import structlog from dotenv import load_dotenv @@ -63,7 +63,6 @@ CONFIG = config.load_config_data(set_cache=True) # Load VERSION data if it exists version_file = BASE_DIR.parent.parent.parent.joinpath('VERSION') if version_file.exists(): - print('load version from file') load_dotenv(version_file) # Default action is to run the system in Debug mode @@ -266,7 +265,8 @@ INSTALLED_APPS = [ # Core django modules 'django.contrib.auth', 'django.contrib.contenttypes', - 'user_sessions', # db user sessions + 'django.contrib.sessions', + 'django.contrib.humanize', 'whitenoise.runserver_nostatic', 'django.contrib.messages', 'django.contrib.staticfiles', @@ -290,13 +290,13 @@ INSTALLED_APPS = [ 'django_structlog', # Structured logging 'allauth', # Base app for SSO 'allauth.account', # Extend user with accounts + 'allauth.headless', # APIs for auth 'allauth.socialaccount', # Use 'social' providers + 'allauth.mfa', # MFA for for allauth + 'allauth.usersessions', # DB sessions 'django_otp', # OTP is needed for MFA - base package 'django_otp.plugins.otp_totp', # Time based OTP '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 ] @@ -306,7 +306,8 @@ MIDDLEWARE = CONFIG.get( [ 'django.middleware.security.SecurityMiddleware', 'x_forwarded_for.middleware.XForwardedForMiddleware', - 'user_sessions.middleware.SessionMiddleware', # db user sessions + 'django.contrib.sessions.middleware.SessionMiddleware', + 'allauth.usersessions.middleware.UserSessionsMiddleware', # DB user sessions 'django.middleware.locale.LocaleMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'corsheaders.middleware.CorsMiddleware', @@ -314,8 +315,6 @@ MIDDLEWARE = CONFIG.get( 'django.middleware.common.CommonMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth - 'django_otp.middleware.OTPMiddleware', # MFA support - 'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth 'allauth.account.middleware.AccountMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', @@ -548,31 +547,13 @@ if DEBUG: 'rest_framework.renderers.BrowsableAPIRenderer' ) -# JWT switch -USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False) -REST_USE_JWT = USE_JWT - -# dj-rest-auth -REST_AUTH = { - 'SESSION_LOGIN': True, - 'TOKEN_MODEL': 'users.models.ApiToken', - 'TOKEN_CREATOR': 'users.models.default_create_token', - 'USE_JWT': USE_JWT, - 'REGISTER_SERIALIZER': 'InvenTree.auth_overrides.RegisterSerializer', -} - -OLD_PASSWORD_FIELD_ENABLED = True - # JWT settings - rest_framework_simplejwt +USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False) if USE_JWT: JWT_AUTH_COOKIE = 'inventree-auth' JWT_AUTH_REFRESH_COOKIE = 'inventree-token' - REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'].append( - 'dj_rest_auth.jwt_auth.JWTCookieAuthentication' - ) INSTALLED_APPS.append('rest_framework_simplejwt') - # WSGI default setting WSGI_APPLICATION = 'InvenTree.wsgi.application' @@ -911,13 +892,8 @@ if GLOBAL_CACHE_ENABLED: # pragma: no cover # as well Q_CLUSTER['django_redis'] = 'worker' -# database user sessions -SESSION_ENGINE = 'user_sessions.backends.db' -LOGOUT_REDIRECT_URL = get_setting( - 'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index' -) -SILENCED_SYSTEM_CHECKS = ['admin.E410', 'templates.E003', 'templates.W003'] +SILENCED_SYSTEM_CHECKS = ['templates.E003', 'templates.W003'] # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators @@ -1192,6 +1168,7 @@ USE_X_FORWARDED_PORT = get_boolean_setting( # Refer to the django-cors-headers documentation for more information # Ref: https://github.com/adamchainz/django-cors-headers + # Extract CORS options from configuration file CORS_ALLOW_ALL_ORIGINS = get_boolean_setting( 'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=DEBUG @@ -1238,6 +1215,11 @@ else: if CORS_ALLOWED_ORIGIN_REGEXES: logger.info('CORS: Whitelisted origin regexes: %s', CORS_ALLOWED_ORIGIN_REGEXES) +# Load settings for the frontend interface +FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG) +FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url'] + +# region auth for app in SOCIAL_BACKENDS: # Ensure that the app starts with 'allauth.socialaccount.providers' social_prefix = 'allauth.socialaccount.providers.' @@ -1262,6 +1244,9 @@ SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX = '' ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting( 'INVENTREE_LOGIN_CONFIRM_DAYS', 'login_confirm_days', 3, typecast=int ) +ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False +ACCOUNT_EMAIL_NOTIFICATIONS = True +USERSESSIONS_TRACK_ACTIVITY = True # allauth rate limiting: https://docs.allauth.org/en/latest/account/rate_limits.html # The default login rate limit is "5/m/user,5/m/ip,5/m/key" @@ -1306,12 +1291,27 @@ ACCOUNT_FORMS = { 'disconnect': 'allauth.socialaccount.forms.DisconnectForm', } -ALLAUTH_2FA_FORMS = {'setup': 'InvenTree.auth_overrides.CustomTOTPDeviceForm'} -# Determine if multi-factor authentication is enabled for this server (default = True) -MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True) - SOCIALACCOUNT_ADAPTER = 'InvenTree.auth_overrides.CustomSocialAccountAdapter' ACCOUNT_ADAPTER = 'InvenTree.auth_overrides.CustomAccountAdapter' +HEADLESS_ADAPTER = 'InvenTree.auth_overrides.CustomHeadlessAdapter' +ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True + +HEADLESS_ONLY = True +HEADLESS_TOKEN_STRATEGY = 'InvenTree.auth_overrides.DRFTokenStrategy' +MFA_ENABLED = get_boolean_setting( + 'INVENTREE_MFA_ENABLED', 'mfa_enabled', True +) # TODO re-implement +MFA_SUPPORTED_TYPES = get_setting( + 'INVENTREE_MFA_SUPPORTED_TYPES', + 'mfa_supported_types', + ['totp', 'recovery_codes'], + typecast=list, +) + +LOGOUT_REDIRECT_URL = get_setting( + 'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index' +) +# endregion auth # Markdownify configuration # Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html @@ -1354,7 +1354,7 @@ MARKDOWNIFY = { } # Ignore these error types for in-database error logging -IGNORED_ERRORS = [Http404, django.core.exceptions.PermissionDenied] +IGNORED_ERRORS = [Http404, HttpResponseGone, django.core.exceptions.PermissionDenied] # Maintenance mode MAINTENANCE_MODE_RETRY_AFTER = 10 @@ -1378,10 +1378,6 @@ CUSTOMIZE = get_setting( 'INVENTREE_CUSTOMIZE', 'customize', default_value=None, typecast=dict ) -# Load settings for the frontend interface -FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG) -FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url'] - if DEBUG: logger.info('InvenTree running with DEBUG enabled') diff --git a/src/backend/InvenTree/InvenTree/social_auth_urls.py b/src/backend/InvenTree/InvenTree/social_auth_urls.py deleted file mode 100644 index a83deaad77..0000000000 --- a/src/backend/InvenTree/InvenTree/social_auth_urls.py +++ /dev/null @@ -1,293 +0,0 @@ -"""API endpoints for social authentication with allauth.""" - -from importlib import import_module - -from django.conf import settings -from django.urls import NoReverseMatch, include, path, reverse - -import allauth.socialaccount.providers.openid_connect.views as oidc_views -import structlog -from allauth.account.models import EmailAddress -from allauth.socialaccount import providers -from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView -from drf_spectacular.utils import OpenApiResponse, extend_schema -from rest_framework import serializers -from rest_framework.exceptions import NotFound -from rest_framework.permissions import AllowAny, IsAuthenticated -from rest_framework.response import Response - -import InvenTree.sso -from common.settings import get_global_setting -from InvenTree.auth_overrides import registration_enabled -from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI -from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer - -logger = structlog.get_logger('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, provider=None): - """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_oidc(provider): - """Define urls for oidc endpoints.""" - return [ - path( - 'login/', - lambda x: oidc_views.login(x, provider.id), - name=f'{provider.id}_api_login', - ), - path( - 'connect/', - lambda x: oidc_views.callback(x, provider.id), - name=f'{provider.id}_api_connect', - ), - ] - - -legacy = { - 'twitter': 'twitter_oauth2', - 'bitbucket': 'bitbucket_oauth2', - 'linkedin': 'linkedin_oauth2', - 'vimeo': 'vimeo_oauth2', - 'openid': 'openid_connect', -} # legacy connectors - - -# Collect urls for all loaded providers -def get_provider_urls() -> list: - """Collect urls for all loaded providers. - - Returns: - list: List of urls for all loaded providers. - """ - auth_provider_routes = [] - - for name, provider in providers.registry.provider_map.items(): - try: - prov_mod = import_module(provider.get_package() + '.views') - except ImportError: - logger.exception('Could not import authentication provider %s', name) - continue - - # Try to extract the adapter class - adapters = [ - cls - for cls in prov_mod.__dict__.values() - if isinstance(cls, type) - and cls != OAuth2Adapter - and issubclass(cls, OAuth2Adapter) - ] - - # Get urls - urls = [] - if len(adapters) == 1: - if provider.id == 'openid_connect': - urls = handle_oidc(provider) - else: - urls = handle_oauth2(adapter=adapters[0], provider=provider) - elif provider.id in legacy: - logger.warning( - '`%s` is not supported on platform UI. Use `%s` instead.', - provider.id, - legacy[provider.id], - ) - continue - else: - logger.error( - 'Found handler that is not yet ready for platform UI: `%s`. Open an feature request on GitHub if you need it implemented.', - provider.id, - ) - continue - auth_provider_routes += [path(f'{provider.id}/', include(urls))] - - return auth_provider_routes - - -class SocialProviderListResponseSerializer(serializers.Serializer): - """Serializer for the SocialProviderListView.""" - - class SocialProvider(serializers.Serializer): - """Serializer for the SocialProviderListResponseSerializer.""" - - id = serializers.CharField() - name = serializers.CharField() - configured = serializers.BooleanField() - login = serializers.URLField() - connect = serializers.URLField() - display_name = serializers.CharField() - - sso_enabled = serializers.BooleanField() - sso_registration = serializers.BooleanField() - mfa_required = serializers.BooleanField() - providers = SocialProvider(many=True) - registration_enabled = serializers.BooleanField() - password_forgotten_enabled = serializers.BooleanField() - - -class SocialProviderListView(ListAPI): - """List of available social providers.""" - - permission_classes = (AllowAny,) - serializer_class = EmptySerializer - - @extend_schema( - responses={200: OpenApiResponse(response=SocialProviderListResponseSerializer)} - ) - def get(self, request, *args, **kwargs): - """Get the list of providers.""" - provider_list = [] - for provider in providers.registry.provider_map.values(): - provider_data = { - 'id': provider.id, - 'name': provider.name, - 'configured': False, - } - - try: - provider_data['login'] = request.build_absolute_uri( - reverse(f'{provider.id}_api_login') - ) - except NoReverseMatch: - provider_data['login'] = None - - try: - provider_data['connect'] = request.build_absolute_uri( - reverse(f'{provider.id}_api_connect') - ) - except NoReverseMatch: - provider_data['connect'] = None - - provider_data['configured'] = InvenTree.sso.check_provider(provider) - provider_data['display_name'] = InvenTree.sso.provider_display_name( - provider - ) - - provider_list.append(provider_data) - - data = { - 'sso_enabled': InvenTree.sso.sso_login_enabled(), - 'sso_registration': InvenTree.sso.sso_registration_enabled(), - 'mfa_required': settings.MFA_ENABLED - and get_global_setting('LOGIN_ENFORCE_MFA'), - 'mfa_enabled': settings.MFA_ENABLED, - 'providers': provider_list, - 'registration_enabled': registration_enabled(), - 'password_forgotten_enabled': get_global_setting('LOGIN_ENABLE_PWD_FORGOT'), - } - return Response(data) - - -class EmailAddressSerializer(InvenTreeModelSerializer): - """Serializer for the EmailAddress model.""" - - class Meta: - """Meta options for EmailAddressSerializer.""" - - model = EmailAddress - fields = '__all__' - - -class EmptyEmailAddressSerializer(InvenTreeModelSerializer): - """Empty Serializer for the EmailAddress model.""" - - class Meta: - """Meta options for EmailAddressSerializer.""" - - model = EmailAddress - fields = [] - - -class EmailListView(ListCreateAPI): - """List of registered email addresses for current users.""" - - permission_classes = (IsAuthenticated,) - serializer_class = EmailAddressSerializer - - def get_queryset(self): - """Only return data for current user.""" - return EmailAddress.objects.filter(user=self.request.user) - - -class EmailActionMixin(CreateAPI): - """Mixin to modify email addresses for current users.""" - - serializer_class = EmptyEmailAddressSerializer - permission_classes = (IsAuthenticated,) - - def get_queryset(self): - """Filter queryset for current user.""" - return EmailAddress.objects.filter( - user=self.request.user, pk=self.kwargs['pk'] - ).first() - - @extend_schema(responses={200: OpenApiResponse(response=EmailAddressSerializer)}) - def post(self, request, *args, **kwargs): - """Filter item, run action and return data.""" - email = self.get_queryset() - if not email: - raise NotFound - - self.special_action(email, request, *args, **kwargs) - return Response(EmailAddressSerializer(email).data) - - -class EmailVerifyView(EmailActionMixin): - """Re-verify an email for a currently logged in user.""" - - def special_action(self, email, request, *args, **kwargs): - """Send confirmation.""" - if email.verified: - return - email.send_confirmation(request) - - -class EmailPrimaryView(EmailActionMixin): - """Make an email for a currently logged in user primary.""" - - def special_action(self, email, *args, **kwargs): - """Mark email as primary.""" - if email.primary: - return - email.set_as_primary() - - -class EmailRemoveView(EmailActionMixin): - """Remove an email for a currently logged in user.""" - - def special_action(self, email, *args, **kwargs): - """Delete email.""" - email.delete() diff --git a/src/backend/InvenTree/InvenTree/static/css/inventree.css b/src/backend/InvenTree/InvenTree/static/css/inventree.css deleted file mode 100644 index cc392d28b7..0000000000 --- a/src/backend/InvenTree/InvenTree/static/css/inventree.css +++ /dev/null @@ -1,1114 +0,0 @@ -:root { - --primary-color: #335d88; - --secondary-color: #eef3f7; - --highlight-color: #ffffff; - - --border-color: #ccc; - - --label-red: #e35a57; - --label-blue: #4194bd; - --label-green: #50aa51; - --label-grey: #aaa; - --label-yellow: #fdc82a; - - --bs-body-color: #68686a; - - --scanner-upscale: 5; - --scanner-margin: 10%; -} - -main { - overflow-x: auto; -} - -.login-screen { - background-size: cover; - background-position: center; - height: 100vh; - font-family: 'Numans', sans-serif; - color: #eee; -} - -.login-container { - align-self: center; - border-radius: 15px; - padding: 20px; - padding-bottom: 35px; - background-color: rgba(50, 50, 50, 0.75); - width: 100%; - max-width: 550px; - margin: auto; -} - -.login-header { - margin-right: 5px; -} - -.login-container input { - background-color: rgba(250, 250, 250, 0.9); -} - -.login-button { - background-color: rgba(250, 250, 250, 0.9); - color: #333; - border-color: #AAA; - width: 100%; - border-radius: 5px; -} - -.index-bg { - width: 100%; - object-fit: fill; - opacity: 5%; -} - -.index-action-selected { - background-color: #EEEEF5; -} - -.panel-content { - padding: 10px; -} - -/* Progress bars */ - -.progress { - position: relative; - width: 100%; - margin-bottom: 0px; - background: #eeeef5; - font-size: 75%; - height: 1.25rem; - border-radius: 10px; -} - -.progress-bar { - opacity: 60%; - background: #2aa02a; -} - -.progress-bar-under { - background: #eeaa33; -} - -.progress-bar-over { - background: #337ab7; -} - -.progress-value { - width: 100%; - color: #333; - position: absolute; - text-align: center; - top: 0px; - left: 0px; - font-size: 110%; -} - -.bg-qr-code { - background-color: #FFF !important; -} - -.qr-code { - max-width: 400px; - max-height: 400px; - align-content: center; -} - -.navbar { - border-bottom: 1px solid #ccc; - background-color: var(--secondary-color); - box-shadow: 0px 5px 5px rgb(0 0 0 / 5%); -} - -.inventree-navbar-menu { - position: absolute !important; -} - -.navbar-brand { - float: left; -} - -.navbar-spacer { - height: 60px; -} - -#navbar-barcode-li { - border-left: none; - border-right: none; - padding-right: 5px; -} - -.navbar-form { - padding-right: 3px; -} - -.navbar-light .navbar-nav .nav-link { - color: var(--bs-body-color); -} - -#barcode-scan { - margin-top: 1px; -} - -.icon-header { - margin-right: 10px; -} - -.glyphicon { - font-size: 18px; -} - - -.glyphicon-small { - font-size: 12px; -} - -.glyphicon-right { - float: right; -} - -.red-cell { - background-color: #ec7f7f; -} - -.part-price { - color: rgb(13, 245, 25); -} - -.icon-red { - color: #c55; -} - -.icon-orange { - color: #fcba03; -} - -.icon-green { - color: #43bb43; -} - -.icon-blue { - color: #55c; -} - -.icon-yellow { - color: #CC2; -} - -/* CSS overrides for treeview */ -.expand-icon { - font-size: 11px; -} - -.treeview .badge { - font-size: 10px; -} - -.treeview .list-group-item { - padding: 3px 5px; -} - -.treeview .list-group-item .indent { - margin-left: 3px; - margin-right: 3px; -} - -.list-group-item-condensed { - padding: 5px 10px; -} - -.stock-sub-group td { - background-color: #ebf4f4; -} - -.sub-table { - margin-left: 60px; -} - -.detail-icon .glyphicon { - color: #98d296; -} - -.basecurrency { - color: #050; - font-style: italic; - font-weight: bold; -} - -.bomselect { - max-width: 250px; -} - -.rowvalid { - color: #050; -} - -.rowinvalid { - color: #A00; -} - -.rowinherited { - background-color: #eee; - font-style: italic; -} - -.dropdown { - padding-left: 1px; - margin-left: 1px; -} - -.dropdown-buttons { - display: inline-block -} - -.dropdown-menu .open{ - z-index: 1000; - position: relative; - overflow: visible; -} - -/* Styles for table buttons and filtering */ - -.filter-list { - display: inline-block; - *display: inline; - margin-bottom: 1px; - margin-top: 1px; - vertical-align: middle; - margin: 1px; - padding: 2px; - border-radius: 3px; -} - -.filter-list .close { - cursor: pointer; - right: 0%; - padding-right: 2px; - padding-left: 2px; - transform: translate(0%, -25%); -} - -.filter-list .close:hover { - background: #bbb; -} - -.filter-list .form-control { - width: initial; -} - -.filter-tag { - display: inline-block; - *display: inline; - margin: 5px; - padding: 5px; - padding-top: 1px; - padding-bottom: 1px; - color: var(--bs-body-color); - border: 1px solid var(--border-color); - border-radius: 10px; - background: var(--secondary-color); - white-space: nowrap; -} - -.filter-button { - padding: 6px; -} - -.filter-input { - display: inline-block; - *display: inline; -} - -.filter-tag:hover { - background: #ddd; -} - -/* Part image icons with full-display on mouse hover */ - -.hover-img-thumb { - background: #eee; - width: 28px; - height: 28px; - object-fit: contain; - border: 1px solid #cce; -} - -.hover-img-large { - background: #eee; - display: none; - position: absolute; - z-index: 400; - border: 1px solid #555; - max-width: 250px; -} - -.hover-icon { - margin-right: 10px; -} - -.hover-icon:hover > .hover-img-large { - display: block; -} - -/* dropzone class - for Drag-n-Drop file uploads */ -.dropzone { - z-index: 2; -} - -/* -.dropzone * { - pointer-events: none; -} -*/ - -.dragover { - background-color: #55A; - border: 1px dashed #111; - opacity: 0.1; - -moz-opacity: 10%; - -webkit-opacity: 10%; -} - -.table-condensed { - font-size: 90%; -} - -.table-condensed td { - padding: .25em .5em; -} - -.table button { - font-size: 90%; - padding: 3px; - padding-left: 5px; - padding-right: 5px; -} - -/* grid display for part images */ - -.table-img-grid tr { - display: inline; -} - -.table-img-grid td { - padding: 10px; - margin: 10px; -} - -.table-img-grid .grid-image { - - height: 128px; - width: 128px; - object-fit: contain; - background: #eee; -} - -/* pricing table widths */ -.table-price-two tr td:first-child { - width: 40%; -} - -.table-price-three tr td:first-child { - width: 40%; -} - -.table-price-two tr td:last-child { - width: 60%; -} - -.table-price-three tr td:last-child { - width: 30%; -} - -/* tracking table column size */ -#track-table .table-condensed th { - inline-size: 30%; - overflow-wrap: break-word; -} - -.panel-heading .badge { - float: right; -} - -.badge-right { - float: right; -} - -.icon-badge { - padding-right: 2px; - padding-left: 2px; - font-size: 125%; -} - -.part-properties > span { - padding-left: 5px; - padding-right: 5px; -} - -.part-thumb { - width: 256px; - height: 256px; - margin: 2px; - padding: 3px; - object-fit: contain; -} - -.part-thumb-container:hover .part-thumb-overlay { - opacity: 0.75; -} - -.part-thumb-overlay { - position: absolute; - top: 0; - left: 0; - opacity: 0; - transition: .25s ease; - margin: 5px; -} - -.checkbox { - margin-left: 20px; -} - -.checkboxinput { - padding-left: 5px; - padding-right: 5px; -} - -.form-switch { - font-size: 120%; -} - -.media { - /* padding-top: 15px; */ - overflow: visible; -} - -.media-body { - padding-top: 10px; - overflow: visible; -} - -.navigation { - background-color: var(--secondary-color); -} - -.nav-tabs { - margin-bottom: 20px; -} - -.settings-container { - width: 90%; - padding: 15px; -} - -.settings-nav { - height: 100%; - width: 160px; - position: fixed; - z-index: 1; - /* top: 0; - left: 0; */ - overflow-x: hidden; - padding-top: 20px; - padding-right: 25px; -} - -.settings-content { - margin-left: 175px; - padding: 0px 10px; -} - -.breadcrumb { - margin-bottom: 5px; - margin-left: 5px; - margin-right: 10px; -} - -.inventree-body { - width: 100%; - padding: 5px; - padding-right: 0; -} - -.inventree-pre-content { - width: auto; -} - -.inventree-content { - padding-left: 10px; - padding-right: 10px; - padding-top: 5px; - width: 100%; - max-width: 100%; - display: inline-block; - transition: 0.1s; -} - -.search-autocomplete-item { - border-top: 1px solid #EEE; - margin-bottom: 2px; - overflow-x: hidden; -} - -.modal { - overflow: hidden; - z-index: 9999; -} - -.modal-error { - border: 2px #FCC solid; - background-color: #f5f0f0; -} - -.modal-header { - padding: 3px; - padding-top: 5px; - padding-left: 15px; - padding-right: 25px; - color: var(--bs-body-color); - background-color: var(--secondary-color); - border-bottom: 1px solid var(--border-color); -} - -.modal-footer { - border-top: 1px solid #ddd; -} - -.modal-primary { - z-index: 10000; -} - -.modal-secondary { - z-index: 11000; -} - -.modal-close { - position: absolute; - top: 15px; - right: 35px; - color: #f1f1f1; - font-size: 40px; - font-weight: bold; - transition: 0.25s; -} - -.modal-close:hover, -.modal-close:focus { - color: #bbb; - text-decoration: none; - cursor: pointer; -} - -.modal-image-content { - margin: auto; - display: block; - width: 80%; - max-width: 700px; - text-align: center; - color: #ccc; - padding: 10px 0; -} - -@media only screen and (max-width: 700px){ - .modal-image-content { - width: 100%; - } -} - -.modal-image { - display: none; - position: fixed; - z-index: 10000; - padding-top: 100px; - left: 0; - top: 0; - width: 100%; - height: 100%; - overflow: auto; - background-color: rgb(0,0,0); /* Fallback color */ - background-color: rgba(0,0,0,0.85); /* Black w/ opacity */ -} - -.js-modal-form .checkbox { - margin-left: 0px; -} - -.modal-dialog { - width: 65%; - max-width: 80%; -} - -.modal-secondary .modal-dialog { - width: 40%; - padding-top: 15px; -} - -.modal-content h3 { - margin-top: 3px; - margin-bottom: 3px; -} - -.modal-form-content-wrapper { - border-radius: 0; - position:relative; - height: auto !important; - max-height: calc(100vh - 200px) !important; - overflow-y: auto; - padding: 10px; -} - -.form-panel { - border-radius: 5px; - border: 1px solid #ccc; - padding: 5px; -} - -.form-group { - padding-bottom: 15px; -} - - -.form-field-error { - color: #A94442; -} - -.form-error-message { - display: block; -} - -.modal input { - width: 100%; -} - -input[type="submit"] { - color: #333; - background-color: #e6e6e6; - border-color: #adadad; -} - -.modal textarea { - width: 100%; -} - -/* Force a control-label div to be 100% width */ -.modal .control-label { - width: 100%; - font-weight: 700; - margin-top: 5px; -} - -.modal .control-label .btn { - padding-top: 3px; - padding-bottom: 3px; -} - -.modal .btn-form-secondary { - margin-bottom: 10px; -} - -/* Make barcode scanner responsive and performant */ -#barcode_scan_video video { - width: calc(100% * var(--scanner-upscale)) !important; - transform: scale(calc(1 / var(--scanner-upscale))); - margin: calc(-100% * (var(--scanner-upscale) - 1) / 2); - display: inline-block !important; -} - -@-moz-document url-prefix() { - #barcode_scan_video video { - margin-top: calc(-100% * (var(--scanner-upscale) - 1) / 2 + 50%); - margin-bottom: calc(-100% * (var(--scanner-upscale) - 1) / 2 + 50%); - } - - @media (pointer:coarse) { - #barcode_scan_video video { - margin-top: calc(-100% * (var(--scanner-upscale)) / 2 - 20%); - margin-bottom: calc(-100% * (var(--scanner-upscale)) / 2 - 20%); - } - } -} - -#barcode_scan_video #qr-shaded-region { - border: none !important; - margin: var(--scanner-margin); -} - -.sidebar-list-group-item { - background-color: var(--secondary-color); - color: var(--bs-body-color); -} - -.sidebar-list-group-item.active { - color: var(--highlight-color); - background-color: var(--bs-body-color); - border: none; -} - -.container > aside, -.container > main { - padding: 10px; - overflow: auto; -} - - -.wrapper { - align-items: stretch; - display: flex; -} - -.help-inline { - color: #A11; -} - -.notification-area { - opacity: 0.8; -} - -.notes { - border-radius: 5px; - background-color: #fafafa; - padding: 5px; -} - -.alert { - border-radius: 5px; - opacity: 0.9; - pointer-events: all; -} - -.alert-block { - display: block; - padding: 0.75rem; -} - -.alert-small { - padding: 0.35rem; - font-size: 75%; -} - -.navbar .btn { - margin-left: 5px; -} - -.btn-secondary { - background-color: var(--bs-body-color); - border-color: var(--bs-body-color); -} - -.btn-outline-secondary { - color: var(--bs-body-color); - border-color: var(--bs-body-color); -} - -.btn-small { - padding: 3px; - padding-left: 5px; - padding-right: 5px; -} - -.btn-remove { - padding: 3px; - padding-left: 5px; - padding-right: 5px; - color: #A11; -} - -.btn-create { - padding: 3px; - padding-left: 5px; - padding-right: 5px; - color: #1A1; -} - -.btn-edit { - padding: 3px; - padding: 3px; - padding-left: 5px; - padding-right: 5px; - color: #55E; -} - -.button-toolbar { - padding-left: 0px; -} - -.panel-group { - margin-bottom: 5px; -} - -.panel-content { - padding: 10px; -} - -.panel-group { - border-radius: 2px; -} - -.panel-heading { - padding: 3px; - padding-top: 5px; - padding-left: 15px; - color: var(--bs-body-color); - background-color: var(--secondary-color); - border-bottom: 1px solid var(--border-color); - box-shadow: 0px 5px 5px rgb(0 0 0 / 5%); -} - -.panel { - box-shadow: 2px 2px #DDD; - margin-bottom: .75rem; - background-color: #fff; - border: 1px solid #ccc; -} - -.panel-hidden { - display: none; -} - -.panel-inventree .card { - padding: 10px; -} - -.card-thumb { - max-width: 64px; - max-height: 64px; -} - -.float-right { - float: right; -} - -.warning-msg { - color: #e00; -} - -.info-messages { - padding: 5px; -} - -.info-messages .alert { - padding: 5px; - margin-bottom: 10px; -} - -.part-allocation { - padding: 3px 10px; - border: 1px solid #ccc; - border-radius: 2px; -} - -.part-allocation-pass { - background-color: #dbf0db; -} - -.part-allocation-underallocated { - background-color: #f0dbdb; -} - -.part-allocation-overallocated { - background-color: #ccf5ff; -} - -.glyphicon-refresh-animate { - -animation: spin .7s infinite linear; - -webkit-animation: spin2 .7s infinite linear; -} - -@-webkit-keyframes spin2 { - from { -webkit-transform: rotate(0deg);} - to { -webkit-transform: rotate(360deg);} -} - -@keyframes spin { - from { transform: scale(1) rotate(0deg);} - to { transform: scale(1) rotate(360deg);} -} - -input[type="date"].form-control, input[type="time"].form-control, input[type="datetime-local"].form-control, input[type="month"].form-control { - line-height: unset; -} - -.clip-btn { - font-size: 10px; - padding: 0px 6px; - color: var(--label-grey); - background: none; -} - -.clip-btn:hover { - background: var(--label-grey); -} - -.sidebar-wrapper { - overflow-y: auto; - /* background: var(--secondary-color); */ - margin-top: 0.3rem; - padding-top: 0.25rem; - padding-left: 0px !important; -} - -.sidebar-item-icon { - min-width: 20px; -} - -.sidebar-item-text { - margin-left: 15px; - margin-right: 15px; -} - -.sidebar-nav a { - color: var(--bs-body-color); -} - -.row.full-height { - display: flex; - flex-wrap: wrap; -} - -.row.full-height > [class*='col-'] { - display: flex; - flex-direction: column; -} - -a.anchor { - display: block; - position: relative; - top: -60px; - visibility: hidden; -} - -.select2-close-mask { - z-index: 99999; -} - -.select2-dropdown { - z-index: 99998; -} - - -.select2-thumbnail { - max-width: 24px; - max-height: 24px; - border-radius: 4px; - margin-right: 10px; -} - -.select2-selection { - overflow-y: clip; -} - -.form-clear { - padding: 6px 6px; -} - -/* Force minimum width of number input fields to show at least ~5 digits */ -input[type='number']{ - min-width: 80px; -} - -.search-menu { - padding-top: 2rem; -} - -.search-menu .ui-menu-item { - margin-top: 0.5rem; -} - -.product-card { - width: 20%; - padding: 5px; - min-height: 25px; - background-color: transparent; -} - -.product-card-panel{ - height: 100%; - margin-bottom: 5px; -} - -.borderless { - border: none; -} - -a { - text-decoration: none; - background-color: transparent; -} - -/* Quicksearch Panel */ - -.search-result-panel { - max-width: 800px; - width: 75% -} - -.search-result-group { - padding: 5px; - padding-left: 10px; - padding-right: 10px; - border: 1px solid var(--border-color); - margin-bottom: 10px; -} - -.search-result-group-buttons > button{ - padding: 2px; - padding-left: 5px; - padding-right: 5px; - font-size: 80%; -} - -.search-result-entry { - border-top: 1px solid var(--border-color); - padding: 3px; - margin-top: 3px; - overflow: hidden; -} - -.treeview .node-icon { - margin-left: 0.25rem; - margin-right: 0.25rem; -} - -.sso-header { - text-align: center; - width: 100%; - padding-bottom: 10px; -} - -.sso-provider-list { - width: 100%; - list-style-type: none; - padding-left: 0px; -} - -.sso-provider-link { - width: 100%; -} - -.sso-provider-link a { - width: 100%; - text-align: left; -} - -.flex-cell { - display: flex; - align-items: center; - justify-content: space-between; -} - -.large-treeview-icon { - font-size: 1em; -} - -.api-icon { - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; - /* Better font rendering */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} diff --git a/src/backend/InvenTree/InvenTree/test_auth.py b/src/backend/InvenTree/InvenTree/test_auth.py index 10aa5c3e09..82cfbda809 100644 --- a/src/backend/InvenTree/InvenTree/test_auth.py +++ b/src/backend/InvenTree/InvenTree/test_auth.py @@ -141,13 +141,34 @@ class EmailSettingsContext: class TestAuth(InvenTreeAPITestCase): """Test authentication functionality.""" + reg_url = '/api/auth/v1/auth/signup' + test_email = 'tester@example.com' + + def test_buildin_token(self): + """Test the built-in token authentication.""" + self.logout() + response = self.post( + '/api/auth/v1/auth/login', + {'username': self.username, 'password': self.password}, + expected_code=200, + ) + data = response.json() + self.assertIn('meta', data) + self.assertTrue(data['meta']['is_authenticated']) + + # Test for conflicting login + self.post( + '/api/auth/v1/auth/login', + {'username': self.username, 'password': self.password}, + expected_code=409, + ) + def email_args(self, user=None, email=None): """Generate registration arguments.""" return { - 'username': user or 'user1', - 'email': email or 'test@example.com', - 'password1': '#asdf1234', - 'password2': '#asdf1234', + 'username': user or 'user2', + 'email': email or self.test_email, + 'password': '#asdf1234', } def test_registration(self): @@ -156,26 +177,17 @@ class TestAuth(InvenTreeAPITestCase): # Duplicate username resp = self.post( - '/api/auth/registration/', - self.email_args(user='testuser'), - expected_code=400, - ) - self.assertIn( - 'A user with that username already exists.', resp.data['username'] + self.reg_url, self.email_args(user='testuser'), expected_code=400 ) + self.assertIn('A user with that username already exists.', str(resp.json())) # Registration is disabled - resp = self.post( - '/api/auth/registration/', self.email_args(), expected_code=400 - ) - self.assertIn('Registration is disabled.', resp.data['non_field_errors']) + self.post(self.reg_url, self.email_args(), expected_code=403) # Enable registration - now it should work with EmailSettingsContext(): - resp = self.post( - '/api/auth/registration/', self.email_args(), expected_code=201 - ) - self.assertIn('key', resp.data) + resp = self.post(self.reg_url, self.email_args(), expected_code=200) + self.assertEqual(resp.json()['data']['user']['email'], self.test_email) def test_registration_email(self): """Test that LOGIN_SIGNUP_MAIL_RESTRICTION works.""" @@ -197,15 +209,13 @@ class TestAuth(InvenTreeAPITestCase): # Wrong email format resp = self.post( - '/api/auth/registration/', + self.reg_url, self.email_args(email='admin@invenhost.com'), expected_code=400, ) - self.assertIn('The provided email domain is not approved.', resp.data['email']) + self.assertIn('The provided email domain is not approved.', str(resp.json())) # Right format should work with EmailSettingsContext(): - resp = self.post( - '/api/auth/registration/', self.email_args(), expected_code=201 - ) - self.assertIn('key', resp.data) + resp = self.post(self.reg_url, self.email_args(), expected_code=200) + self.assertEqual(resp.json()['data']['user']['email'], self.test_email) diff --git a/src/backend/InvenTree/InvenTree/urls.py b/src/backend/InvenTree/InvenTree/urls.py index b0af89d6fc..665fa35a2e 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -10,11 +10,7 @@ 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 ( - ConfirmEmailView, - SocialAccountDisconnectView, - SocialAccountListView, -) +from allauth.headless.urls import Client, build_urlpatterns from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView from sesame.views import LoginView @@ -29,7 +25,6 @@ import plugin.api import report.api import stock.api import users.api -from InvenTree.auth_override_views import CustomRegisterView from plugin.urls import get_plugin_urls from web.urls import urlpatterns as platform_urls @@ -42,14 +37,6 @@ from .api import ( VersionView, ) from .magic_login import GetSimpleLoginView -from .social_auth_urls import ( - EmailListView, - EmailPrimaryView, - EmailRemoveView, - EmailVerifyView, - SocialProviderListView, - get_provider_urls, -) from .views import auth_request admin.site.site_header = 'InvenTree Admin' @@ -107,59 +94,17 @@ apipatterns = [ path( 'auth/', include([ - re_path( - r'^registration/account-confirm-email/(?P[-:\w]+)/$', - ConfirmEmailView.as_view(), - name='account_confirm_email', - ), - path('registration/', CustomRegisterView.as_view(), name='rest_register'), - path('registration/', include('dj_rest_auth.registration.urls')), - path( - 'providers/', SocialProviderListView.as_view(), name='social_providers' - ), - path( - 'emails/', - include([ - path( - '/', - include([ - path( - 'primary/', - EmailPrimaryView.as_view(), - name='email-primary', - ), - path( - 'verify/', - EmailVerifyView.as_view(), - name='email-verify', - ), - path( - 'remove/', - EmailRemoveView().as_view(), - name='email-remove', - ), - ]), - ), - path('', EmailListView.as_view(), name='email-list'), - ]), - ), - path('social/', include(get_provider_urls())), - path( - 'social/', SocialAccountListView.as_view(), name='social_account_list' - ), - path( - 'social//disconnect/', - SocialAccountDisconnectView.as_view(), - name='social_account_disconnect', - ), - path('login/', users.api.Login.as_view(), name='api-login'), - path('logout/', users.api.Logout.as_view(), name='api-logout'), path( 'login-redirect/', users.api.LoginRedirect.as_view(), name='api-login-redirect', ), - path('', include('dj_rest_auth.urls')), + path( + '', + include( + (build_urlpatterns(Client.BROWSER), 'headless'), namespace='browser' + ), + ), # Allauth headless logic (only the browser client is included as we only use sessions based auth there) ]), ), # Magic login URLs @@ -175,8 +120,16 @@ apipatterns = [ backendpatterns = [ - path('auth/', include('rest_framework.urls', namespace='rest_framework')), - path('auth/', auth_request), + path( + 'auth/', include('rest_framework.urls', namespace='rest_framework') + ), # Used for (DRF) browsable API auth + path('auth/', auth_request), # Used for proxies to check if user is authenticated + path('accounts/', include('allauth.urls')), + path( + 'accounts/login/', + RedirectView.as_view(url=f'/{settings.FRONTEND_URL_BASE}', permanent=False), + name='account_login', + ), # Add a redirect for login views path('api/', include(apipatterns)), path('api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'), ] @@ -193,19 +146,7 @@ if settings.INVENTREE_ADMIN_ENABLED: ] urlpatterns += backendpatterns - -frontendpatterns = [ - *platform_urls, - # Add a redirect for login views - path( - 'accounts/login/', - RedirectView.as_view(url=f'/{settings.FRONTEND_URL_BASE}', permanent=False), - name='account_login', - ), - path('accounts/', include('allauth_2fa.urls')), # MFA support -] - -urlpatterns += frontendpatterns +urlpatterns += platform_urls # Append custom plugin URLs (if custom plugin support is enabled) if settings.PLUGINS_ENABLED: diff --git a/src/backend/InvenTree/config_template.yaml b/src/backend/InvenTree/config_template.yaml index b80cdbf448..7e5f9b9428 100644 --- a/src/backend/InvenTree/config_template.yaml +++ b/src/backend/InvenTree/config_template.yaml @@ -176,11 +176,6 @@ 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/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py index 092f5bbb41..80016152ea 100644 --- a/src/backend/InvenTree/generic/states/api.py +++ b/src/backend/InvenTree/generic/states/api.py @@ -115,7 +115,6 @@ class AllStatusViews(StatusView): for item in cls.custom_values(): label = str(item.name) if label not in cls_data['values']: - print('custom value:', item) cls_data['values'][label] = { 'color': item.color, 'logical_key': item.logical_key, diff --git a/src/backend/InvenTree/templates/403.html b/src/backend/InvenTree/templates/403.html index fb76ba5976..1f69ed473a 100644 --- a/src/backend/InvenTree/templates/403.html +++ b/src/backend/InvenTree/templates/403.html @@ -7,13 +7,6 @@ {% endblock page_title %} {% block content %} - -
-

{% trans "Permission Denied" %}

- -
- {% trans "You do not have permission to view this page." %} -
-
- +

{% trans "Permission Denied" %}

+{% trans "You do not have permission to view this page." %} {% endblock content %} diff --git a/src/backend/InvenTree/templates/403_csrf.html b/src/backend/InvenTree/templates/403_csrf.html index e66345fb56..701d3965dd 100644 --- a/src/backend/InvenTree/templates/403_csrf.html +++ b/src/backend/InvenTree/templates/403_csrf.html @@ -9,15 +9,5 @@ {% block content %}

{% trans "Authentication Failure" %}

- -
- {% trans "You have been logged out from InvenTree." %} -
-
- - +{% trans "You have been logged out from InvenTree." %} {% endblock content %} diff --git a/src/backend/InvenTree/templates/404.html b/src/backend/InvenTree/templates/404.html index 15531651bc..021d4ebbc7 100644 --- a/src/backend/InvenTree/templates/404.html +++ b/src/backend/InvenTree/templates/404.html @@ -7,13 +7,6 @@ {% endblock page_title %} {% block content %} - -
-

{% trans "Page Not Found" %}

- -
- {% trans "The requested page does not exist" %} -
-
- +

{% trans "Page Not Found" %}

+{% trans "The requested page does not exist" %} {% endblock content %} diff --git a/src/backend/InvenTree/templates/500.html b/src/backend/InvenTree/templates/500.html index 33a0ad2c6b..eb395c0859 100644 --- a/src/backend/InvenTree/templates/500.html +++ b/src/backend/InvenTree/templates/500.html @@ -7,14 +7,7 @@ {% endblock page_title %} {% block content %} - -
-

{% trans "Internal Server Error" %}

- -
- {% blocktrans %}The {{ inventree_title }} server raised an internal error{% endblocktrans %}
- {% trans "Refer to the error log in the admin interface for further details" %} -
-
- +

{% trans "Internal Server Error" %}

+{% blocktrans %}The {{ inventree_title }} server raised an internal error{% endblocktrans %}
+{% trans "Refer to the error log in the admin interface for further details" %} {% endblock content %} diff --git a/src/backend/InvenTree/templates/503.html b/src/backend/InvenTree/templates/503.html index 5e4b65ea9d..f0671796ed 100644 --- a/src/backend/InvenTree/templates/503.html +++ b/src/backend/InvenTree/templates/503.html @@ -11,61 +11,9 @@ {% trans 'Site is in Maintenance' %} {% endblock page_title %} -{% block body_class %}login-screen{% endblock body_class %} - {% block body %} - -
-
- -
-
- - +

{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock body_title %}

+{% block content %} +{% trans 'The site is currently in maintenance and should be up again soon!' %} +{% endblock content %} {% endblock body %} - -{% block js_base %} - -{% endblock js_base %} - - diff --git a/src/backend/InvenTree/templates/account/base.html b/src/backend/InvenTree/templates/account/base.html deleted file mode 100644 index eee8f0bc5c..0000000000 --- a/src/backend/InvenTree/templates/account/base.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "skeleton.html" %} -{% load i18n %} -{% load inventree_extras %} - -{% block body %} - -
-
- -
-
- - -{% endblock body %} diff --git a/src/backend/InvenTree/templates/skeleton.html b/src/backend/InvenTree/templates/skeleton.html index 3e77e83f9f..4c06cc31db 100644 --- a/src/backend/InvenTree/templates/skeleton.html +++ b/src/backend/InvenTree/templates/skeleton.html @@ -12,18 +12,14 @@ {% include "favicon.html" %} - - - {% block page_title %} {% endblock page_title %} - + {% block body %} {% endblock body %} - diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index c794326d90..f55a0c29ac 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -2,22 +2,14 @@ import datetime -from django.contrib.auth import authenticate, get_user, login, logout +from django.contrib.auth import get_user, login from django.contrib.auth.models import Group, User -from django.http.response import HttpResponse -from django.shortcuts import redirect -from django.urls import include, path, re_path, reverse +from django.urls import include, path, re_path +from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic.base import RedirectView import structlog -from allauth.account import app_settings -from allauth.account.adapter import get_adapter -from allauth_2fa.utils import user_has_valid_totp_device -from dj_rest_auth.views import LoginView, LogoutView -from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import exceptions, permissions -from rest_framework.authentication import BasicAuthentication -from rest_framework.decorators import authentication_classes from rest_framework.generics import DestroyAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -25,7 +17,6 @@ from rest_framework.views import APIView import InvenTree.helpers import InvenTree.permissions -from common.models import InvenTreeSetting from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.mixins import ( ListAPI, @@ -224,96 +215,6 @@ class GroupList(GroupMixin, ListCreateAPI): ordering_fields = ['name'] -@authentication_classes([BasicAuthentication]) -@extend_schema_view( - post=extend_schema( - responses={200: OpenApiResponse(description='User successfully logged in')} - ) -) -class Login(LoginView): - """API view for logging in via API.""" - - def post(self, request, *args, **kwargs): - """API view for logging in via API.""" - _data = request.data.copy() - _data.update(request.POST.copy()) - - if not _data.get('mfa', None): - logger.info('No MFA requested - Proceeding') - return super().post(request, *args, **kwargs) - - # Check if login credentials valid - user = authenticate( - request, username=_data.get('username'), password=_data.get('password') - ) - if user is None: - logger.info('Invalid login - Aborting') - return HttpResponse(status=401) - - # Check if user has mfa set up - if not user_has_valid_totp_device(user): - logger.info('No MFA set up - Proceeding') - return super().post(request, *args, **kwargs) - - # Stage login and redirect to 2fa - request.session['allauth_2fa_user_id'] = str(user.id) - request.session['allauth_2fa_login'] = { - 'email_verification': app_settings.EMAIL_VERIFICATION, - 'signal_kwargs': None, - 'signup': False, - 'email': None, - 'redirect_url': reverse('platform'), - } - logger.info('Redirecting to 2fa - Proceeding') - return redirect(reverse('two-factor-authenticate')) - - def process_login(self): - """Process the login request, ensure that MFA is enforced if required.""" - # Normal login process - ret = super().process_login() - user = self.request.user - adapter = get_adapter(self.request) - - # User requires 2FA or MFA is enforced globally - no logins via API - if adapter.has_2fa_enabled(user) or InvenTreeSetting.get_setting( - 'LOGIN_ENFORCE_MFA' - ): - logout(self.request) - logger.info('User was logged out because MFA is required - Aborting') - raise exceptions.PermissionDenied('MFA required for this user') - return ret - - -@extend_schema_view( - post=extend_schema( - responses={200: OpenApiResponse(description='User successfully logged out')} - ) -) -class Logout(LogoutView): - """API view for logging out via API.""" - - serializer_class = None - - def post(self, request): - """Logout the current user. - - Deletes user token associated with request. - """ - from InvenTree.middleware import get_token_from_request - - if request.user: - token_key = get_token_from_request(request) - - if token_key: - try: - token = ApiToken.objects.get(key=token_key, user=request.user) - token.delete() - except ApiToken.DoesNotExist: # pragma: no cover - pass - - return super().logout(request) - - class GetAuthToken(APIView): """Return authentication token for an authenticated user.""" @@ -396,7 +297,7 @@ class LoginRedirect(RedirectView): user_urls = [ path('roles/', RoleDetails.as_view(), name='api-user-roles'), - path('token/', GetAuthToken.as_view(), name='api-token'), + path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'), path( 'tokens/', include([ diff --git a/src/backend/InvenTree/users/migrations/0013_migrate_mfa_20240408_1659.py b/src/backend/InvenTree/users/migrations/0013_migrate_mfa_20240408_1659.py new file mode 100644 index 0000000000..85f48829b4 --- /dev/null +++ b/src/backend/InvenTree/users/migrations/0013_migrate_mfa_20240408_1659.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2.23 on 2024-01-19 16:59 + +import base64 + +from django.db import migrations + +from allauth.mfa.adapter import get_adapter +from allauth.mfa.models import Authenticator +from django_otp.plugins.otp_static.models import StaticDevice +from django_otp.plugins.otp_totp.models import TOTPDevice + + +def move_mfa(apps, schema_editor): + """Data migration to switch to django-allauth's new built-in MFA.""" + adapter = get_adapter() + authenticators = [] + for totp in TOTPDevice.objects.filter(confirmed=True).iterator(): + recovery_codes = set() + for sdevice in StaticDevice.objects.filter( + confirmed=True, user_id=totp.user_id + ).iterator(): + recovery_codes.update(sdevice.token_set.values_list('token', flat=True)) + secret = base64.b32encode(bytes.fromhex(totp.key)).decode('ascii') + totp_authenticator = Authenticator( + user_id=totp.user_id, + type=Authenticator.Type.TOTP, + data={'secret': adapter.encrypt(secret)}, + ) + authenticators.append(totp_authenticator) + authenticators.append( + Authenticator( + user_id=totp.user_id, + type=Authenticator.Type.RECOVERY_CODES, + data={'migrated_codes': [adapter.encrypt(c) for c in recovery_codes]}, + ) + ) + Authenticator.objects.bulk_create(authenticators) + + +class Migration(migrations.Migration): + dependencies = [('users', '0012_alter_ruleset_can_view'), + ('otp_static', '0002_throttling'), + ('otp_totp', '0002_auto_20190420_0723'), + ('mfa', '0002_authenticator_timestamps'),] + + operations = [ + migrations.RunPython(move_mfa, reverse_code=migrations.RunPython.noop) + ] diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index a7b1756905..ea9d453fda 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -238,6 +238,7 @@ class RuleSet(models.Model): 'otp_totp_totpdevice', 'otp_static_statictoken', 'otp_static_staticdevice', + 'mfa_authenticator', 'plugin_pluginconfig', 'plugin_pluginsetting', 'plugin_notificationusersetting', @@ -353,7 +354,8 @@ class RuleSet(models.Model): 'error_report_error', 'exchange_rate', 'exchange_exchangebackend', - 'user_sessions_session', + 'usersessions_usersession', + 'sessions_session', # Django-q 'django_q_ormq', 'django_q_failure', diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index 190ebb631b..05e1be8b5f 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -110,15 +110,6 @@ class UserAPITests(InvenTreeAPITestCase): self.assertIn('name', response.data) self.assertIn('permissions', response.data) - def test_logout(self): - """Test api logout endpoint.""" - token_key = self.get(url=reverse('api-token')).data['token'] - self.client.logout() - self.client.credentials(HTTP_AUTHORIZATION='Token ' + token_key) - - self.post(reverse('api-logout'), expected_code=200) - self.get(reverse('api-token'), expected_code=401) - def test_login_redirect(self): """Test login redirect endpoint.""" response = self.get(reverse('api-login-redirect'), expected_code=302) @@ -233,16 +224,6 @@ class UserTokenTests(InvenTreeAPITestCase): self.client.get(me, expected_code=200) - def test_builtin_token(self): - """Test the built-in token authentication.""" - response = self.post( - reverse('rest_login'), - {'username': self.username, 'password': self.password}, - expected_code=200, - ) - self.assertIn('key', response.data) - self.assertTrue(response.data['key'].startswith('inv-')) - def test_token_api(self): """Test the token API.""" url = reverse('api-token-list') diff --git a/src/backend/InvenTree/users/test_migrations.py b/src/backend/InvenTree/users/test_migrations.py index 9843b79177..f65f75a3c9 100644 --- a/src/backend/InvenTree/users/test_migrations.py +++ b/src/backend/InvenTree/users/test_migrations.py @@ -24,3 +24,36 @@ class TestForwardMigrations(MigratorTestCase): User = self.new_state.apps.get_model('auth', 'user') self.assertEqual(User.objects.count(), 2) + + +class MFAMigrations(MigratorTestCase): + """Test entire schema migration sequence for the users app.""" + + migrate_from = ('users', '0012_alter_ruleset_can_view') + migrate_to = ('users', '0013_migrate_mfa_20240408_1659') + + def prepare(self): + """Setup the initial state of the database before migrations.""" + User = self.old_state.apps.get_model('auth', 'user') + TOTPDevice = self.old_state.apps.get_model('otp_totp', 'TOTPDevice') + StaticDevice = self.old_state.apps.get_model('otp_static', 'StaticDevice') + + abc = User.objects.create( + username='fred', email='fred@fred.com', password='password' + ) + TOTPDevice.objects.create(user=abc, confirmed=True, key='1234') + abc1 = User.objects.create( + username='brad', email='brad@fred.com', password='password' + ) + TOTPDevice.objects.create(user=abc1, confirmed=False, key='1234') + StaticDevice.objects.create(user=abc1, confirmed=True) + + def test_users_exist(self): + """Test that users exist in the database.""" + User = self.new_state.apps.get_model('auth', 'user') + Authenticator = self.new_state.apps.get_model('mfa', 'Authenticator') + + self.assertEqual(User.objects.count(), 2) + # 2 Tokens - both for user 1 + self.assertEqual(Authenticator.objects.count(), 2) + self.assertEqual([1, 1], [i.user_id for i in Authenticator.objects.all()]) diff --git a/src/backend/InvenTree/users/tests.py b/src/backend/InvenTree/users/tests.py index 262e8dcd99..97f36cf569 100644 --- a/src/backend/InvenTree/users/tests.py +++ b/src/backend/InvenTree/users/tests.py @@ -296,8 +296,9 @@ class OwnerModelTest(InvenTreeTestCase): class MFALoginTest(InvenTreeAPITestCase): """Some simplistic tests to ensure that MFA is working.""" + """ def test_api(self): - """Test that the API is working.""" + ""Test that the API is working."" auth_data = {'username': self.username, 'password': self.password} login_url = reverse('api-login') @@ -335,6 +336,7 @@ class MFALoginTest(InvenTreeAPITestCase): # Wrong login should not work auth_data['password'] = 'wrong' self.post(login_url, auth_data, expected_code=401) + """ class AdminTest(AdminTestCase): diff --git a/src/backend/InvenTree/web/urls.py b/src/backend/InvenTree/web/urls.py index 208dd7d512..97c8bbe59f 100644 --- a/src/backend/InvenTree/web/urls.py +++ b/src/backend/InvenTree/web/urls.py @@ -18,7 +18,7 @@ class RedirectAssetView(TemplateView): spa_view = ensure_csrf_cookie(TemplateView.as_view(template_name='web/index.html')) -assets_path = path('assets/', RedirectAssetView.as_view()) +assets_path = path('assets/', RedirectAssetView.as_view(), name='web-assets') urlpatterns = [ @@ -31,7 +31,7 @@ urlpatterns = [ spa_view, name='password_reset_confirm', ), - re_path('.*', spa_view), + re_path('.*', spa_view, name='platform-wildcard'), ]), ), assets_path, diff --git a/src/backend/requirements.in b/src/backend/requirements.in index a7ff784ba9..9ede585b4f 100644 --- a/src/backend/requirements.in +++ b/src/backend/requirements.in @@ -2,8 +2,7 @@ Django<5.0 # Django package coreapi # API documentation for djangorestframework cryptography>=44.0.0 # Core cryptographic functionality -django-allauth[openid,saml] # SSO for external providers via OpenID -django-allauth-2fa # MFA / 2FA +django-allauth[mfa,socialaccount,saml,openid] # SSO for external providers via OpenID django-cleanup # Automated deletion of old / unused uploaded files django-cors-headers # CORS headers extension for DRF django-dbbackup # Backup / restore of database and media files @@ -26,12 +25,11 @@ django-sslserver # Secure HTTP development server django-structlog # Structured logging django-stdimage # Advanced ImageField management django-taggit # Tagging support -django-user-sessions # user sessions in DB +django-otp==1.3.0 # Two-factor authentication (legacy to ensure migrations) https://github.com/inventree/InvenTree/pull/6293 django-weasyprint # django weasyprint integration djangorestframework<3.15 # DRF framework # FIXED 2024-06-26 see https://github.com/inventree/InvenTree/pull/7521 djangorestframework-simplejwt[crypto] # JWT authentication django-xforwardedfor-middleware # IP forwarding metadata -dj-rest-auth==7.0.1 # Authentication API endpoints # FIXED 2024-12-22 due to https://github.com/inventree/InvenTree/issues/8707 dulwich # pure Python git integration docutils # Documentation utilities for auto admin docs drf-spectacular # DRF API documentation diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index addac53c2d..a0be7b58ef 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -5,6 +5,7 @@ asgiref==3.8.1 \ --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590 # via # django + # django-allauth # django-cors-headers # django-structlog async-timeout==5.0.1 \ @@ -366,6 +367,7 @@ cryptography==44.0.1 \ # via # -r src/backend/requirements.in # djangorestframework-simplejwt + # fido2 # pyjwt cssselect2==0.7.0 \ --hash=sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a \ @@ -383,17 +385,12 @@ deprecated==1.2.18 \ # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-http # opentelemetry-semantic-conventions -dj-rest-auth==7.0.1 \ - --hash=sha256:3f8c744cbcf05355ff4bcbef0c8a63645da38e29a0fdef3c3332d4aced52fb90 - # via -r src/backend/requirements.in django==4.2.19 \ --hash=sha256:6c833be4b0ca614f0a919472a1028a3bbdeb6f056fa04023aeb923346ba2c306 \ --hash=sha256:a104e13f219fc55996a4e416ef7d18ab4eeb44e0aa95174c192f16cda9f94e75 # via # -r src/backend/requirements.in - # dj-rest-auth # django-allauth - # django-allauth-2fa # django-cors-headers # django-dbbackup # django-error-report-2 @@ -414,20 +411,13 @@ django==4.2.19 \ # django-stdimage # django-structlog # django-taggit - # django-user-sessions # django-weasyprint # django-xforwardedfor-middleware # djangorestframework # djangorestframework-simplejwt # drf-spectacular -django-allauth[openid, saml]==0.57.2 \ - --hash=sha256:51c400f61bfb15bd08e22543a65d551c8f563254064620c37c49766b1ba7e1ae - # via - # -r src/backend/requirements.in - # django-allauth-2fa -django-allauth-2fa==0.12.0 \ - --hash=sha256:4b0c4cfea9c30be7c0971421b9b1f4961a19a69af72b7b5c0c7a3dbbc2537253 \ - --hash=sha256:ba53f47fd777d13c48842a7b779ab5ad24ad8c50f05aba98c64810263a50382d +django-allauth[mfa, openid, saml, socialaccount]==65.4.1 \ + --hash=sha256:60b32aef7dbbcc213319aa4fd8f570e985266ea1162ae6ef7a26a24efca85c8c # via -r src/backend/requirements.in django-cleanup==9.0.0 \ --hash=sha256:19f8b0e830233f9f0f683b17181f414672a0f48afe3ea3cc80ba47ae40ad880c \ @@ -481,10 +471,10 @@ django-mptt==0.16.0 \ --hash=sha256:56c9606bf0b329b5f5afd55dd8bfd073612ea1d5999b10903b09de62bee84c8e \ --hash=sha256:8716849ba3318d94e2e100ed0923a05c1ffdf8195f8472b690dbaf737d2af3b5 # via -r src/backend/requirements.in -django-otp==1.5.4 \ - --hash=sha256:0d9497ea4fb13fc04d50b49aa53dd1c740fe4bc5dde0ca27fb394f84e5da7bac \ - --hash=sha256:783dea669ac0eaf5cd336f73839443584ee665af427a84175cca5a9d620366db - # via django-allauth-2fa +django-otp==1.3.0 \ + --hash=sha256:5277731bc05b6cdbf96aa84ac46018e30ed5fb248086053b0146f925de059060 \ + --hash=sha256:8f4156a3c14ce2aaa31379385eadf388925cd50fc4b5d20a3b944f454c98ff7c + # via -r src/backend/requirements.in django-picklefield==3.2 \ --hash=sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d \ --hash=sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c @@ -527,10 +517,6 @@ django-taggit==6.1.0 \ --hash=sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0 \ --hash=sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3 # via -r src/backend/requirements.in -django-user-sessions==2.0.0 \ - --hash=sha256:0965554279f556b47062965609fa08b3ae45bbc581001dbe84b2ea599cc67748 \ - --hash=sha256:41b8b1ebeb4736065efbc96437c9cfbf491c39e10fd547a76b98f2312e11fa3e - # via -r src/backend/requirements.in django-weasyprint==2.3.1 \ --hash=sha256:09cc1c40c92db34bed80154be7c959fea03d6001dc46fd599f3fd464d6a6dc72 \ --hash=sha256:cd35b8bd24b28128a17a2416d0e6f3e64cb727f25c53467150b4be16ccd01c19 @@ -543,7 +529,6 @@ djangorestframework==3.14.0 \ --hash=sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08 # via # -r src/backend/requirements.in - # dj-rest-auth # djangorestframework-simplejwt # drf-spectacular djangorestframework-simplejwt[crypto]==5.4.0 \ @@ -610,6 +595,10 @@ feedparser==6.0.11 \ --hash=sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45 \ --hash=sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5 # via -r src/backend/requirements.in +fido2==1.2.0 \ + --hash=sha256:e39f95920122d64283fda5e5581d95a206e704fa42846bfa4662f86aa0d3333b \ + --hash=sha256:f7c8ee62e359aa980a45773f9493965bb29ede1b237a9218169dbfe60c80e130 + # via django-allauth flexcache==0.3 \ --hash=sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656 \ --hash=sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32 @@ -1328,7 +1317,7 @@ qrcode[pil]==8.0 \ --hash=sha256:9fc05f03305ad27a709eb742cf3097fa19e6f6f93bb9e2f039c0979190f6f1b1 # via # -r src/backend/requirements.in - # django-allauth-2fa + # django-allauth rapidfuzz==3.12.1 \ --hash=sha256:00ceb8ff3c44ab0d6014106c71709c85dee9feedd6890eff77c814aa3798952b \ --hash=sha256:018506a53c3b20dcbda8c93d4484b9eb1764c93d5ea16be103cf6b0d8b11d860 \ diff --git a/src/frontend/src/components/buttons/SSOButton.tsx b/src/frontend/src/components/buttons/SSOButton.tsx index 2ae4da60c5..ba7e8cb44b 100644 --- a/src/frontend/src/components/buttons/SSOButton.tsx +++ b/src/frontend/src/components/buttons/SSOButton.tsx @@ -15,6 +15,7 @@ import { } from '@tabler/icons-react'; import { t } from '@lingui/macro'; +import { ProviderLogin } from '../../functions/auth'; import type { Provider } from '../../states/states'; const brandIcons: { [key: string]: JSX.Element } = { @@ -32,26 +33,17 @@ const brandIcons: { [key: string]: JSX.Element } = { }; export function SsoButton({ provider }: Readonly<{ provider: Provider }>) { - function login() { - window.location.href = provider.login; - } - return ( ); diff --git a/src/frontend/src/components/forms/AuthenticationForm.tsx b/src/frontend/src/components/forms/AuthenticationForm.tsx index 1cd64f898c..982b386265 100644 --- a/src/frontend/src/components/forms/AuthenticationForm.tsx +++ b/src/frontend/src/components/forms/AuthenticationForm.tsx @@ -21,6 +21,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { doBasicLogin, doSimpleLogin, + ensureCsrf, followRedirect } from '../../functions/auth'; import { showLoginNotification } from '../../functions/notifications'; @@ -34,7 +35,12 @@ export function AuthenticationForm() { }); const simpleForm = useForm({ initialValues: { email: '' } }); const [classicLoginMode, setMode] = useDisclosure(true); - const [auth_settings] = useServerApiState((state) => [state.auth_settings]); + const [auth_config, sso_enabled, password_forgotten_enabled] = + useServerApiState((state) => [ + state.auth_config, + state.sso_enabled, + state.password_forgotten_enabled + ]); const navigate = useNavigate(); const location = useLocation(); const { isLoggedIn } = useUserState(); @@ -45,8 +51,12 @@ export function AuthenticationForm() { setIsLoggingIn(true); if (classicLoginMode === true) { - doBasicLogin(classicForm.values.username, classicForm.values.password) - .then(() => { + doBasicLogin( + classicForm.values.username, + classicForm.values.password, + navigate + ) + .then((success) => { setIsLoggingIn(false); if (isLoggedIn()) { @@ -55,6 +65,8 @@ export function AuthenticationForm() { message: t`Logged in successfully` }); followRedirect(navigate, location?.state); + } else if (success) { + // MFA login } else { showLoginNotification({ title: t`Login failed`, @@ -92,10 +104,10 @@ export function AuthenticationForm() { return ( <> - {auth_settings?.sso_enabled === true ? ( + {sso_enabled() ? ( <> - {auth_settings.providers.map((provider) => ( + {auth_config?.socialaccount.providers.map((provider) => ( ))} @@ -124,7 +136,7 @@ export function AuthenticationForm() { placeholder={t`Your password`} {...classicForm.getInputProps('password')} /> - {auth_settings?.password_forgotten_enabled === true && ( + {password_forgotten_enabled() === true && ( [state.auth_settings]); + const [auth_config, registration_enabled, sso_registration] = + useServerApiState((state) => [ + state.auth_config, + state.registration_enabled, + state.sso_registration_enabled + ]); const [isRegistering, setIsRegistering] = useState(false); - function handleRegistration() { + async function handleRegistration() { + // check if passwords match + if ( + registrationForm.values.password !== registrationForm.values.password2 + ) { + registrationForm.setFieldError('password2', t`Passwords do not match`); + return; + } setIsRegistering(true); + + // remove password2 from the request + const { password2, ...vals } = registrationForm.values; + await ensureCsrf(); + api - .post(apiUrl(ApiEndpoints.user_register), registrationForm.values, { + .post(apiUrl(ApiEndpoints.auth_signup), vals, { headers: { Authorization: '' } }) .then((ret) => { - if (ret?.status === 204 || ret?.status === 201) { + if (ret?.status === 200) { setIsRegistering(false); showLoginNotification({ title: t`Registration successful`, @@ -210,27 +244,33 @@ export function RegistrationForm() { .catch((err) => { if (err.response?.status === 400) { setIsRegistering(false); - for (const [key, value] of Object.entries(err.response.data)) { - registrationForm.setFieldError(key, value as string); + + // collect all errors per field + const errors: { [key: string]: string[] } = {}; + for (const val of err.response.data.errors) { + if (!errors[val.param]) { + errors[val.param] = []; + } + errors[val.param].push(val.message); } - let err_msg = ''; - if (err.response?.data?.non_field_errors) { - err_msg = err.response.data.non_field_errors; + + for (const key in errors) { + registrationForm.setFieldError(key, errors[key]); } + showLoginNotification({ title: t`Input error`, - message: t`Check your input and try again. ` + err_msg, + message: t`Check your input and try again. `, success: false }); } }); } - const both_reg_enabled = - auth_settings?.registration_enabled && auth_settings?.sso_registration; + const both_reg_enabled = registration_enabled() && sso_registration(); return ( <> - {auth_settings?.registration_enabled && ( + {registration_enabled() && (
{})}> )} - {auth_settings?.sso_registration === true && ( + {sso_registration() && ( - {auth_settings.providers.map((provider) => ( + {auth_config?.socialaccount.providers.map((provider) => ( ))} @@ -292,18 +332,18 @@ export function RegistrationForm() { export function ModeSelector({ loginMode, - setMode + changePage }: Readonly<{ loginMode: boolean; - setMode: any; + changePage: (state: string) => void; }>) { - const [auth_settings] = useServerApiState((state) => [state.auth_settings]); - const registration_enabled = - auth_settings?.registration_enabled || - auth_settings?.sso_registration || - false; + const [sso_registration, registration_enabled] = useServerApiState( + (state) => [state.sso_registration_enabled, state.registration_enabled] + ); + const both_reg_enabled = + registration_enabled() || sso_registration() || false; - if (registration_enabled === false) return null; + if (both_reg_enabled === false) return null; return ( {loginMode ? ( @@ -314,7 +354,7 @@ export function ModeSelector({ type='button' c='dimmed' size='xs' - onClick={() => setMode.close()} + onClick={() => changePage('register')} > Register @@ -325,7 +365,7 @@ export function ModeSelector({ type='button' c='dimmed' size='xs' - onClick={() => setMode.open()} + onClick={() => changePage('login')} > Go back to login diff --git a/src/frontend/src/components/panels/PanelGroup.tsx b/src/frontend/src/components/panels/PanelGroup.tsx index f857cccc33..17e19e8544 100644 --- a/src/frontend/src/components/panels/PanelGroup.tsx +++ b/src/frontend/src/components/panels/PanelGroup.tsx @@ -116,20 +116,20 @@ function BasePanelGroup({ // Callback when the active panel changes const handlePanelChange = useCallback( - (panel: string, event?: any) => { + (targetPanel: string, event?: any) => { if (event && (event?.ctrlKey || event?.shiftKey)) { - const url = `${location.pathname}/../${panel}`; + const url = `${location.pathname}/../${targetPanel}`; cancelEvent(event); navigateToLink(url, navigate, event); } else { - navigate(`../${panel}`); + navigate(`../${targetPanel}`); } - localState.setLastUsedPanel(pageKey)(panel); + localState.setLastUsedPanel(pageKey)(targetPanel); // Optionally call external callback hook - if (panel && onPanelChange) { - onPanelChange(panel); + if (targetPanel && onPanelChange) { + onPanelChange(targetPanel); } }, [activePanels, navigate, location, onPanelChange] diff --git a/src/frontend/src/defaults/defaults.tsx b/src/frontend/src/defaults/defaults.tsx index 142a0b711b..1463538a23 100644 --- a/src/frontend/src/defaults/defaults.tsx +++ b/src/frontend/src/defaults/defaults.tsx @@ -20,6 +20,7 @@ export const emptyServerAPI = { target: null, default_locale: null, django_admin: null, + settings: null, customize: null }; diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 3f3e2712cc..442b342b29 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -16,18 +16,25 @@ export enum ApiEndpoints { user_token = 'user/token/', user_tokens = 'user/tokens/', user_simple_login = 'email/generate/', - user_reset = 'auth/password/reset/', - user_reset_set = 'auth/password/reset/confirm/', - user_change_password = 'auth/password/change/', - user_sso = 'auth/social/', - user_sso_remove = 'auth/social/:id/disconnect/', - user_emails = 'auth/emails/', - user_email_remove = 'auth/emails/:id/remove/', - user_email_verify = 'auth/emails/:id/verify/', - user_email_primary = 'auth/emails/:id/primary/', - user_login = 'auth/login/', - user_logout = 'auth/logout/', - user_register = 'auth/registration/', + + // User auth endpoints + user_reset = 'auth/v1/auth/password/request', + user_reset_set = 'auth/v1/auth/password/reset', + auth_pwd_change = 'auth/v1/account/password/change', + auth_login = 'auth/v1/auth/login', + auth_login_2fa = 'auth/v1/auth/2fa/authenticate', + auth_session = 'auth/v1/auth/session', + auth_signup = 'auth/v1/auth/signup', + auth_authenticators = 'auth/v1/account/authenticators', + auth_recovery = 'auth/v1/account/authenticators/recovery-codes', + auth_mfa_reauthenticate = 'auth/v1/auth/2fa/reauthenticate', + auth_totp = 'auth/v1/account/authenticators/totp', + auth_reauthenticate = 'auth/v1/auth/reauthenticate', + auth_email = 'auth/v1/account/email', + auth_email_verify = 'auth/v1/auth/email/verify', + auth_providers = 'auth/v1/account/providers', + auth_provider_redirect = 'auth/v1/auth/provider/redirect', + auth_config = 'auth/v1/config', // Generic API endpoints currency_list = 'currency/exchange/', @@ -45,7 +52,6 @@ export enum ApiEndpoints { custom_state_list = 'generic/status/custom/', version = 'version/', license = 'license/', - sso_providers = 'auth/providers/', group_list = 'user/group/', owner_list = 'user/owner/', content_type_list = 'contenttype/', diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index 5e4aa69b71..788f205a86 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -1,15 +1,16 @@ import { t } from '@lingui/macro'; import { notifications } from '@mantine/notifications'; import axios from 'axios'; -import type { NavigateFunction } from 'react-router-dom'; - +import type { AxiosRequestConfig } from 'axios'; +import type { Location, NavigateFunction } from 'react-router-dom'; import { api, setApiDefaults } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; -import { apiUrl } from '../states/ApiState'; +import { apiUrl, useServerApiState } from '../states/ApiState'; import { useLocalState } from '../states/LocalState'; import { useUserState } from '../states/UserState'; -import { fetchGlobalStates } from '../states/states'; +import { type Provider, fetchGlobalStates } from '../states/states'; import { showLoginNotification } from './notifications'; +import { generateUrl } from './urls'; export function followRedirect(navigate: NavigateFunction, redirect: any) { let url = redirect?.redirectUrl ?? '/home'; @@ -59,24 +60,29 @@ function post(path: string, params: any, method = 'post') { * If login is successful, an API token will be returned. * This API token is used for any future API requests. */ -export const doBasicLogin = async (username: string, password: string) => { +export const doBasicLogin = async ( + username: string, + password: string, + navigate: NavigateFunction +) => { const { host } = useLocalState.getState(); const { clearUserState, setToken, fetchUserState } = useUserState.getState(); + const { setAuthContext } = useServerApiState.getState(); if (username.length == 0 || password.length == 0) { return; } clearCsrfCookie(); + await ensureCsrf(); - const login_url = apiUrl(ApiEndpoints.user_login); - - let result = false; + let loginDone = false; + let success = false; // Attempt login with await api .post( - login_url, + apiUrl(ApiEndpoints.auth_login), { username: username, password: password @@ -86,33 +92,41 @@ export const doBasicLogin = async (username: string, password: string) => { } ) .then((response) => { - if (response.status == 200) { - if (response.data.key) { - setToken(response.data.key); - result = true; - } + setAuthContext(response.data?.data); + if (response.status == 200 && response.data?.meta?.is_authenticated) { + setToken(response.data.meta.access_token); + loginDone = true; + success = true; } }) .catch((err) => { - if ( - err?.response?.status == 403 && - err?.response?.data?.detail == 'MFA required for this user' - ) { - post(apiUrl(ApiEndpoints.user_login), { - username: username, - password: password, - csrfmiddlewaretoken: getCsrfCookie(), - mfa: true + if (err?.response?.status == 401) { + setAuthContext(err.response.data?.data); + const mfa_flow = err.response.data.data.flows.find( + (flow: any) => flow.id == 'mfa_authenticate' + ); + if (mfa_flow && mfa_flow.is_pending == true) { + success = true; + navigate('/mfa'); + } + } else if (err?.response?.status == 409) { + notifications.show({ + title: t`Already logged in`, + message: t`There is a conflicting session on the server for this browser. Please logout of that first.`, + color: 'red', + autoClose: false }); } }); - if (result) { + if (loginDone) { await fetchUserState(); - fetchGlobalStates(); - } else { + // see if mfa registration is required + await fetchGlobalStates(navigate); + } else if (!success) { clearUserState(); } + return success; }; /** @@ -125,8 +139,9 @@ export const doLogout = async (navigate: NavigateFunction) => { // Logout from the server session if (isLoggedIn() || !!getCsrfCookie()) { - await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => {}); - + await authApi(apiUrl(ApiEndpoints.auth_session), undefined, 'delete').catch( + () => {} + ); showLoginNotification({ title: t`Logged Out`, message: t`Successfully logged out` @@ -158,26 +173,70 @@ export const doSimpleLogin = async (email: string) => { return mail; }; -export function handleReset(navigate: any, values: { email: string }) { - api - .post(apiUrl(ApiEndpoints.user_reset), values, { - headers: { Authorization: '' } +export async function ensureCsrf() { + const cookie = getCsrfCookie(); + if (cookie == undefined) { + await api.get(apiUrl(ApiEndpoints.user_token)).catch(() => {}); + } +} + +export function handleReset( + navigate: NavigateFunction, + values: { email: string } +) { + ensureCsrf(); + api.post(apiUrl(ApiEndpoints.user_reset), values).then((val) => { + if (val.status === 200) { + notifications.show({ + title: t`Mail delivery successful`, + message: t`Check your inbox for a reset link. This only works if you have an account. Check in spam too.`, + color: 'green', + autoClose: false + }); + navigate('/login'); + } else { + notifications.show({ + title: t`Reset failed`, + message: t`Check your input and try again.`, + color: 'red' + }); + } + }); +} + +export function handleMfaLogin( + navigate: NavigateFunction, + location: Location, + values: { code: string }, + setError: (message: string | undefined) => void +) { + const { setToken } = useUserState.getState(); + const { setAuthContext } = useServerApiState.getState(); + authApi(apiUrl(ApiEndpoints.auth_login_2fa), undefined, 'post', { + code: values.code + }) + .then((response) => { + setError(undefined); + setAuthContext(response.data?.data); + setToken(response.data.meta.access_token); + followRedirect(navigate, location?.state); }) - .then((val) => { - if (val.status === 200) { + .catch((err) => { + if (err?.response?.status == 409) { notifications.show({ - title: t`Mail delivery successful`, - message: t`Check your inbox for a reset link. This only works if you have an account. Check in spam too.`, - color: 'green', + title: t`Already logged in`, + message: t`There is a conflicting session on the server for this browser. Please logout of that first.`, + color: 'red', autoClose: false }); - navigate('/login'); } else { - notifications.show({ - title: t`Reset failed`, - message: t`Check your input and try again.`, - color: 'red' - }); + const errors = err.response?.data?.errors; + let msg = t`An error occurred`; + + if (errors) { + msg = errors.map((e: any) => e.message).join(', '); + } + setError(msg); } }); } @@ -209,7 +268,7 @@ export const checkLoginState = async ( message: t`Successfully logged in` }); - fetchGlobalStates(); + fetchGlobalStates(navigate); followRedirect(navigate, redirect); }; @@ -257,3 +316,45 @@ export function clearCsrfCookie() { document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; } + +export async function ProviderLogin( + provider: Provider, + process: 'login' | 'connect' = 'login' +) { + await ensureCsrf(); + post(generateUrl(apiUrl(ApiEndpoints.auth_provider_redirect)), { + provider: provider.id, + callback_url: generateUrl('/logged-in'), + process: process, + csrfmiddlewaretoken: getCsrfCookie() + }); +} + +/** + * Makes an API request with session tokens using the provided URL, configuration, method, and data. + * + * @param url - The URL to which the request is sent. + * @param config - Optional Axios request configuration. + * @param method - The HTTP method to use for the request. Defaults to 'get'. + * @param data - Optional data to be sent with the request. + * @returns A promise that resolves to the response of the API request. + */ +export function authApi( + url: string, + config: AxiosRequestConfig | undefined = undefined, + method: 'get' | 'post' | 'put' | 'delete' = 'get', + data?: any +) { + const requestConfig = config || {}; + + // set method + requestConfig.method = method; + + // set data + if (data) { + requestConfig.data = data; + } + + // use normal api + return api(url, requestConfig); +} diff --git a/src/frontend/src/pages/Auth/ChangePassword.tsx b/src/frontend/src/pages/Auth/ChangePassword.tsx index 6b6a2b96cd..4e2087501c 100644 --- a/src/frontend/src/pages/Auth/ChangePassword.tsx +++ b/src/frontend/src/pages/Auth/ChangePassword.tsx @@ -19,12 +19,14 @@ import { StylishText } from '../../components/items/StylishText'; import { ProtectedRoute } from '../../components/nav/Layout'; import { LanguageContext } from '../../contexts/LanguageContext'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { clearCsrfCookie } from '../../functions/auth'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; export default function Set_Password() { const simpleForm = useForm({ initialValues: { + current_password: '', new_password1: '', new_password2: '' } @@ -35,8 +37,10 @@ export default function Set_Password() { function passwordError(values: any) { let message: any = + values?.new_password || values?.new_password2 || values?.new_password1 || + values?.current_password || values?.error || t`Password could not be changed`; @@ -55,27 +59,45 @@ export default function Set_Password() { } function handleSet() { + const { clearUserState } = useUserState.getState(); + + // check if passwords match + if (simpleForm.values.new_password1 !== simpleForm.values.new_password2) { + passwordError({ new_password2: t`The two password fields didn’t match` }); + return; + } + // Set password with call to backend api - .post(apiUrl(ApiEndpoints.user_change_password), { - new_password1: simpleForm.values.new_password1, - new_password2: simpleForm.values.new_password2 + .post(apiUrl(ApiEndpoints.auth_pwd_change), { + current_password: simpleForm.values.current_password, + new_password: simpleForm.values.new_password2 }) .then((val) => { - if (val.status === 200) { + passwordError(val.data); + }) + .catch((err) => { + if (err.status === 401) { notifications.show({ title: t`Password Changed`, message: t`The password was set successfully. You can now login with your new password`, color: 'green', autoClose: false }); + clearUserState(); + clearCsrfCookie(); navigate('/login'); } else { - passwordError(val.data); + // compile errors + const errors: { [key: string]: string[] } = {}; + for (const val of err.response.data.errors) { + if (!errors[val.param]) { + errors[val.param] = []; + } + errors[val.param].push(val.message); + } + passwordError(errors); } - }) - .catch((err) => { - passwordError(err.response.data); }); } @@ -97,6 +119,13 @@ export default function Set_Password() { )} + { + if (location.pathname === '/register') { + setMode.close(); + } else { + setMode.open(); + } + }, [location]); + const LoginMessage = useMemo(() => { const val = server.customize?.login_message; if (val) { @@ -95,7 +103,8 @@ export default function Login() { if (searchParams.has('login') && searchParams.has('password')) { doBasicLogin( searchParams.get('login') ?? '', - searchParams.get('password') ?? '' + searchParams.get('password') ?? '', + navigate ).then(() => { followRedirect(navigate, location?.state); }); @@ -135,7 +144,10 @@ export default function Login() { {loginMode ? : } - + navigate(`/${newPage}`)} + /> {LoginMessage} (undefined); + + return ( + +
+ + + + <Trans>MFA Login</Trans> + + + + + + + +
+
+ ); +} diff --git a/src/frontend/src/pages/Auth/MFASetup.tsx b/src/frontend/src/pages/Auth/MFASetup.tsx new file mode 100644 index 0000000000..c4c7901a55 --- /dev/null +++ b/src/frontend/src/pages/Auth/MFASetup.tsx @@ -0,0 +1,76 @@ +import { Trans, t } from '@lingui/macro'; +import { Button, Center, Container, Stack, Title } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { LanguageContext } from '../../contexts/LanguageContext'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { authApi, doLogout, followRedirect } from '../../functions/auth'; +import { apiUrl } from '../../states/ApiState'; +import { QrRegistrationForm } from '../Index/Settings/AccountSettings/QrRegistrationForm'; + +export default function MFASetup() { + const navigate = useNavigate(); + const location = useLocation(); + + const [totpQr, setTotpQr] = useState<{ totp_url: string; secret: string }>(); + const [value, setValue] = useState(''); + + const registerTotp = async () => { + await authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'get').catch( + (err) => { + if (err.status == 404 && err.response.data.meta.secret) { + setTotpQr(err.response.data.meta); + } else { + const msg = err.response.data.errors[0].message; + showNotification({ + title: t`Failed to set up MFA`, + message: msg, + color: 'red' + }); + } + } + ); + }; + + useEffect(() => { + if (!totpQr) { + registerTotp(); + } + }, [totpQr]); + + return ( + +
+ + + + <Trans>MFA Setup Required</Trans> + + + + + + +
+
+ ); +} diff --git a/src/frontend/src/pages/Auth/ResetPassword.tsx b/src/frontend/src/pages/Auth/ResetPassword.tsx index ac1d5a1362..c47bb96f42 100644 --- a/src/frontend/src/pages/Auth/ResetPassword.tsx +++ b/src/frontend/src/pages/Auth/ResetPassword.tsx @@ -22,32 +22,41 @@ export default function ResetPassword() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const token = searchParams.get('token'); - const uid = searchParams.get('uid'); + const key = searchParams.get('key'); - function invalidToken() { + function invalidKey() { notifications.show({ - title: t`Token invalid`, - message: t`You need to provide a valid token to set a new password. Check your inbox for a reset link.`, + title: t`Key invalid`, + message: t`You need to provide a valid key to set a new password. Check your inbox for a reset link.`, color: 'red' }); navigate('/login'); } + function success() { + notifications.show({ + title: t`Password set`, + message: t`The password was set successfully. You can now login with your new password`, + color: 'green', + autoClose: false + }); + navigate('/login'); + } + function passwordError(values: any) { notifications.show({ title: t`Reset failed`, - message: values?.new_password2 || values?.new_password1 || values?.token, + message: values?.errors.map((e: any) => e.message).join('\n'), color: 'red' }); } useEffect(() => { - // make sure we have a token - if (!token || !uid) { - invalidToken(); + // make sure we have a key + if (!key) { + invalidKey(); } - }, [token]); + }, [key]); function handleSet() { // Set password with call to backend @@ -55,32 +64,23 @@ export default function ResetPassword() { .post( apiUrl(ApiEndpoints.user_reset_set), { - uid: uid, - token: token, - new_password1: simpleForm.values.password, - new_password2: simpleForm.values.password + key: key, + password: simpleForm.values.password }, { headers: { Authorization: '' } } ) .then((val) => { if (val.status === 200) { - notifications.show({ - title: t`Password set`, - message: t`The password was set successfully. You can now login with your new password`, - color: 'green', - autoClose: false - }); - navigate('/login'); + success(); } else { passwordError(val.data); } }) .catch((err) => { - if ( - err.response?.status === 400 && - err.response?.data?.token == 'Invalid value' - ) { - invalidToken(); + if (err.response?.status === 400) { + passwordError(err.response.data); + } else if (err.response?.status === 401) { + success(); } else { passwordError(err.response.data); } @@ -99,12 +99,12 @@ export default function ResetPassword() {
diff --git a/src/frontend/src/pages/Auth/VerifyEmail.tsx b/src/frontend/src/pages/Auth/VerifyEmail.tsx new file mode 100644 index 0000000000..c6e098252d --- /dev/null +++ b/src/frontend/src/pages/Auth/VerifyEmail.tsx @@ -0,0 +1,61 @@ +import { Trans, t } from '@lingui/macro'; +import { Button, Center, Container, Stack, Title } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { api } from '../../App'; +import { LanguageContext } from '../../contexts/LanguageContext'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { apiUrl } from '../../states/ApiState'; + +export default function VerifyEmail() { + const { key } = useParams(); + const navigate = useNavigate(); + + function invalidKey() { + notifications.show({ + title: t`Key invalid`, + message: t`You need to provide a valid key.`, + color: 'red' + }); + navigate('/login'); + } + + useEffect(() => { + // make sure we have a key + if (!key) { + invalidKey(); + } + }, [key]); + + function handleSet() { + // Set password with call to backend + api + .post(apiUrl(ApiEndpoints.auth_email_verify), { + key: key + }) + .then((val) => { + if (val.status === 200) { + navigate('/login'); + } + }); + } + + return ( + +
+ + + + <Trans>Verify Email</Trans> + + + + +
+
+ ); +} diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/QrRegistrationForm.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/QrRegistrationForm.tsx new file mode 100644 index 0000000000..54118d8d33 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/QrRegistrationForm.tsx @@ -0,0 +1,37 @@ +import { Trans, t } from '@lingui/macro'; +import { Divider, Text, TextInput } from '@mantine/core'; +import { QRCode } from '../../../../components/barcodes/QRCode'; + +export function QrRegistrationForm({ + url, + secret, + value, + error, + setValue +}: Readonly<{ + url: string; + secret: string; + value: string; + error?: string; + setValue: (value: string) => void; +}>) { + return ( + <> + + + + Secret +
+ {secret} +
+ setValue(event.currentTarget.value)} + error={error} + /> + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx index 089ee71025..1ef92b58d3 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx @@ -3,59 +3,55 @@ import { Alert, Badge, Button, + Code, Grid, Group, Loader, + Modal, Radio, Stack, Table, Text, TextInput, - Title, - Tooltip + Title } from '@mantine/core'; -import { IconAlertCircle, IconAt } from '@tabler/icons-react'; +import { useDisclosure } from '@mantine/hooks'; +import { hideNotification, showNotification } from '@mantine/notifications'; +import { + IconAlertCircle, + IconAt, + IconExclamationCircle, + IconX +} from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; - -import { api, queryClient } from '../../../../App'; +import { useMemo, useState } from 'react'; +import { api } from '../../../../App'; import { YesNoButton } from '../../../../components/buttons/YesNoButton'; -import { PlaceholderPill } from '../../../../components/items/Placeholder'; +import { StylishText } from '../../../../components/items/StylishText'; import { ApiEndpoints } from '../../../../enums/ApiEndpoints'; -import { apiUrl } from '../../../../states/ApiState'; -import { useUserState } from '../../../../states/UserState'; +import { ProviderLogin, authApi } from '../../../../functions/auth'; +import { apiUrl, useServerApiState } from '../../../../states/ApiState'; +import type { AuthConfig, Provider } from '../../../../states/states'; +import { QrRegistrationForm } from './QrRegistrationForm'; +import { useReauth } from './useConfirm'; export function SecurityContent() { - const [isSsoEnabled, setIsSsoEnabled] = useState(false); - const [isMfaEnabled, setIsMfaEnabled] = useState(false); - - const { isLoading: isLoadingProvider, data: dataProvider } = useQuery({ - queryKey: ['sso-providers'], - queryFn: () => - api.get(apiUrl(ApiEndpoints.sso_providers)).then((res) => res.data) - }); - - // evaluate if security options are enabled - useEffect(() => { - if (dataProvider === undefined) return; - - // check if SSO is enabled on the server - setIsSsoEnabled(dataProvider.sso_enabled || false); - // check if MFa is enabled - setIsMfaEnabled(dataProvider.mfa_required || false); - }, [dataProvider]); + const [auth_config, sso_enabled] = useServerApiState((state) => [ + state.auth_config, + state.sso_enabled + ]); return ( - <Trans>Email</Trans> + <Trans>Email Addresses</Trans> - + - <Trans>Single Sign On Accounts</Trans> + <Trans>Single Sign On</Trans> - {isSsoEnabled ? ( - + {sso_enabled() ? ( + ) : ( } @@ -66,65 +62,42 @@ export function SecurityContent() { )} - <Trans>Multifactor</Trans> + <Trans>Multifactor authentication</Trans> - {isLoadingProvider ? ( - - ) : ( - <> - {isMfaEnabled ? ( - - ) : ( - } - title={t`Not enabled`} - color='yellow' - > - - Multifactor authentication is not configured for your account{' '} - - - )} - - )} - + - <Trans>Token</Trans> + <Trans>Access Tokens</Trans> - + ); } -function EmailContent() { +function EmailSection() { const [value, setValue] = useState(''); const [newEmailValue, setNewEmailValue] = useState(''); - const [user] = useUserState((state) => [state.user]); const { isLoading, data, refetch } = useQuery({ queryKey: ['emails'], queryFn: () => - api.get(apiUrl(ApiEndpoints.user_emails)).then((res) => res.data) + authApi(apiUrl(ApiEndpoints.auth_email)).then((res) => res.data.data) }); + const emailAvailable = useMemo(() => { + return data == undefined || data.length == 0; + }, [data]); - function runServerAction(url: ApiEndpoints) { - api - .post(apiUrl(url, undefined, { id: value }), {}) - .then(() => { - refetch(); - }) - .catch((res) => console.log(res.data)); - } - - function addEmail() { - api - .post(apiUrl(ApiEndpoints.user_emails), { - email: newEmailValue, - user: user?.pk - }) - .then(() => { - refetch(); - }) - .catch((res) => console.log(res.data)); + function runServerAction( + action: 'post' | 'put' | 'delete' = 'post', + data?: any + ) { + const vals: any = data || { email: value }; + return authApi( + apiUrl(ApiEndpoints.auth_email), + undefined, + action, + vals + ).then(() => { + refetch(); + }); } if (isLoading) return ; @@ -132,40 +105,50 @@ function EmailContent() { return ( - - - {data.map((link: any) => ( - - {link.email} - {link.primary && ( - - Primary - - )} - {link.verified ? ( - - Verified - - ) : ( - - Unverified - - )} - - } - /> - ))} - - + {emailAvailable ? ( + } + title={t`Not configured`} + color='yellow' + > + Currently no email addresses are registered. + + ) : ( + + + {data.map((email: any) => ( + + {email.email} + {email.primary && ( + + Primary + + )} + {email.verified ? ( + + Verified + + ) : ( + + Unverified + + )} + + } + /> + ))} + + + )} @@ -184,24 +167,44 @@ function EmailContent() { - @@ -209,68 +212,51 @@ function EmailContent() { ); } -function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) { +function ProviderButton({ provider }: Readonly<{ provider: Provider }>) { + return ( + + ); +} + +function ProviderSection({ + auth_config +}: Readonly<{ auth_config: AuthConfig | undefined }>) { const [value, setValue] = useState(''); - const [currentProviders, setCurrentProviders] = useState<[]>(); - const { isLoading, data } = useQuery({ - queryKey: ['sso-list'], + const { isLoading, data, refetch } = useQuery({ + queryKey: ['provider-list'], queryFn: () => - api.get(apiUrl(ApiEndpoints.user_sso)).then((res) => res.data) + authApi(apiUrl(ApiEndpoints.auth_providers)).then((res) => res.data.data) }); - useEffect(() => { - if (dataProvider === undefined) return; - if (data === undefined) return; + const availableProviders = useMemo(() => { + if (!auth_config || !data) return []; - const configuredProviders = data.map((item: any) => { - return item.provider; - }); - function isAlreadyInUse(value: any) { - return !configuredProviders.includes(value.id); - } - - // remove providers that are used currently - let newData = dataProvider.providers; - newData = newData.filter(isAlreadyInUse); - setCurrentProviders(newData); - }, [dataProvider, data]); + const configuredProviders = data.map((item: any) => item.provider.id); + return auth_config.socialaccount.providers.filter( + (provider: any) => !configuredProviders.includes(provider.id) + ); + }, [auth_config, data]); function removeProvider() { - api - .post(apiUrl(ApiEndpoints.user_sso_remove, undefined, { id: value })) + const [uid, provider] = value.split('$'); + authApi(apiUrl(ApiEndpoints.auth_providers), undefined, 'delete', { + provider, + account: uid + }) .then(() => { - queryClient.removeQueries({ - queryKey: ['sso-list'] - }); + refetch(); }) .catch((res) => console.log(res.data)); } - /* renderer */ if (isLoading) return ; - function ProviderButton({ provider }: Readonly<{ provider: any }>) { - const button = ( - - ); - - if (provider.configured) return button; - return ( - {button} - ); - } - return ( @@ -280,9 +266,7 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) { title={t`Not configured`} color='yellow' > - - There are no social network accounts connected to this account.{' '} - + There are no providers connected to this account. ) : ( @@ -290,20 +274,20 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) { value={value} onChange={setValue} name='sso_accounts' - label={t`You can sign in to your account using any of the following third party accounts`} + label={t`You can sign in to your account using any of the following providers`} > {data.map((link: any) => ( ))} )} @@ -311,33 +295,394 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) { Add SSO Account - - {currentProviders === undefined ? ( + {availableProviders === undefined ? ( + Loading - ) : ( - - {currentProviders.map((provider: any) => ( - - ))} - - )} - + + ) : ( + + {availableProviders.map((provider: any) => ( + + ))} + + )} ); } -function MfaContent() { +function MfaSection() { + const [getReauthText, ReauthModal] = useReauth(); + const [recoveryCodes, setRecoveryCodes] = useState< + Recoverycodes | undefined + >(); + const [ + recoveryCodesOpen, + { open: openRecoveryCodes, close: closeRecoveryCodes } + ] = useDisclosure(false); + const { isLoading, data, refetch } = useQuery({ + queryKey: ['mfa-list'], + queryFn: () => + api + .get(apiUrl(ApiEndpoints.auth_authenticators)) + .then((res) => res.data.data) + }); + + function showRecoveryCodes(codes: Recoverycodes) { + setRecoveryCodes(codes); + openRecoveryCodes(); + } + + const removeTotp = () => { + runActionWithFallback( + () => + authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'delete').then( + () => { + refetch(); + return ResultType.success; + } + ), + getReauthText + ); + }; + const viewRecoveryCodes = () => { + runActionWithFallback( + () => + authApi(apiUrl(ApiEndpoints.auth_recovery), undefined, 'get').then( + (res) => { + showRecoveryCodes(res.data.data); + return ResultType.success; + } + ), + getReauthText + ); + }; + + const parseDate = (date: number) => + date == null ? 'Never' : new Date(date * 1000).toLocaleString(); + + const rows = useMemo(() => { + if (isLoading || !data) return null; + return data.map((token: any) => ( + + {token.type} + {parseDate(token.last_used_at)} + {parseDate(token.created_at)} + + {token.type == 'totp' && ( + + )} + {token.type == 'recovery_codes' && ( + + )} + + + )); + }, [data, isLoading]); + + const usedFactors: string[] = useMemo(() => { + if (isLoading || !data) return []; + return data.map((token: any) => token.type); + }, [data]); + + if (isLoading) return ; + return ( <> - MFA Details - + + + + {data.length == 0 ? ( + } color='yellow'> + No factors configured + + ) : ( + + + + + Type + + + Last used at + + + Created at + + + Actions + + + + {rows} +
+ )} +
+ + + { + refetch(); + closeRecoveryCodes(); + }} + title={t`Recovery Codes`} + centered + > + + <Trans>Unused Codes</Trans> + + {recoveryCodes?.unused_codes?.join('\n')} + + + <Trans>Used Codes</Trans> + + {recoveryCodes?.used_codes?.join('\n')} + + +
); } -function TokenContent() { +enum ResultType { + success = 0, + reauth = 1, + mfareauth = 2, + error = 3 +} + +export interface Recoverycodes { + type: string; + created_at: number; + last_used_at: null; + total_code_count: number; + unused_code_count: number; + unused_codes: string[]; + used_code_count: number; + used_codes: string[]; +} + +function MfaAddSection({ + usedFactors, + refetch, + showRecoveryCodes +}: Readonly<{ + usedFactors: string[]; + refetch: () => void; + showRecoveryCodes: (codes: Recoverycodes) => void; +}>) { + const [auth_config] = useServerApiState((state) => [state.auth_config]); + const [totpQrOpen, { open: openTotpQr, close: closeTotpQr }] = + useDisclosure(false); + const [totpQr, setTotpQr] = useState<{ totp_url: string; secret: string }>(); + const [value, setValue] = useState(''); + const [getReauthText, ReauthModal] = useReauth(); + + const registerRecoveryCodes = async () => { + await runActionWithFallback( + () => + authApi(apiUrl(ApiEndpoints.auth_recovery), undefined, 'post') + .then((res) => { + showRecoveryCodes(res.data.data); + return ResultType.success; + }) + .catch((err) => { + showNotification({ + title: t`Error while registering recovery codes`, + message: err.response.data.errors + .map((error: any) => error.message) + .join('\n'), + color: 'red', + icon: + }); + + return ResultType.error; + }), + getReauthText + ); + }; + const registerTotp = async () => { + await runActionWithFallback( + () => + authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'get') + .then(() => ResultType.error) + .catch((err) => { + if (err.status == 404 && err.response.data.meta.secret) { + setTotpQr(err.response.data.meta); + openTotpQr(); + return ResultType.success; + } + return ResultType.error; + }), + getReauthText + ); + }; + + const possibleFactors = useMemo(() => { + return [ + { + type: 'totp', + name: t`TOTP`, + description: t`Time-based One-Time Password`, + function: registerTotp, + used: usedFactors?.includes('totp') + }, + { + type: 'recovery_codes', + name: t`Recovery Codes`, + description: t`One-Time pre-generated recovery codes`, + function: registerRecoveryCodes, + used: usedFactors?.includes('recovery_codes') + } + ].filter((factor) => { + return auth_config?.mfa?.supported_types.includes(factor.type); + }); + }, [usedFactors, auth_config]); + + const [totpError, setTotpError] = useState(''); + + return ( + + + Add Factor + {possibleFactors.map((factor) => ( + + ))} + {t`Register TOTP Token`}} + > + + + + + + + ); +} + +async function runActionWithFallback( + action: () => Promise, + getReauthText: (props: any) => any +) { + const { setAuthContext } = useServerApiState.getState(); + const rslt = await action().catch((err) => { + setAuthContext(err.response.data?.data); + // check if we need to re-authenticate + if (err.status == 401) { + if ( + err.response.data.data.flows.find( + (flow: any) => flow.id == 'mfa_reauthenticate' + ) + ) { + return ResultType.mfareauth; + } else if ( + err.response.data.data.flows.find( + (flow: any) => flow.id == 'reauthenticate' + ) + ) { + return ResultType.reauth; + } else { + return ResultType.error; + } + } else { + return ResultType.error; + } + }); + if (rslt == ResultType.mfareauth) { + authApi(apiUrl(ApiEndpoints.auth_mfa_reauthenticate), undefined, 'post', { + code: await getReauthText({ + label: t`TOTP Code`, + name: 'TOTP', + description: t`Enter your TOTP or recovery code` + }) + }) + .then((response) => { + setAuthContext(response.data?.data); + action(); + }) + .catch((err) => { + setAuthContext(err.response.data?.data); + }); + } else if (rslt == ResultType.reauth) { + authApi(apiUrl(ApiEndpoints.auth_reauthenticate), undefined, 'post', { + password: await getReauthText({ + label: t`Password`, + name: 'password', + description: t`Enter your password` + }) + }) + .then((response) => { + setAuthContext(response.data?.data); + action(); + }) + .catch((err) => { + setAuthContext(err.response.data?.data); + }); + } +} + +function TokenSection() { const { isLoading, data, refetch } = useQuery({ queryKey: ['token-list'], queryFn: () => @@ -352,8 +697,9 @@ function TokenContent() { }) .catch((res) => console.log(res.data)); } + const rows = useMemo(() => { - if (isLoading || data === undefined) return null; + if (isLoading || !data) return null; return data.map((token: any) => ( @@ -380,7 +726,6 @@ function TokenContent() { )); }, [data, isLoading]); - /* renderer */ if (isLoading) return ; if (data.length == 0) diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/useConfirm.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/useConfirm.tsx new file mode 100644 index 0000000000..d7872a197a --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/useConfirm.tsx @@ -0,0 +1,116 @@ +import { Trans, t } from '@lingui/macro'; +import { Button, Group, Modal, Stack, TextInput } from '@mantine/core'; +import { useState } from 'react'; + +/* Adapted from https://daveteu.medium.com/react-custom-confirmation-box-458cceba3f7b */ +const createPromise = () => { + let resolver: any; + return [ + new Promise((resolve) => { + resolver = resolve; + }), + resolver + ]; +}; + +/* Adapted from https://daveteu.medium.com/react-custom-confirmation-box-458cceba3f7b */ +export const useConfirm = () => { + const [open, setOpen] = useState(false); + const [resolver, setResolver] = useState<((status: boolean) => void) | null>( + null + ); + const [label, setLabel] = useState(''); + + const getConfirmation = async (text: string) => { + setLabel(text); + setOpen(true); + const [promise, resolve] = await createPromise(); + + setResolver(resolve); + return promise; + }; + + const onClick = async (status: boolean) => { + setOpen(false); + if (resolver) { + resolver(status); + } + }; + + const Confirmation = () => ( + setOpen(false)}> + {label} + + + + ); + + return [getConfirmation, Confirmation]; +}; + +type InputProps = { + label: string; + name: string; + description: string; +}; +export const useReauth = (): [ + (props: InputProps) => Promise<[string, boolean]>, + () => JSX.Element +] => { + const [inputProps, setInputProps] = useState({ + label: '', + name: '', + description: '' + }); + + const [value, setValue] = useState(''); + const [open, setOpen] = useState(false); + const [resolver, setResolver] = useState<{ + resolve: (result: string, positive: boolean) => void; + } | null>(null); + + const getReauthText = async (props: InputProps) => { + setInputProps(props); + setOpen(true); + const [promise, resolve] = await createPromise(); + + setResolver({ resolve }); + return promise; + }; + + const onClick = async (result: string, positive: boolean) => { + setOpen(false); + if (resolver) { + resolver.resolve(result, positive); + } + }; + + const ReauthModal = () => ( + setOpen(false)} + title={t`Reauthentication`} + > + + setValue(event.currentTarget.value)} + /> + + + + + + + ); + + return [getReauthText, ReauthModal]; +}; diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index 1eff80d49d..4fc9a2a6df 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -104,6 +104,8 @@ export const NotFound = Loadable( lazy(() => import('./components/errors/NotFound')) ); export const Login = Loadable(lazy(() => import('./pages/Auth/Login'))); +export const MFALogin = Loadable(lazy(() => import('./pages/Auth/MFALogin'))); +export const MFASetup = Loadable(lazy(() => import('./pages/Auth/MFASetup'))); export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout'))); export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In'))); export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset'))); @@ -115,6 +117,9 @@ export const ChangePassword = Loadable( export const ResetPassword = Loadable( lazy(() => import('./pages/Auth/ResetPassword')) ); +export const VerifyEmail = Loadable( + lazy(() => import('./pages/Auth/VerifyEmail')) +); // Routes export const routes = ( @@ -170,11 +175,15 @@ export const routes = ( }> } />, + } />, + } />, + } />, } />, } /> } /> } /> } /> + } /> ); diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index 6043111261..b4bc557b56 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -4,18 +4,31 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { api } from '../App'; import { emptyServerAPI } from '../defaults/defaults'; import { ApiEndpoints } from '../enums/ApiEndpoints'; -import type { AuthProps, ServerAPIProps } from './states'; +import type { AuthConfig, AuthContext, ServerAPIProps } from './states'; interface ServerApiStateProps { server: ServerAPIProps; setServer: (newServer: ServerAPIProps) => void; fetchServerApiState: () => void; - auth_settings?: AuthProps; + auth_config?: AuthConfig; + auth_context?: AuthContext; + setAuthContext: (auth_context: AuthContext) => void; + sso_enabled: () => boolean; + registration_enabled: () => boolean; + sso_registration_enabled: () => boolean; + password_forgotten_enabled: () => boolean; +} + +function get_server_setting(val: any) { + if (val === null || val === undefined) { + return false; + } + return val; } export const useServerApiState = create()( persist( - (set) => ({ + (set, get) => ({ server: emptyServerAPI, setServer: (newServer: ServerAPIProps) => set({ server: newServer }), fetchServerApiState: async () => { @@ -31,17 +44,36 @@ export const useServerApiState = create()( // Fetch login/SSO behaviour await api - .get(apiUrl(ApiEndpoints.sso_providers), { + .get(apiUrl(ApiEndpoints.auth_config), { headers: { Authorization: '' } }) .then((response) => { - set({ auth_settings: response.data }); + set({ auth_config: response.data.data }); }) .catch(() => { console.error('ERR: Error fetching SSO information'); }); }, - status: undefined + auth_config: undefined, + auth_context: undefined, + setAuthContext(auth_context) { + set({ auth_context }); + }, + sso_enabled: () => { + const data = get().auth_config?.socialaccount.providers; + return !(data === undefined || data.length == 0); + }, + registration_enabled: () => { + return get_server_setting(get().server?.settings?.registration_enabled); + }, + sso_registration_enabled: () => { + return get_server_setting(get().server?.settings?.sso_registration); + }, + password_forgotten_enabled: () => { + return get_server_setting( + get().server?.settings?.password_forgotten_enabled + ); + } }), { name: 'server-api-state', diff --git a/src/frontend/src/states/SettingsState.tsx b/src/frontend/src/states/SettingsState.tsx index 396ca01c1d..88651bd140 100644 --- a/src/frontend/src/states/SettingsState.tsx +++ b/src/frontend/src/states/SettingsState.tsx @@ -13,7 +13,7 @@ import type { Setting, SettingsLookup } from './states'; export interface SettingsStateProps { settings: Setting[]; lookup: SettingsLookup; - fetchSettings: () => void; + fetchSettings: () => Promise; endpoint: ApiEndpoints; pathParams?: PathParams; getSetting: (key: string, default_value?: string) => string; // Return a raw setting value @@ -29,10 +29,11 @@ export const useGlobalSettingsState = create( lookup: {}, endpoint: ApiEndpoints.settings_global_list, fetchSettings: async () => { + let success = true; const { isLoggedIn } = useUserState.getState(); if (!isLoggedIn()) { - return; + return success; } await api @@ -45,7 +46,10 @@ export const useGlobalSettingsState = create( }) .catch((_error) => { console.error('ERR: Error fetching global settings'); + success = false; }); + + return success; }, getSetting: (key: string, default_value?: string) => { return get().lookup[key] ?? default_value ?? ''; @@ -65,10 +69,11 @@ export const useUserSettingsState = create((set, get) => ({ lookup: {}, endpoint: ApiEndpoints.settings_user_list, fetchSettings: async () => { + let success = true; const { isLoggedIn } = useUserState.getState(); if (!isLoggedIn()) { - return; + return success; } await api @@ -81,7 +86,10 @@ export const useUserSettingsState = create((set, get) => ({ }) .catch((_error) => { console.error('ERR: Error fetching user settings'); + success = false; }); + + return success; }, getSetting: (key: string, default_value?: string) => { return get().lookup[key] ?? default_value ?? ''; @@ -110,6 +118,8 @@ export const createPluginSettingsState = ({ endpoint: ApiEndpoints.plugin_setting_list, pathParams, fetchSettings: async () => { + let success = true; + await api .get(apiUrl(ApiEndpoints.plugin_setting_list, undefined, { plugin })) .then((response) => { @@ -121,7 +131,10 @@ export const createPluginSettingsState = ({ }) .catch((_error) => { console.error(`Error fetching plugin settings for plugin ${plugin}`); + success = false; }); + + return success; }, getSetting: (key: string, default_value?: string) => { return get().lookup[key] ?? default_value ?? ''; @@ -153,6 +166,8 @@ export const createMachineSettingsState = ({ endpoint: ApiEndpoints.machine_setting_detail, pathParams, fetchSettings: async () => { + let success = true; + await api .get(apiUrl(ApiEndpoints.machine_setting_list, undefined, { machine })) .then((response) => { @@ -169,7 +184,10 @@ export const createMachineSettingsState = ({ `Error fetching machine settings for machine ${machine} with type ${configType}:`, error ); + success = false; }); + + return success; }, getSetting: (key: string, default_value?: string) => { return get().lookup[key] ?? default_value ?? ''; diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index fe1bbee675..01af40db42 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -1,3 +1,4 @@ +import type { NavigateFunction } from 'react-router-dom'; import { setApiDefaults } from '../App'; import { useServerApiState } from './ApiState'; import { useIconState } from './IconState'; @@ -48,6 +49,11 @@ export interface ServerAPIProps { target: null | string; default_locale: null | string; django_admin: null | string; + settings: { + sso_registration: null | boolean; + registration_enabled: null | boolean; + password_forgotten_enabled: null | boolean; + } | null; customize: null | { logo: string; splash: string; @@ -56,22 +62,48 @@ export interface ServerAPIProps { }; } -export interface AuthProps { - sso_enabled: boolean; - sso_registration: boolean; - mfa_required: boolean; - providers: Provider[]; - registration_enabled: boolean; - password_forgotten_enabled: boolean; +export interface AuthContext { + status: number; + data: { flows: Flow[] }; + meta: { is_authenticated: boolean }; +} + +export enum FlowEnum { + VerifyEmail = 'verify_email', + Login = 'login', + Signup = 'signup', + ProviderRedirect = 'provider_redirect', + ProviderSignup = 'provider_signup', + ProviderToken = 'provider_token', + MfaAuthenticate = 'mfa_authenticate', + Reauthenticate = 'reauthenticate', + MfaReauthenticate = 'mfa_reauthenticate' +} + +export interface Flow { + id: FlowEnum; + providers?: string[]; + is_pending?: boolean[]; +} + +export interface AuthConfig { + account: { + authentication_method: string; + }; + socialaccount: { providers: Provider[] }; + mfa: { + supported_types: string[]; + }; + usersessions: { + track_activity: boolean; + }; } export interface Provider { id: string; name: string; - configured: boolean; - login: string; - connect: string; - display_name: string; + flows: string[]; + client_id: string; } // Type interface defining a single 'setting' object @@ -134,7 +166,9 @@ export type SettingsLookup = { * Refetch all global state information. * Necessary on login, or if locale is changed. */ -export function fetchGlobalStates() { +export async function fetchGlobalStates( + navigate?: NavigateFunction | undefined +) { const { isLoggedIn } = useUserState.getState(); if (!isLoggedIn()) { @@ -144,7 +178,12 @@ export function fetchGlobalStates() { setApiDefaults(); useServerApiState.getState().fetchServerApiState(); - useUserSettingsState.getState().fetchSettings(); + const result = await useUserSettingsState.getState().fetchSettings(); + if (!result && navigate) { + console.log('MFA is required - setting up'); + // call mfa setup + navigate('/mfa-setup'); + } useGlobalSettingsState.getState().fetchSettings(); useGlobalStatusState.getState().fetchStatus(); useIconState.getState().fetchIcons(); diff --git a/src/frontend/src/views/MainView.tsx b/src/frontend/src/views/MainView.tsx index 56c2bb77e6..c874ae7288 100644 --- a/src/frontend/src/views/MainView.tsx +++ b/src/frontend/src/views/MainView.tsx @@ -20,8 +20,12 @@ export default function MainView() { const [allowMobile] = useLocalState((state) => [state.allowMobile]); // Set initial login status useEffect(() => { - // Local state initialization - setApiDefaults(); + try { + // Local state initialization + setApiDefaults(); + } catch (e) { + console.error(e); + } }, []); // Check if mobile diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts index 6c70e3e38d..f7885824a3 100644 --- a/src/frontend/tests/baseFixtures.ts +++ b/src/frontend/tests/baseFixtures.ts @@ -71,6 +71,9 @@ export const test = baseTest.extend({ url != 'http://localhost:8000/this/does/not/exist.js' && url != 'http://localhost:8000/api/user/me/' && url != 'http://localhost:8000/api/user/token/' && + url != 'http://localhost:8000/api/auth/v1/auth/login' && + url != 'http://localhost:8000/api/auth/v1/auth/session' && + url != 'http://localhost:8000/api/auth/v1/account/password/change' && url != 'http://localhost:8000/api/barcode/' && url != 'https://docs.inventree.org/en/versions.json' && url != 'http://localhost:5173/favicon.ico' && diff --git a/src/frontend/tests/login.ts b/src/frontend/tests/login.ts index 18fd0e60fa..989bc6ea52 100644 --- a/src/frontend/tests/login.ts +++ b/src/frontend/tests/login.ts @@ -32,10 +32,10 @@ export const doQuickLogin = async ( password = password ?? user.password; url = url ?? baseUrl; - await navigate(page, `${url}/login/?login=${username}&password=${password}`); + await navigate(page, `${url}/login?login=${username}&password=${password}`); await page.waitForURL('**/platform/home'); - await page.getByLabel('navigation-menu').waitFor(); + await page.getByLabel('navigation-menu').waitFor({ timeout: 5000 }); await page.getByText(/InvenTree Demo Server -/).waitFor(); // Wait for the dashboard to load diff --git a/src/frontend/tests/pui_login.spec.ts b/src/frontend/tests/pui_login.spec.ts index ad38a16b59..caa5a76e5c 100644 --- a/src/frontend/tests/pui_login.spec.ts +++ b/src/frontend/tests/pui_login.spec.ts @@ -87,6 +87,7 @@ test('Login - Change Password', async ({ page }) => { await page.getByLabel('action-menu-user-actions-change-password').click(); // First attempt with some errors + await page.getByLabel('password', { exact: true }).fill('youshallnotpass'); await page.getByLabel('input-password-1').fill('12345'); await page.getByLabel('input-password-2').fill('54321'); await page.getByRole('button', { name: 'Confirm' }).click(); @@ -105,9 +106,5 @@ test('Login - Change Password', async ({ page }) => { await page.getByText('Password Changed').waitFor(); await page.getByText('The password was set successfully').waitFor(); - // Should have redirected to the index page - await page.waitForURL('**/platform/home**'); - await page.getByText('InvenTree Demo Server - Norman Nothington'); - await page.waitForTimeout(1000); }); diff --git a/tasks.py b/tasks.py index ee8a86a5c6..675af6cbb6 100644 --- a/tasks.py +++ b/tasks.py @@ -181,7 +181,6 @@ def content_excludes( 'exchange.exchangebackend', 'common.notificationentry', 'common.notificationmessage', - 'user_sessions.session', 'importer.dataimportsession', 'importer.dataimportcolumnmap', 'importer.dataimportrow', @@ -1226,7 +1225,7 @@ def schema( info(f"Exporting schema file to '{filename}'") - cmd = f'spectacular --file {filename} --validate --color' + cmd = f'schema --file {filename} --validate --color' if not ignore_warnings: cmd += ' --fail-on-warn'