2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-10 01:08:49 +00:00

Remove django-allauth-2fa, dj-rest-auth and django-user-sessions (#6293)

* Remove django-allauth-2fa
Fixes #6281

* fix req

* fix file again

* remove allauth_2fa flows

* reintroduce otp

* fix rq

* remove old ref

* remove otp things from settings

* reintroduce otp codes

* remove totp section

* bump version

* fix reqs

* add missing model

* ignore TOTP migration if the model is not laoded

* add model deps

* add extra migrations step for easier testing

* add migration testing

* remove old catch

* cover static devies too

* remove more old stuff

* fix import

* mrege migrations

* bump API version

* switch to allauth.usersessions

* add headless

* re-add saml/openid

* user sessions cleanup

* turn off normal allauth urls if CUI is not active

* disable tests that rely on old endpoints - to be replaced

* always track session changes

* remove old allauth templates

* remove old ref

* add missing model

* fix session lookup

* always logout when pwd is changed

* reimplement session ending

* fix merge

* upgrade reqs

* lower cryptography version

* clean allauth_2fa reference

* disable test temporarly

* fix migration check

* disable tests temporarly

* Re-implement auth flow using new APIs; adds MFA to PUI

* re-implement logoff

* stop failure message from appearing when in MFA flow

* remove jwt mention

* fix: email endpoints (to be cleaned TODO@matmair)

* remove unused endpoints

* ignore the now often-used 410 error

* fix auth for email actions in MFA scenarios

* add mfa listing use build-in forms

* add dummy entry for missing frontend urls; see TODO@matmair

* remove unneeded change of confirm url

* add mfa reg endpoint (not fully implemented)

* implement more provider stuff

* simplify calls

* make calls more robust

* switch to browser based sessions

* add todo's

* update api version

* remove x-session, not needed anymore

* remove old urls

* remove ui preference - there is no decision anymore

* fix login redirect logic

* change name to ensure 1p can detect field

* add mfa table

* fix remove sso provider account action; provider (user) admin stuff is done

* reduce templates to the raw basics

* fix tests

* more exclusions

* rewrite url structure

* move buildin token test

* re-enable registration tests

* re-implement registrations

* enable registration for now

* re-implement password change

* adjust tests

* fix asserts

* align names with allauth

* simplify

* refactor and rephrasing

* fix nesting issue

* clean up urls even more

* add mfa add and remove screens

* add type

* revert dep change

* fix api version

* re-add settings

* simplify urls

* Add timeout to login wait for

* fix url assertation

* remove unneded mfa_enabled

* add setting for configuring types

* bump api version

* fix password reset flow

* change settings order

* save auth context

* rename var to remove confusion

* make login/register seperate paths

* make info text better

* adjust urls

* add error message

* disable buttons if no email is set

* add custom adapters for MFA and headless authentication to use upstreamed features

* move auth settings to status

* respect more settings

* update settings

* bump api version

* remove depreceated docs part

* remove dj_rest_auth stuff

* fix api_version bump

* remove temp fix

* fix provider login

* remove unsupported option

* remove hash requirement for now

* simplify customisation

* implement email-verification

* remove auth from api docs

* fix override of get_frontend_url
details in https://codeberg.org/allauth/django-allauth/pulls/4248

* bump api again

* fix req

* Revert "remove hash requirement for now"

This reverts commit 00bb6c5274ee673948280ec084831edfa40ec3de.

* remove usage of git repo

* fix doc string

* extend schema generation to just patch in allauth

* patch allauth OAI ref names

* reduce types

* refactor code structure

* fix ref patching a bit more

* add param cleanup

* ensure strings, number, bools are handled correctly in cleanup

* move fnc

* shorten names

* bump allauth

* re-add auth doc section

* fix doc structure

* revert playwrigth change

* ckean up browser only path

* clean up parameters that we do not use

* re-add 2fa required middleware

* fix mail sending hook

* fix password set texts

* Add forced mfa setup

* remove type

* adjust api_version

* Remove debug prints

* Add error message for TOTP creation

* Handle failed TOTP login

* fix reqs

* Add error on 409 during login

* fix tested url

* fix api_version

* fix allauth version

* minimize req diff

* further minimize diff

---------

Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair 2025-02-22 01:11:04 +01:00 committed by GitHub
parent 1f84f24514
commit 03278c56c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 1814 additions and 2356 deletions

View File

@ -380,6 +380,7 @@ InvenTree provides allowance for additional sign-in options. The following optio
| Environment Variable | Configuration File | Description | Default | | 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_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 ### Single Sign On

View File

@ -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

View File

@ -20,8 +20,11 @@ from rest_framework.views import APIView
import InvenTree.version import InvenTree.version
import users.models import users.models
from common.settings import get_global_setting
from InvenTree import helpers from InvenTree import helpers
from InvenTree.auth_overrides import registration_enabled
from InvenTree.mixins import ListCreateAPI from InvenTree.mixins import ListCreateAPI
from InvenTree.sso import sso_registration_enabled
from part.models import Part from part.models import Part
from plugin.serializers import MetadataSerializer from plugin.serializers import MetadataSerializer
from users.models import ApiToken from users.models import ApiToken
@ -197,6 +200,13 @@ class VersionTextView(ListAPI):
class InfoApiSerializer(serializers.Serializer): class InfoApiSerializer(serializers.Serializer):
"""InvenTree server information - some information might be blanked if called without elevated credentials.""" """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): class CustomizeSerializer(serializers.Serializer):
"""Serializer for customize field.""" """Serializer for customize field."""
@ -229,6 +239,7 @@ class InfoApiSerializer(serializers.Serializer):
installer = serializers.CharField(read_only=True) installer = serializers.CharField(read_only=True)
target = serializers.CharField(read_only=True) target = serializers.CharField(read_only=True)
django_admin = serializers.CharField(read_only=True) django_admin = serializers.CharField(read_only=True)
settings = SettingsSerializer(read_only=True, many=False)
class InfoView(APIView): class InfoView(APIView):
@ -288,6 +299,13 @@ class InfoView(APIView):
'django_admin': settings.INVENTREE_ADMIN_URL 'django_admin': settings.INVENTREE_ADMIN_URL
if (is_staff and settings.INVENTREE_ADMIN_ENABLED) if (is_staff and settings.INVENTREE_ADMIN_ENABLED)
else None, 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) return JsonResponse(data)

View File

@ -1,13 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v313 - 2025-02-17 : https://github.com/inventree/InvenTree/pull/9087
- Adds instance id optionally to the info view endpoint - Adds instance id optionally to the info view endpoint

View File

@ -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

View File

@ -1,10 +1,9 @@
"""Overrides for allauth and adjacent packages to enforce InvenTree specific auth settings and restirctions.""" """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 import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -12,20 +11,15 @@ from django.utils.translation import gettext_lazy as _
import structlog import structlog
from allauth.account.adapter import DefaultAccountAdapter from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order 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.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.helpers_model
import InvenTree.sso import InvenTree.sso
from common.settings import get_global_setting from common.settings import get_global_setting
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from users.models import ApiToken
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -92,16 +86,6 @@ class CustomSignupForm(SignupForm):
return cleaned_data 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(): def registration_enabled():
"""Determine whether user registration is enabled.""" """Determine whether user registration is enabled."""
if ( if (
@ -177,19 +161,7 @@ class RegistrationMixin:
return user return user
class CustomUrlMixin: class CustomAccountAdapter(RegistrationMixin, DefaultAccountAdapter):
"""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
):
"""Override of adapter to use dynamic settings.""" """Override of adapter to use dynamic settings."""
def send_mail(self, template_prefix, email, context): def send_mail(self, template_prefix, email, context):
@ -207,16 +179,14 @@ class CustomAccountAdapter(
return False return False
def get_email_confirmation_url(self, request, emailconfirmation): def send_password_reset_mail(self, user, email, context):
"""Construct the email confirmation url.""" """Send the password reset mail."""
url = super().get_email_confirmation_url(request, emailconfirmation) if not get_global_setting('LOGIN_ENABLE_PWD_FORGOT'):
url = InvenTree.helpers_model.construct_absolute_url(url) raise PermissionDenied('Password reset is disabled')
return url return super().send_password_reset_mail(user, email, context)
class CustomSocialAccountAdapter( class CustomSocialAccountAdapter(RegistrationMixin, DefaultSocialAccountAdapter):
CustomUrlMixin, RegistrationMixin, DefaultSocialAccountAdapter
):
"""Override of adapter to use dynamic settings.""" """Override of adapter to use dynamic settings."""
def is_auto_signup_allowed(self, request, sociallogin): def is_auto_signup_allowed(self, request, sociallogin):
@ -225,29 +195,6 @@ class CustomSocialAccountAdapter(
return super().is_auto_signup_allowed(request, sociallogin) return super().is_auto_signup_allowed(request, sociallogin)
return False 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( def authentication_error(
self, request, provider_id, error=None, exception=None, extra_context=None 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) log_error(path, error_name=error, error_data=exception)
logger.error("SSO error for provider '%s' - check admin error log", provider_id) 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): def get_frontend_url(self, urlname, **kwargs):
"""Override to check if registration is open.""" """Get the frontend URL for the given URL name respecting the request."""
if registration_enabled(): HEADLESS_FRONTEND_URLS = {
return super().save(request) 'account_confirm_email': 'verify-email/{key}',
raise forms.ValidationError(_('Registration is disabled.')) '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

View File

@ -8,12 +8,15 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import sesame.utils import sesame.utils
import structlog
from rest_framework import serializers from rest_framework import serializers
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
import InvenTree.version import InvenTree.version
logger = structlog.get_logger('inventree')
def send_simple_login_email(user, link): def send_simple_login_email(user, link):
"""Send an email with the login link to this user.""" """Send an email with the login link to this user."""
@ -55,7 +58,7 @@ class GetSimpleLoginView(GenericAPIView):
"""Notify user about link.""" """Notify user about link."""
user = self.get_user(email) user = self.get_user(email)
if user is None: if user is None:
print('user not found:', email) logger.warning('User email not found: %s', email)
return return
link = self.create_link(user) link = self.create_link(user)
send_simple_login_email(user, link) send_simple_login_email(user, link)

View File

@ -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)

View File

@ -6,16 +6,15 @@ from django.conf import settings
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect 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 from django.utils.deprecation import MiddlewareMixin
import structlog import structlog
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
from error_report.middleware import ExceptionProcessor from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting from common.settings import get_global_setting
from InvenTree.AllUserRequire2FAMiddleware import AllUserRequire2FAMiddleware
from InvenTree.cache import create_session_cache, delete_session_cache from InvenTree.cache import create_session_cache, delete_session_cache
from InvenTree.urls import frontendpatterns
from users.models import ApiToken from users.models import ApiToken
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
@ -137,32 +136,12 @@ class AuthRequiredMiddleware:
return response return response
url_matcher = path('', include(frontendpatterns)) class Check2FAMiddleware(AllUserRequire2FAMiddleware):
"""Ensure that mfa is enforced if set so."""
def enforce_2fa(self, request):
class Check2FAMiddleware(BaseRequire2FAMiddleware): """Use setting to check if MFA should be enforced."""
"""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') 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
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware): class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):

View File

@ -18,7 +18,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import django.conf.locale import django.conf.locale
import django.core.exceptions import django.core.exceptions
from django.core.validators import URLValidator from django.core.validators import URLValidator
from django.http import Http404 from django.http import Http404, HttpResponseGone
import structlog import structlog
from dotenv import load_dotenv from dotenv import load_dotenv
@ -63,7 +63,6 @@ CONFIG = config.load_config_data(set_cache=True)
# Load VERSION data if it exists # Load VERSION data if it exists
version_file = BASE_DIR.parent.parent.parent.joinpath('VERSION') version_file = BASE_DIR.parent.parent.parent.joinpath('VERSION')
if version_file.exists(): if version_file.exists():
print('load version from file')
load_dotenv(version_file) load_dotenv(version_file)
# Default action is to run the system in Debug mode # Default action is to run the system in Debug mode
@ -266,7 +265,8 @@ INSTALLED_APPS = [
# Core django modules # Core django modules
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'user_sessions', # db user sessions 'django.contrib.sessions',
'django.contrib.humanize',
'whitenoise.runserver_nostatic', 'whitenoise.runserver_nostatic',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
@ -290,13 +290,13 @@ INSTALLED_APPS = [
'django_structlog', # Structured logging 'django_structlog', # Structured logging
'allauth', # Base app for SSO 'allauth', # Base app for SSO
'allauth.account', # Extend user with accounts 'allauth.account', # Extend user with accounts
'allauth.headless', # APIs for auth
'allauth.socialaccount', # Use 'social' providers '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', # OTP is needed for MFA - base package
'django_otp.plugins.otp_totp', # Time based OTP 'django_otp.plugins.otp_totp', # Time based OTP
'django_otp.plugins.otp_static', # Backup codes '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 'drf_spectacular', # API documentation
'django_ical', # For exporting calendars 'django_ical', # For exporting calendars
] ]
@ -306,7 +306,8 @@ MIDDLEWARE = CONFIG.get(
[ [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'x_forwarded_for.middleware.XForwardedForMiddleware', '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.locale.LocaleMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'corsheaders.middleware.CorsMiddleware', 'corsheaders.middleware.CorsMiddleware',
@ -314,8 +315,6 @@ MIDDLEWARE = CONFIG.get(
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth 'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth
'django_otp.middleware.OTPMiddleware', # MFA support
'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth
'allauth.account.middleware.AccountMiddleware', 'allauth.account.middleware.AccountMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
@ -548,31 +547,13 @@ if DEBUG:
'rest_framework.renderers.BrowsableAPIRenderer' '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 # JWT settings - rest_framework_simplejwt
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
if USE_JWT: if USE_JWT:
JWT_AUTH_COOKIE = 'inventree-auth' JWT_AUTH_COOKIE = 'inventree-auth'
JWT_AUTH_REFRESH_COOKIE = 'inventree-token' JWT_AUTH_REFRESH_COOKIE = 'inventree-token'
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'].append(
'dj_rest_auth.jwt_auth.JWTCookieAuthentication'
)
INSTALLED_APPS.append('rest_framework_simplejwt') INSTALLED_APPS.append('rest_framework_simplejwt')
# WSGI default setting # WSGI default setting
WSGI_APPLICATION = 'InvenTree.wsgi.application' WSGI_APPLICATION = 'InvenTree.wsgi.application'
@ -911,13 +892,8 @@ if GLOBAL_CACHE_ENABLED: # pragma: no cover
# as well # as well
Q_CLUSTER['django_redis'] = 'worker' 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 # Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators # 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 # Refer to the django-cors-headers documentation for more information
# Ref: https://github.com/adamchainz/django-cors-headers # Ref: https://github.com/adamchainz/django-cors-headers
# Extract CORS options from configuration file # Extract CORS options from configuration file
CORS_ALLOW_ALL_ORIGINS = get_boolean_setting( CORS_ALLOW_ALL_ORIGINS = get_boolean_setting(
'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=DEBUG 'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=DEBUG
@ -1238,6 +1215,11 @@ else:
if CORS_ALLOWED_ORIGIN_REGEXES: if CORS_ALLOWED_ORIGIN_REGEXES:
logger.info('CORS: Whitelisted origin regexes: %s', 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: for app in SOCIAL_BACKENDS:
# Ensure that the app starts with 'allauth.socialaccount.providers' # Ensure that the app starts with 'allauth.socialaccount.providers'
social_prefix = 'allauth.socialaccount.providers.' social_prefix = 'allauth.socialaccount.providers.'
@ -1262,6 +1244,9 @@ SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX = ''
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting( ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting(
'INVENTREE_LOGIN_CONFIRM_DAYS', 'login_confirm_days', 3, typecast=int '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 # 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" # 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', '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' SOCIALACCOUNT_ADAPTER = 'InvenTree.auth_overrides.CustomSocialAccountAdapter'
ACCOUNT_ADAPTER = 'InvenTree.auth_overrides.CustomAccountAdapter' 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 # Markdownify configuration
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html # Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
@ -1354,7 +1354,7 @@ MARKDOWNIFY = {
} }
# Ignore these error types for in-database error logging # 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
MAINTENANCE_MODE_RETRY_AFTER = 10 MAINTENANCE_MODE_RETRY_AFTER = 10
@ -1378,10 +1378,6 @@ CUSTOMIZE = get_setting(
'INVENTREE_CUSTOMIZE', 'customize', default_value=None, typecast=dict '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: if DEBUG:
logger.info('InvenTree running with DEBUG enabled') logger.info('InvenTree running with DEBUG enabled')

View File

@ -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()

File diff suppressed because it is too large Load Diff

View File

@ -141,13 +141,34 @@ class EmailSettingsContext:
class TestAuth(InvenTreeAPITestCase): class TestAuth(InvenTreeAPITestCase):
"""Test authentication functionality.""" """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): def email_args(self, user=None, email=None):
"""Generate registration arguments.""" """Generate registration arguments."""
return { return {
'username': user or 'user1', 'username': user or 'user2',
'email': email or 'test@example.com', 'email': email or self.test_email,
'password1': '#asdf1234', 'password': '#asdf1234',
'password2': '#asdf1234',
} }
def test_registration(self): def test_registration(self):
@ -156,26 +177,17 @@ class TestAuth(InvenTreeAPITestCase):
# Duplicate username # Duplicate username
resp = self.post( resp = self.post(
'/api/auth/registration/', self.reg_url, self.email_args(user='testuser'), expected_code=400
self.email_args(user='testuser'),
expected_code=400,
)
self.assertIn(
'A user with that username already exists.', resp.data['username']
) )
self.assertIn('A user with that username already exists.', str(resp.json()))
# Registration is disabled # Registration is disabled
resp = self.post( self.post(self.reg_url, self.email_args(), expected_code=403)
'/api/auth/registration/', self.email_args(), expected_code=400
)
self.assertIn('Registration is disabled.', resp.data['non_field_errors'])
# Enable registration - now it should work # Enable registration - now it should work
with EmailSettingsContext(): with EmailSettingsContext():
resp = self.post( resp = self.post(self.reg_url, self.email_args(), expected_code=200)
'/api/auth/registration/', self.email_args(), expected_code=201 self.assertEqual(resp.json()['data']['user']['email'], self.test_email)
)
self.assertIn('key', resp.data)
def test_registration_email(self): def test_registration_email(self):
"""Test that LOGIN_SIGNUP_MAIL_RESTRICTION works.""" """Test that LOGIN_SIGNUP_MAIL_RESTRICTION works."""
@ -197,15 +209,13 @@ class TestAuth(InvenTreeAPITestCase):
# Wrong email format # Wrong email format
resp = self.post( resp = self.post(
'/api/auth/registration/', self.reg_url,
self.email_args(email='admin@invenhost.com'), self.email_args(email='admin@invenhost.com'),
expected_code=400, 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 # Right format should work
with EmailSettingsContext(): with EmailSettingsContext():
resp = self.post( resp = self.post(self.reg_url, self.email_args(), expected_code=200)
'/api/auth/registration/', self.email_args(), expected_code=201 self.assertEqual(resp.json()['data']['user']['email'], self.test_email)
)
self.assertIn('key', resp.data)

View File

@ -10,11 +10,7 @@ from django.urls import include, path, re_path
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from dj_rest_auth.registration.views import ( from allauth.headless.urls import Client, build_urlpatterns
ConfirmEmailView,
SocialAccountDisconnectView,
SocialAccountListView,
)
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from sesame.views import LoginView from sesame.views import LoginView
@ -29,7 +25,6 @@ import plugin.api
import report.api import report.api
import stock.api import stock.api
import users.api import users.api
from InvenTree.auth_override_views import CustomRegisterView
from plugin.urls import get_plugin_urls from plugin.urls import get_plugin_urls
from web.urls import urlpatterns as platform_urls from web.urls import urlpatterns as platform_urls
@ -42,14 +37,6 @@ from .api import (
VersionView, VersionView,
) )
from .magic_login import GetSimpleLoginView from .magic_login import GetSimpleLoginView
from .social_auth_urls import (
EmailListView,
EmailPrimaryView,
EmailRemoveView,
EmailVerifyView,
SocialProviderListView,
get_provider_urls,
)
from .views import auth_request from .views import auth_request
admin.site.site_header = 'InvenTree Admin' admin.site.site_header = 'InvenTree Admin'
@ -107,59 +94,17 @@ apipatterns = [
path( path(
'auth/', 'auth/',
include([ include([
re_path(
r'^registration/account-confirm-email/(?P<key>[-:\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(
'<int:pk>/',
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/<int:pk>/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( path(
'login-redirect/', 'login-redirect/',
users.api.LoginRedirect.as_view(), users.api.LoginRedirect.as_view(),
name='api-login-redirect', 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 # Magic login URLs
@ -175,8 +120,16 @@ apipatterns = [
backendpatterns = [ backendpatterns = [
path('auth/', include('rest_framework.urls', namespace='rest_framework')), path(
path('auth/', auth_request), '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/', include(apipatterns)),
path('api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'), path('api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
] ]
@ -193,19 +146,7 @@ if settings.INVENTREE_ADMIN_ENABLED:
] ]
urlpatterns += backendpatterns urlpatterns += backendpatterns
urlpatterns += platform_urls
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
# Append custom plugin URLs (if custom plugin support is enabled) # Append custom plugin URLs (if custom plugin support is enabled)
if settings.PLUGINS_ENABLED: if settings.PLUGINS_ENABLED:

View File

@ -176,11 +176,6 @@ login_default_protocol: http
remote_login_enabled: False remote_login_enabled: False
remote_login_header: HTTP_REMOTE_USER remote_login_header: HTTP_REMOTE_USER
# JWT tokens
# JWT can be used optionally to authenticate users. Turned off by default.
# Alternatively, use the environment variable INVENTREE_USE_JWT
# use_jwt: True
# Logout redirect configuration # Logout redirect configuration
# This setting may be required if using remote / proxy login to redirect requests # This setting may be required if using remote / proxy login to redirect requests
# during the logout process (default is 'index'). Please read the docs for more details # during the logout process (default is 'index'). Please read the docs for more details

View File

@ -115,7 +115,6 @@ class AllStatusViews(StatusView):
for item in cls.custom_values(): for item in cls.custom_values():
label = str(item.name) label = str(item.name)
if label not in cls_data['values']: if label not in cls_data['values']:
print('custom value:', item)
cls_data['values'][label] = { cls_data['values'][label] = {
'color': item.color, 'color': item.color,
'logical_key': item.logical_key, 'logical_key': item.logical_key,

View File

@ -7,13 +7,6 @@
{% endblock page_title %} {% endblock page_title %}
{% block content %} {% block content %}
<h3>{% trans "Permission Denied" %}</h3>
<div class='container-fluid'> {% trans "You do not have permission to view this page." %}
<h3>{% trans "Permission Denied" %}</h3>
<div class='alert alert-danger alert-block'>
{% trans "You do not have permission to view this page." %}
</div>
</div>
{% endblock content %} {% endblock content %}

View File

@ -9,15 +9,5 @@
{% block content %} {% block content %}
<h3>{% trans "Authentication Failure" %}</h3> <h3>{% trans "Authentication Failure" %}</h3>
{% trans "You have been logged out from InvenTree." %}
<div class='alert alert-danger alert-block'>
{% trans "You have been logged out from InvenTree." %}
</div>
<hr>
<div class='btn-group float-right' role='group'>
<a type='button' class='btn btn-primary' href='{% url "account_login" %}'>
<span class='fas fa-sign-in-alt'></span> {% trans "Login" %}
</a>
</div>
{% endblock content %} {% endblock content %}

View File

@ -7,13 +7,6 @@
{% endblock page_title %} {% endblock page_title %}
{% block content %} {% block content %}
<h3>{% trans "Page Not Found" %}</h3>
<div class='container-fluid'> {% trans "The requested page does not exist" %}
<h3>{% trans "Page Not Found" %}</h3>
<div class='alert alert-danger alert-block'>
{% trans "The requested page does not exist" %}
</div>
</div>
{% endblock content %} {% endblock content %}

View File

@ -7,14 +7,7 @@
{% endblock page_title %} {% endblock page_title %}
{% block content %} {% block content %}
<h3>{% trans "Internal Server Error" %}</h3>
<div class='container-fluid'> {% blocktrans %}The {{ inventree_title }} server raised an internal error{% endblocktrans %}<br>
<h3>{% trans "Internal Server Error" %}</h3> {% trans "Refer to the error log in the admin interface for further details" %}
<div class='alert alert-danger alert-block'>
{% blocktrans %}The {{ inventree_title }} server raised an internal error{% endblocktrans %}<br>
{% trans "Refer to the error log in the admin interface for further details" %}
</div>
</div>
{% endblock content %} {% endblock content %}

View File

@ -11,61 +11,9 @@
{% trans 'Site is in Maintenance' %} {% trans 'Site is in Maintenance' %}
{% endblock page_title %} {% endblock page_title %}
{% block body_class %}login-screen{% endblock body_class %}
{% block body %} {% block body %}
<h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock body_title %}</h3>
<div class='container-fluid'> {% block content %}
<div class='notification-area' id='alerts'> {% trans 'The site is currently in maintenance and should be up again soon!' %}
<!-- Div for displayed alerts --> {% endblock content %}
</div>
</div>
<div class='main body-wrapper login-screen d-flex'>
<div class='login-container'>
<div class="row">
<div class='container-fluid'>
<div class='clearfix content-heading login-header d-flex flex-wrap'>
<img class="pull-left" alt='{% trans "Image" %}' src="{% inventree_logo %}" width="60" height="60"/>
{% include "spacer.html" %}
<span class='float-right'><h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock body_title %}</h3></span>
</div>
</div>
<div class='container-fluid'>
<hr>
{% block content %}
{% trans 'The site is currently in maintenance and should be up again soon!' %}
{% endblock content %}
</div>
</div>
</div>
{% block extra_body %}
{% endblock extra_body %}
</div>
{% endblock body %} {% endblock body %}
{% block js_base %}
<script type='text/javascript'>
$(document).ready(function () {
// notifications
{% if messages %}
{% for message in messages %}
showAlertOrCache(
'{{ message }}',
true,
{
style: 'info',
}
);
{% endfor %}
{% endif %}
inventreeDocReady();
});
</script>
{% endblock js_base %}
</body>
</html>

View File

@ -1,37 +0,0 @@
{% extends "skeleton.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block body %}
<body class='login-screen'>
<div class='container-fluid'>
<div class='notification-area' id='alerts'>
<!-- Div for displayed alerts -->
</div>
</div>
<div class='main body-wrapper login-screen d-flex'>
<div class='login-container'>
<div class="row">
<div class='container-fluid'>
<div class='clearfix content-heading login-header d-flex flex-wrap'>
<img class="pull-left" src="{% inventree_logo %}" alt='{% trans "InvenTree logo" %}' width="60" height="60"/>
{% include "spacer.html" %}
<span class='float-right'><h3>{% inventree_title %}</h3></span>
</div>
</div>
<div class='container-fluid'>
<hr>
{% block content %}
{% endblock content %}
</div>
</div>
</div>
{% block extra_body %}
{% endblock extra_body %}
</div>
{% endblock body %}

View File

@ -12,18 +12,14 @@
{% include "favicon.html" %} {% include "favicon.html" %}
<!-- CSS -->
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
<title> <title>
{% block page_title %} {% block page_title %}
{% endblock page_title %} {% endblock page_title %}
</title> </title>
</head> </head>
<body class='{% block body_class %}{% endblock body_class %}'> <body>
{% block body %} {% block body %}
{% endblock body %} {% endblock body %}
</body> </body>
</html> </html>

View File

@ -2,22 +2,14 @@
import datetime 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.contrib.auth.models import Group, User
from django.http.response import HttpResponse from django.urls import include, path, re_path
from django.shortcuts import redirect from django.views.decorators.csrf import ensure_csrf_cookie
from django.urls import include, path, re_path, reverse
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
import structlog 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 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.generics import DestroyAPIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
@ -25,7 +17,6 @@ from rest_framework.views import APIView
import InvenTree.helpers import InvenTree.helpers
import InvenTree.permissions import InvenTree.permissions
from common.models import InvenTreeSetting
from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ( from InvenTree.mixins import (
ListAPI, ListAPI,
@ -224,96 +215,6 @@ class GroupList(GroupMixin, ListCreateAPI):
ordering_fields = ['name'] 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): class GetAuthToken(APIView):
"""Return authentication token for an authenticated user.""" """Return authentication token for an authenticated user."""
@ -396,7 +297,7 @@ class LoginRedirect(RedirectView):
user_urls = [ user_urls = [
path('roles/', RoleDetails.as_view(), name='api-user-roles'), 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( path(
'tokens/', 'tokens/',
include([ include([

View File

@ -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)
]

View File

@ -238,6 +238,7 @@ class RuleSet(models.Model):
'otp_totp_totpdevice', 'otp_totp_totpdevice',
'otp_static_statictoken', 'otp_static_statictoken',
'otp_static_staticdevice', 'otp_static_staticdevice',
'mfa_authenticator',
'plugin_pluginconfig', 'plugin_pluginconfig',
'plugin_pluginsetting', 'plugin_pluginsetting',
'plugin_notificationusersetting', 'plugin_notificationusersetting',
@ -353,7 +354,8 @@ class RuleSet(models.Model):
'error_report_error', 'error_report_error',
'exchange_rate', 'exchange_rate',
'exchange_exchangebackend', 'exchange_exchangebackend',
'user_sessions_session', 'usersessions_usersession',
'sessions_session',
# Django-q # Django-q
'django_q_ormq', 'django_q_ormq',
'django_q_failure', 'django_q_failure',

View File

@ -110,15 +110,6 @@ class UserAPITests(InvenTreeAPITestCase):
self.assertIn('name', response.data) self.assertIn('name', response.data)
self.assertIn('permissions', 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): def test_login_redirect(self):
"""Test login redirect endpoint.""" """Test login redirect endpoint."""
response = self.get(reverse('api-login-redirect'), expected_code=302) response = self.get(reverse('api-login-redirect'), expected_code=302)
@ -233,16 +224,6 @@ class UserTokenTests(InvenTreeAPITestCase):
self.client.get(me, expected_code=200) 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): def test_token_api(self):
"""Test the token API.""" """Test the token API."""
url = reverse('api-token-list') url = reverse('api-token-list')

View File

@ -24,3 +24,36 @@ class TestForwardMigrations(MigratorTestCase):
User = self.new_state.apps.get_model('auth', 'user') User = self.new_state.apps.get_model('auth', 'user')
self.assertEqual(User.objects.count(), 2) 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()])

View File

@ -296,8 +296,9 @@ class OwnerModelTest(InvenTreeTestCase):
class MFALoginTest(InvenTreeAPITestCase): class MFALoginTest(InvenTreeAPITestCase):
"""Some simplistic tests to ensure that MFA is working.""" """Some simplistic tests to ensure that MFA is working."""
"""
def test_api(self): def test_api(self):
"""Test that the API is working.""" ""Test that the API is working.""
auth_data = {'username': self.username, 'password': self.password} auth_data = {'username': self.username, 'password': self.password}
login_url = reverse('api-login') login_url = reverse('api-login')
@ -335,6 +336,7 @@ class MFALoginTest(InvenTreeAPITestCase):
# Wrong login should not work # Wrong login should not work
auth_data['password'] = 'wrong' auth_data['password'] = 'wrong'
self.post(login_url, auth_data, expected_code=401) self.post(login_url, auth_data, expected_code=401)
"""
class AdminTest(AdminTestCase): class AdminTest(AdminTestCase):

View File

@ -18,7 +18,7 @@ class RedirectAssetView(TemplateView):
spa_view = ensure_csrf_cookie(TemplateView.as_view(template_name='web/index.html')) spa_view = ensure_csrf_cookie(TemplateView.as_view(template_name='web/index.html'))
assets_path = path('assets/<path:path>', RedirectAssetView.as_view()) assets_path = path('assets/<path:path>', RedirectAssetView.as_view(), name='web-assets')
urlpatterns = [ urlpatterns = [
@ -31,7 +31,7 @@ urlpatterns = [
spa_view, spa_view,
name='password_reset_confirm', name='password_reset_confirm',
), ),
re_path('.*', spa_view), re_path('.*', spa_view, name='platform-wildcard'),
]), ]),
), ),
assets_path, assets_path,

View File

@ -2,8 +2,7 @@
Django<5.0 # Django package Django<5.0 # Django package
coreapi # API documentation for djangorestframework coreapi # API documentation for djangorestframework
cryptography>=44.0.0 # Core cryptographic functionality cryptography>=44.0.0 # Core cryptographic functionality
django-allauth[openid,saml] # SSO for external providers via OpenID django-allauth[mfa,socialaccount,saml,openid] # SSO for external providers via OpenID
django-allauth-2fa # MFA / 2FA
django-cleanup # Automated deletion of old / unused uploaded files django-cleanup # Automated deletion of old / unused uploaded files
django-cors-headers # CORS headers extension for DRF django-cors-headers # CORS headers extension for DRF
django-dbbackup # Backup / restore of database and media files django-dbbackup # Backup / restore of database and media files
@ -26,12 +25,11 @@ django-sslserver # Secure HTTP development server
django-structlog # Structured logging django-structlog # Structured logging
django-stdimage # Advanced ImageField management django-stdimage # Advanced ImageField management
django-taggit # Tagging support 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 django-weasyprint # django weasyprint integration
djangorestframework<3.15 # DRF framework # FIXED 2024-06-26 see https://github.com/inventree/InvenTree/pull/7521 djangorestframework<3.15 # DRF framework # FIXED 2024-06-26 see https://github.com/inventree/InvenTree/pull/7521
djangorestframework-simplejwt[crypto] # JWT authentication djangorestframework-simplejwt[crypto] # JWT authentication
django-xforwardedfor-middleware # IP forwarding metadata 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 dulwich # pure Python git integration
docutils # Documentation utilities for auto admin docs docutils # Documentation utilities for auto admin docs
drf-spectacular # DRF API documentation drf-spectacular # DRF API documentation

View File

@ -5,6 +5,7 @@ asgiref==3.8.1 \
--hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590 --hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590
# via # via
# django # django
# django-allauth
# django-cors-headers # django-cors-headers
# django-structlog # django-structlog
async-timeout==5.0.1 \ async-timeout==5.0.1 \
@ -366,6 +367,7 @@ cryptography==44.0.1 \
# via # via
# -r src/backend/requirements.in # -r src/backend/requirements.in
# djangorestframework-simplejwt # djangorestframework-simplejwt
# fido2
# pyjwt # pyjwt
cssselect2==0.7.0 \ cssselect2==0.7.0 \
--hash=sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a \ --hash=sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a \
@ -383,17 +385,12 @@ deprecated==1.2.18 \
# opentelemetry-exporter-otlp-proto-grpc # opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http # opentelemetry-exporter-otlp-proto-http
# opentelemetry-semantic-conventions # opentelemetry-semantic-conventions
dj-rest-auth==7.0.1 \
--hash=sha256:3f8c744cbcf05355ff4bcbef0c8a63645da38e29a0fdef3c3332d4aced52fb90
# via -r src/backend/requirements.in
django==4.2.19 \ django==4.2.19 \
--hash=sha256:6c833be4b0ca614f0a919472a1028a3bbdeb6f056fa04023aeb923346ba2c306 \ --hash=sha256:6c833be4b0ca614f0a919472a1028a3bbdeb6f056fa04023aeb923346ba2c306 \
--hash=sha256:a104e13f219fc55996a4e416ef7d18ab4eeb44e0aa95174c192f16cda9f94e75 --hash=sha256:a104e13f219fc55996a4e416ef7d18ab4eeb44e0aa95174c192f16cda9f94e75
# via # via
# -r src/backend/requirements.in # -r src/backend/requirements.in
# dj-rest-auth
# django-allauth # django-allauth
# django-allauth-2fa
# django-cors-headers # django-cors-headers
# django-dbbackup # django-dbbackup
# django-error-report-2 # django-error-report-2
@ -414,20 +411,13 @@ django==4.2.19 \
# django-stdimage # django-stdimage
# django-structlog # django-structlog
# django-taggit # django-taggit
# django-user-sessions
# django-weasyprint # django-weasyprint
# django-xforwardedfor-middleware # django-xforwardedfor-middleware
# djangorestframework # djangorestframework
# djangorestframework-simplejwt # djangorestframework-simplejwt
# drf-spectacular # drf-spectacular
django-allauth[openid, saml]==0.57.2 \ django-allauth[mfa, openid, saml, socialaccount]==65.4.1 \
--hash=sha256:51c400f61bfb15bd08e22543a65d551c8f563254064620c37c49766b1ba7e1ae --hash=sha256:60b32aef7dbbcc213319aa4fd8f570e985266ea1162ae6ef7a26a24efca85c8c
# via
# -r src/backend/requirements.in
# django-allauth-2fa
django-allauth-2fa==0.12.0 \
--hash=sha256:4b0c4cfea9c30be7c0971421b9b1f4961a19a69af72b7b5c0c7a3dbbc2537253 \
--hash=sha256:ba53f47fd777d13c48842a7b779ab5ad24ad8c50f05aba98c64810263a50382d
# via -r src/backend/requirements.in # via -r src/backend/requirements.in
django-cleanup==9.0.0 \ django-cleanup==9.0.0 \
--hash=sha256:19f8b0e830233f9f0f683b17181f414672a0f48afe3ea3cc80ba47ae40ad880c \ --hash=sha256:19f8b0e830233f9f0f683b17181f414672a0f48afe3ea3cc80ba47ae40ad880c \
@ -481,10 +471,10 @@ django-mptt==0.16.0 \
--hash=sha256:56c9606bf0b329b5f5afd55dd8bfd073612ea1d5999b10903b09de62bee84c8e \ --hash=sha256:56c9606bf0b329b5f5afd55dd8bfd073612ea1d5999b10903b09de62bee84c8e \
--hash=sha256:8716849ba3318d94e2e100ed0923a05c1ffdf8195f8472b690dbaf737d2af3b5 --hash=sha256:8716849ba3318d94e2e100ed0923a05c1ffdf8195f8472b690dbaf737d2af3b5
# via -r src/backend/requirements.in # via -r src/backend/requirements.in
django-otp==1.5.4 \ django-otp==1.3.0 \
--hash=sha256:0d9497ea4fb13fc04d50b49aa53dd1c740fe4bc5dde0ca27fb394f84e5da7bac \ --hash=sha256:5277731bc05b6cdbf96aa84ac46018e30ed5fb248086053b0146f925de059060 \
--hash=sha256:783dea669ac0eaf5cd336f73839443584ee665af427a84175cca5a9d620366db --hash=sha256:8f4156a3c14ce2aaa31379385eadf388925cd50fc4b5d20a3b944f454c98ff7c
# via django-allauth-2fa # via -r src/backend/requirements.in
django-picklefield==3.2 \ django-picklefield==3.2 \
--hash=sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d \ --hash=sha256:aa463f5d79d497dbe789f14b45180f00a51d0d670067d0729f352a3941cdfa4d \
--hash=sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c --hash=sha256:e9a73539d110f69825d9320db18bcb82e5189ff48dbed41821c026a20497764c
@ -527,10 +517,6 @@ django-taggit==6.1.0 \
--hash=sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0 \ --hash=sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0 \
--hash=sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3 --hash=sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3
# via -r src/backend/requirements.in # 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 \ django-weasyprint==2.3.1 \
--hash=sha256:09cc1c40c92db34bed80154be7c959fea03d6001dc46fd599f3fd464d6a6dc72 \ --hash=sha256:09cc1c40c92db34bed80154be7c959fea03d6001dc46fd599f3fd464d6a6dc72 \
--hash=sha256:cd35b8bd24b28128a17a2416d0e6f3e64cb727f25c53467150b4be16ccd01c19 --hash=sha256:cd35b8bd24b28128a17a2416d0e6f3e64cb727f25c53467150b4be16ccd01c19
@ -543,7 +529,6 @@ djangorestframework==3.14.0 \
--hash=sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08 --hash=sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08
# via # via
# -r src/backend/requirements.in # -r src/backend/requirements.in
# dj-rest-auth
# djangorestframework-simplejwt # djangorestframework-simplejwt
# drf-spectacular # drf-spectacular
djangorestframework-simplejwt[crypto]==5.4.0 \ djangorestframework-simplejwt[crypto]==5.4.0 \
@ -610,6 +595,10 @@ feedparser==6.0.11 \
--hash=sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45 \ --hash=sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45 \
--hash=sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5 --hash=sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5
# via -r src/backend/requirements.in # via -r src/backend/requirements.in
fido2==1.2.0 \
--hash=sha256:e39f95920122d64283fda5e5581d95a206e704fa42846bfa4662f86aa0d3333b \
--hash=sha256:f7c8ee62e359aa980a45773f9493965bb29ede1b237a9218169dbfe60c80e130
# via django-allauth
flexcache==0.3 \ flexcache==0.3 \
--hash=sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656 \ --hash=sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656 \
--hash=sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32 --hash=sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32
@ -1328,7 +1317,7 @@ qrcode[pil]==8.0 \
--hash=sha256:9fc05f03305ad27a709eb742cf3097fa19e6f6f93bb9e2f039c0979190f6f1b1 --hash=sha256:9fc05f03305ad27a709eb742cf3097fa19e6f6f93bb9e2f039c0979190f6f1b1
# via # via
# -r src/backend/requirements.in # -r src/backend/requirements.in
# django-allauth-2fa # django-allauth
rapidfuzz==3.12.1 \ rapidfuzz==3.12.1 \
--hash=sha256:00ceb8ff3c44ab0d6014106c71709c85dee9feedd6890eff77c814aa3798952b \ --hash=sha256:00ceb8ff3c44ab0d6014106c71709c85dee9feedd6890eff77c814aa3798952b \
--hash=sha256:018506a53c3b20dcbda8c93d4484b9eb1764c93d5ea16be103cf6b0d8b11d860 \ --hash=sha256:018506a53c3b20dcbda8c93d4484b9eb1764c93d5ea16be103cf6b0d8b11d860 \

View File

@ -15,6 +15,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { ProviderLogin } from '../../functions/auth';
import type { Provider } from '../../states/states'; import type { Provider } from '../../states/states';
const brandIcons: { [key: string]: JSX.Element } = { const brandIcons: { [key: string]: JSX.Element } = {
@ -32,26 +33,17 @@ const brandIcons: { [key: string]: JSX.Element } = {
}; };
export function SsoButton({ provider }: Readonly<{ provider: Provider }>) { export function SsoButton({ provider }: Readonly<{ provider: Provider }>) {
function login() {
window.location.href = provider.login;
}
return ( return (
<Tooltip <Tooltip
label={ label={t`You will be redirected to the provider for further actions.`}
provider.login
? t`You will be redirected to the provider for further actions.`
: t`This provider is not full set up.`
}
> >
<Button <Button
leftSection={getBrandIcon(provider)} leftSection={getBrandIcon(provider)}
radius='xl' radius='xl'
component='a' component='a'
onClick={login} onClick={() => ProviderLogin(provider)}
disabled={!provider.login}
> >
{provider.display_name} {provider.name}
</Button> </Button>
</Tooltip> </Tooltip>
); );

View File

@ -21,6 +21,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { import {
doBasicLogin, doBasicLogin,
doSimpleLogin, doSimpleLogin,
ensureCsrf,
followRedirect followRedirect
} from '../../functions/auth'; } from '../../functions/auth';
import { showLoginNotification } from '../../functions/notifications'; import { showLoginNotification } from '../../functions/notifications';
@ -34,7 +35,12 @@ export function AuthenticationForm() {
}); });
const simpleForm = useForm({ initialValues: { email: '' } }); const simpleForm = useForm({ initialValues: { email: '' } });
const [classicLoginMode, setMode] = useDisclosure(true); 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 navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { isLoggedIn } = useUserState(); const { isLoggedIn } = useUserState();
@ -45,8 +51,12 @@ export function AuthenticationForm() {
setIsLoggingIn(true); setIsLoggingIn(true);
if (classicLoginMode === true) { if (classicLoginMode === true) {
doBasicLogin(classicForm.values.username, classicForm.values.password) doBasicLogin(
.then(() => { classicForm.values.username,
classicForm.values.password,
navigate
)
.then((success) => {
setIsLoggingIn(false); setIsLoggingIn(false);
if (isLoggedIn()) { if (isLoggedIn()) {
@ -55,6 +65,8 @@ export function AuthenticationForm() {
message: t`Logged in successfully` message: t`Logged in successfully`
}); });
followRedirect(navigate, location?.state); followRedirect(navigate, location?.state);
} else if (success) {
// MFA login
} else { } else {
showLoginNotification({ showLoginNotification({
title: t`Login failed`, title: t`Login failed`,
@ -92,10 +104,10 @@ export function AuthenticationForm() {
return ( return (
<> <>
{auth_settings?.sso_enabled === true ? ( {sso_enabled() ? (
<> <>
<Group grow mb='md' mt='md'> <Group grow mb='md' mt='md'>
{auth_settings.providers.map((provider) => ( {auth_config?.socialaccount.providers.map((provider) => (
<SsoButton provider={provider} key={provider.id} /> <SsoButton provider={provider} key={provider.id} />
))} ))}
</Group> </Group>
@ -124,7 +136,7 @@ export function AuthenticationForm() {
placeholder={t`Your password`} placeholder={t`Your password`}
{...classicForm.getInputProps('password')} {...classicForm.getInputProps('password')}
/> />
{auth_settings?.password_forgotten_enabled === true && ( {password_forgotten_enabled() === true && (
<Group justify='space-between' mt='0'> <Group justify='space-between' mt='0'>
<Anchor <Anchor
component='button' component='button'
@ -185,20 +197,42 @@ export function AuthenticationForm() {
export function RegistrationForm() { export function RegistrationForm() {
const registrationForm = useForm({ const registrationForm = useForm({
initialValues: { username: '', email: '', password1: '', password2: '' } initialValues: {
username: '',
email: '',
password: '',
password2: '' as string | undefined
}
}); });
const navigate = useNavigate(); const navigate = useNavigate();
const [auth_settings] = useServerApiState((state) => [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<boolean>(false); const [isRegistering, setIsRegistering] = useState<boolean>(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); setIsRegistering(true);
// remove password2 from the request
const { password2, ...vals } = registrationForm.values;
await ensureCsrf();
api api
.post(apiUrl(ApiEndpoints.user_register), registrationForm.values, { .post(apiUrl(ApiEndpoints.auth_signup), vals, {
headers: { Authorization: '' } headers: { Authorization: '' }
}) })
.then((ret) => { .then((ret) => {
if (ret?.status === 204 || ret?.status === 201) { if (ret?.status === 200) {
setIsRegistering(false); setIsRegistering(false);
showLoginNotification({ showLoginNotification({
title: t`Registration successful`, title: t`Registration successful`,
@ -210,27 +244,33 @@ export function RegistrationForm() {
.catch((err) => { .catch((err) => {
if (err.response?.status === 400) { if (err.response?.status === 400) {
setIsRegistering(false); 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] = [];
} }
let err_msg = ''; errors[val.param].push(val.message);
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({ showLoginNotification({
title: t`Input error`, title: t`Input error`,
message: t`Check your input and try again. ` + err_msg, message: t`Check your input and try again. `,
success: false success: false
}); });
} }
}); });
} }
const both_reg_enabled = const both_reg_enabled = registration_enabled() && sso_registration();
auth_settings?.registration_enabled && auth_settings?.sso_registration;
return ( return (
<> <>
{auth_settings?.registration_enabled && ( {registration_enabled() && (
<form onSubmit={registrationForm.onSubmit(() => {})}> <form onSubmit={registrationForm.onSubmit(() => {})}>
<Stack gap={0}> <Stack gap={0}>
<TextInput <TextInput
@ -253,7 +293,7 @@ export function RegistrationForm() {
label={t`Password`} label={t`Password`}
aria-label='register-password' aria-label='register-password'
placeholder={t`Your password`} placeholder={t`Your password`}
{...registrationForm.getInputProps('password1')} {...registrationForm.getInputProps('password')}
/> />
<PasswordInput <PasswordInput
required required
@ -279,9 +319,9 @@ export function RegistrationForm() {
{both_reg_enabled && ( {both_reg_enabled && (
<Divider label={t`Or use SSO`} labelPosition='center' my='lg' /> <Divider label={t`Or use SSO`} labelPosition='center' my='lg' />
)} )}
{auth_settings?.sso_registration === true && ( {sso_registration() && (
<Group grow mb='md' mt='md'> <Group grow mb='md' mt='md'>
{auth_settings.providers.map((provider) => ( {auth_config?.socialaccount.providers.map((provider) => (
<SsoButton provider={provider} key={provider.id} /> <SsoButton provider={provider} key={provider.id} />
))} ))}
</Group> </Group>
@ -292,18 +332,18 @@ export function RegistrationForm() {
export function ModeSelector({ export function ModeSelector({
loginMode, loginMode,
setMode changePage
}: Readonly<{ }: Readonly<{
loginMode: boolean; loginMode: boolean;
setMode: any; changePage: (state: string) => void;
}>) { }>) {
const [auth_settings] = useServerApiState((state) => [state.auth_settings]); const [sso_registration, registration_enabled] = useServerApiState(
const registration_enabled = (state) => [state.sso_registration_enabled, state.registration_enabled]
auth_settings?.registration_enabled || );
auth_settings?.sso_registration || const both_reg_enabled =
false; registration_enabled() || sso_registration() || false;
if (registration_enabled === false) return null; if (both_reg_enabled === false) return null;
return ( return (
<Text ta='center' size={'xs'} mt={'md'}> <Text ta='center' size={'xs'} mt={'md'}>
{loginMode ? ( {loginMode ? (
@ -314,7 +354,7 @@ export function ModeSelector({
type='button' type='button'
c='dimmed' c='dimmed'
size='xs' size='xs'
onClick={() => setMode.close()} onClick={() => changePage('register')}
> >
<Trans>Register</Trans> <Trans>Register</Trans>
</Anchor> </Anchor>
@ -325,7 +365,7 @@ export function ModeSelector({
type='button' type='button'
c='dimmed' c='dimmed'
size='xs' size='xs'
onClick={() => setMode.open()} onClick={() => changePage('login')}
> >
<Trans>Go back to login</Trans> <Trans>Go back to login</Trans>
</Anchor> </Anchor>

View File

@ -116,20 +116,20 @@ function BasePanelGroup({
// Callback when the active panel changes // Callback when the active panel changes
const handlePanelChange = useCallback( const handlePanelChange = useCallback(
(panel: string, event?: any) => { (targetPanel: string, event?: any) => {
if (event && (event?.ctrlKey || event?.shiftKey)) { if (event && (event?.ctrlKey || event?.shiftKey)) {
const url = `${location.pathname}/../${panel}`; const url = `${location.pathname}/../${targetPanel}`;
cancelEvent(event); cancelEvent(event);
navigateToLink(url, navigate, event); navigateToLink(url, navigate, event);
} else { } else {
navigate(`../${panel}`); navigate(`../${targetPanel}`);
} }
localState.setLastUsedPanel(pageKey)(panel); localState.setLastUsedPanel(pageKey)(targetPanel);
// Optionally call external callback hook // Optionally call external callback hook
if (panel && onPanelChange) { if (targetPanel && onPanelChange) {
onPanelChange(panel); onPanelChange(targetPanel);
} }
}, },
[activePanels, navigate, location, onPanelChange] [activePanels, navigate, location, onPanelChange]

View File

@ -20,6 +20,7 @@ export const emptyServerAPI = {
target: null, target: null,
default_locale: null, default_locale: null,
django_admin: null, django_admin: null,
settings: null,
customize: null customize: null
}; };

View File

@ -16,18 +16,25 @@ export enum ApiEndpoints {
user_token = 'user/token/', user_token = 'user/token/',
user_tokens = 'user/tokens/', user_tokens = 'user/tokens/',
user_simple_login = 'email/generate/', user_simple_login = 'email/generate/',
user_reset = 'auth/password/reset/',
user_reset_set = 'auth/password/reset/confirm/', // User auth endpoints
user_change_password = 'auth/password/change/', user_reset = 'auth/v1/auth/password/request',
user_sso = 'auth/social/', user_reset_set = 'auth/v1/auth/password/reset',
user_sso_remove = 'auth/social/:id/disconnect/', auth_pwd_change = 'auth/v1/account/password/change',
user_emails = 'auth/emails/', auth_login = 'auth/v1/auth/login',
user_email_remove = 'auth/emails/:id/remove/', auth_login_2fa = 'auth/v1/auth/2fa/authenticate',
user_email_verify = 'auth/emails/:id/verify/', auth_session = 'auth/v1/auth/session',
user_email_primary = 'auth/emails/:id/primary/', auth_signup = 'auth/v1/auth/signup',
user_login = 'auth/login/', auth_authenticators = 'auth/v1/account/authenticators',
user_logout = 'auth/logout/', auth_recovery = 'auth/v1/account/authenticators/recovery-codes',
user_register = 'auth/registration/', 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 // Generic API endpoints
currency_list = 'currency/exchange/', currency_list = 'currency/exchange/',
@ -45,7 +52,6 @@ export enum ApiEndpoints {
custom_state_list = 'generic/status/custom/', custom_state_list = 'generic/status/custom/',
version = 'version/', version = 'version/',
license = 'license/', license = 'license/',
sso_providers = 'auth/providers/',
group_list = 'user/group/', group_list = 'user/group/',
owner_list = 'user/owner/', owner_list = 'user/owner/',
content_type_list = 'contenttype/', content_type_list = 'contenttype/',

View File

@ -1,15 +1,16 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import axios from 'axios'; 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 { api, setApiDefaults } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState'; import { apiUrl, useServerApiState } from '../states/ApiState';
import { useLocalState } from '../states/LocalState'; import { useLocalState } from '../states/LocalState';
import { useUserState } from '../states/UserState'; import { useUserState } from '../states/UserState';
import { fetchGlobalStates } from '../states/states'; import { type Provider, fetchGlobalStates } from '../states/states';
import { showLoginNotification } from './notifications'; import { showLoginNotification } from './notifications';
import { generateUrl } from './urls';
export function followRedirect(navigate: NavigateFunction, redirect: any) { export function followRedirect(navigate: NavigateFunction, redirect: any) {
let url = redirect?.redirectUrl ?? '/home'; 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. * If login is successful, an API token will be returned.
* This API token is used for any future API requests. * 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 { host } = useLocalState.getState();
const { clearUserState, setToken, fetchUserState } = useUserState.getState(); const { clearUserState, setToken, fetchUserState } = useUserState.getState();
const { setAuthContext } = useServerApiState.getState();
if (username.length == 0 || password.length == 0) { if (username.length == 0 || password.length == 0) {
return; return;
} }
clearCsrfCookie(); clearCsrfCookie();
await ensureCsrf();
const login_url = apiUrl(ApiEndpoints.user_login); let loginDone = false;
let success = false;
let result = false;
// Attempt login with // Attempt login with
await api await api
.post( .post(
login_url, apiUrl(ApiEndpoints.auth_login),
{ {
username: username, username: username,
password: password password: password
@ -86,33 +92,41 @@ export const doBasicLogin = async (username: string, password: string) => {
} }
) )
.then((response) => { .then((response) => {
if (response.status == 200) { setAuthContext(response.data?.data);
if (response.data.key) { if (response.status == 200 && response.data?.meta?.is_authenticated) {
setToken(response.data.key); setToken(response.data.meta.access_token);
result = true; loginDone = true;
} success = true;
} }
}) })
.catch((err) => { .catch((err) => {
if ( if (err?.response?.status == 401) {
err?.response?.status == 403 && setAuthContext(err.response.data?.data);
err?.response?.data?.detail == 'MFA required for this user' const mfa_flow = err.response.data.data.flows.find(
) { (flow: any) => flow.id == 'mfa_authenticate'
post(apiUrl(ApiEndpoints.user_login), { );
username: username, if (mfa_flow && mfa_flow.is_pending == true) {
password: password, success = true;
csrfmiddlewaretoken: getCsrfCookie(), navigate('/mfa');
mfa: true }
} 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(); await fetchUserState();
fetchGlobalStates(); // see if mfa registration is required
} else { await fetchGlobalStates(navigate);
} else if (!success) {
clearUserState(); clearUserState();
} }
return success;
}; };
/** /**
@ -125,8 +139,9 @@ export const doLogout = async (navigate: NavigateFunction) => {
// Logout from the server session // Logout from the server session
if (isLoggedIn() || !!getCsrfCookie()) { if (isLoggedIn() || !!getCsrfCookie()) {
await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => {}); await authApi(apiUrl(ApiEndpoints.auth_session), undefined, 'delete').catch(
() => {}
);
showLoginNotification({ showLoginNotification({
title: t`Logged Out`, title: t`Logged Out`,
message: t`Successfully logged out` message: t`Successfully logged out`
@ -158,12 +173,19 @@ export const doSimpleLogin = async (email: string) => {
return mail; return mail;
}; };
export function handleReset(navigate: any, values: { email: string }) { export async function ensureCsrf() {
api const cookie = getCsrfCookie();
.post(apiUrl(ApiEndpoints.user_reset), values, { if (cookie == undefined) {
headers: { Authorization: '' } await api.get(apiUrl(ApiEndpoints.user_token)).catch(() => {});
}) }
.then((val) => { }
export function handleReset(
navigate: NavigateFunction,
values: { email: string }
) {
ensureCsrf();
api.post(apiUrl(ApiEndpoints.user_reset), values).then((val) => {
if (val.status === 200) { if (val.status === 200) {
notifications.show({ notifications.show({
title: t`Mail delivery successful`, title: t`Mail delivery successful`,
@ -182,6 +204,43 @@ export function handleReset(navigate: any, values: { email: string }) {
}); });
} }
export function handleMfaLogin(
navigate: NavigateFunction,
location: Location<any>,
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);
})
.catch((err) => {
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
});
} else {
const errors = err.response?.data?.errors;
let msg = t`An error occurred`;
if (errors) {
msg = errors.map((e: any) => e.message).join(', ');
}
setError(msg);
}
});
}
/** /**
* Check login state, and redirect the user as required. * Check login state, and redirect the user as required.
* *
@ -209,7 +268,7 @@ export const checkLoginState = async (
message: t`Successfully logged in` message: t`Successfully logged in`
}); });
fetchGlobalStates(); fetchGlobalStates(navigate);
followRedirect(navigate, redirect); followRedirect(navigate, redirect);
}; };
@ -257,3 +316,45 @@ export function clearCsrfCookie() {
document.cookie = document.cookie =
'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; '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);
}

View File

@ -19,12 +19,14 @@ import { StylishText } from '../../components/items/StylishText';
import { ProtectedRoute } from '../../components/nav/Layout'; import { ProtectedRoute } from '../../components/nav/Layout';
import { LanguageContext } from '../../contexts/LanguageContext'; import { LanguageContext } from '../../contexts/LanguageContext';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { clearCsrfCookie } from '../../functions/auth';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
export default function Set_Password() { export default function Set_Password() {
const simpleForm = useForm({ const simpleForm = useForm({
initialValues: { initialValues: {
current_password: '',
new_password1: '', new_password1: '',
new_password2: '' new_password2: ''
} }
@ -35,8 +37,10 @@ export default function Set_Password() {
function passwordError(values: any) { function passwordError(values: any) {
let message: any = let message: any =
values?.new_password ||
values?.new_password2 || values?.new_password2 ||
values?.new_password1 || values?.new_password1 ||
values?.current_password ||
values?.error || values?.error ||
t`Password could not be changed`; t`Password could not be changed`;
@ -55,27 +59,45 @@ export default function Set_Password() {
} }
function handleSet() { 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 didnt match` });
return;
}
// Set password with call to backend // Set password with call to backend
api api
.post(apiUrl(ApiEndpoints.user_change_password), { .post(apiUrl(ApiEndpoints.auth_pwd_change), {
new_password1: simpleForm.values.new_password1, current_password: simpleForm.values.current_password,
new_password2: simpleForm.values.new_password2 new_password: simpleForm.values.new_password2
}) })
.then((val) => { .then((val) => {
if (val.status === 200) { passwordError(val.data);
})
.catch((err) => {
if (err.status === 401) {
notifications.show({ notifications.show({
title: t`Password Changed`, title: t`Password Changed`,
message: t`The password was set successfully. You can now login with your new password`, message: t`The password was set successfully. You can now login with your new password`,
color: 'green', color: 'green',
autoClose: false autoClose: false
}); });
clearUserState();
clearCsrfCookie();
navigate('/login'); navigate('/login');
} else { } 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() {
)} )}
<Divider /> <Divider />
<Stack gap='xs'> <Stack gap='xs'>
<PasswordInput
required
aria-label='password'
label={t`Current Password`}
description={t`Enter your current password`}
{...simpleForm.getInputProps('current_password')}
/>
<PasswordInput <PasswordInput
required required
aria-label='input-password-1' aria-label='input-password-1'

View File

@ -47,6 +47,14 @@ export default function Login() {
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
useEffect(() => {
if (location.pathname === '/register') {
setMode.close();
} else {
setMode.open();
}
}, [location]);
const LoginMessage = useMemo(() => { const LoginMessage = useMemo(() => {
const val = server.customize?.login_message; const val = server.customize?.login_message;
if (val) { if (val) {
@ -95,7 +103,8 @@ export default function Login() {
if (searchParams.has('login') && searchParams.has('password')) { if (searchParams.has('login') && searchParams.has('password')) {
doBasicLogin( doBasicLogin(
searchParams.get('login') ?? '', searchParams.get('login') ?? '',
searchParams.get('password') ?? '' searchParams.get('password') ?? '',
navigate
).then(() => { ).then(() => {
followRedirect(navigate, location?.state); followRedirect(navigate, location?.state);
}); });
@ -135,7 +144,10 @@ export default function Login() {
</StylishText> </StylishText>
<Divider p='xs' /> <Divider p='xs' />
{loginMode ? <AuthenticationForm /> : <RegistrationForm />} {loginMode ? <AuthenticationForm /> : <RegistrationForm />}
<ModeSelector loginMode={loginMode} setMode={setMode} /> <ModeSelector
loginMode={loginMode}
changePage={(newPage) => navigate(`/${newPage}`)}
/>
{LoginMessage} {LoginMessage}
</Paper> </Paper>
<AuthFormOptions <AuthFormOptions

View File

@ -0,0 +1,59 @@
import { Trans, t } from '@lingui/macro';
import {
Button,
Center,
Container,
Stack,
TextInput,
Title
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useLocation, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { LanguageContext } from '../../contexts/LanguageContext';
import { handleMfaLogin } from '../../functions/auth';
export default function MFALogin() {
const simpleForm = useForm({ initialValues: { code: '' } });
const navigate = useNavigate();
const location = useLocation();
const [loginError, setLoginError] = useState<string | undefined>(undefined);
return (
<LanguageContext>
<Center mih='100vh'>
<Container w='md' miw={425}>
<Stack>
<Title>
<Trans>MFA Login</Trans>
</Title>
<Stack>
<TextInput
required
label={t`TOTP Code`}
name='TOTP'
description={t`Enter your TOTP or recovery code`}
{...simpleForm.getInputProps('code')}
error={loginError}
/>
</Stack>
<Button
type='submit'
onClick={() =>
handleMfaLogin(
navigate,
location,
simpleForm.values,
setLoginError
)
}
>
<Trans>Log in</Trans>
</Button>
</Stack>
</Container>
</Center>
</LanguageContext>
);
}

View File

@ -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 (
<LanguageContext>
<Center mih='100vh'>
<Container w='md' miw={425}>
<Stack>
<Title>
<Trans>MFA Setup Required</Trans>
</Title>
<QrRegistrationForm
url={totpQr?.totp_url ?? ''}
secret={totpQr?.secret ?? ''}
value={value}
setValue={setValue}
/>
<Button
disabled={!value}
onClick={() => {
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
code: value
}).then(() => {
followRedirect(navigate, location?.state);
});
}}
>
<Trans>Add TOTP</Trans>
</Button>
<Button onClick={() => doLogout(navigate)} color='red'>
<Trans>Log off</Trans>
</Button>
</Stack>
</Container>
</Center>
</LanguageContext>
);
}

View File

@ -22,48 +22,18 @@ export default function ResetPassword() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const token = searchParams.get('token'); const key = searchParams.get('key');
const uid = searchParams.get('uid');
function invalidToken() { function invalidKey() {
notifications.show({ notifications.show({
title: t`Token invalid`, title: t`Key invalid`,
message: t`You need to provide a valid token to set a new password. Check your inbox for a reset link.`, message: t`You need to provide a valid key to set a new password. Check your inbox for a reset link.`,
color: 'red' color: 'red'
}); });
navigate('/login'); navigate('/login');
} }
function passwordError(values: any) { function success() {
notifications.show({
title: t`Reset failed`,
message: values?.new_password2 || values?.new_password1 || values?.token,
color: 'red'
});
}
useEffect(() => {
// make sure we have a token
if (!token || !uid) {
invalidToken();
}
}, [token]);
function handleSet() {
// Set password with call to backend
api
.post(
apiUrl(ApiEndpoints.user_reset_set),
{
uid: uid,
token: token,
new_password1: simpleForm.values.password,
new_password2: simpleForm.values.password
},
{ headers: { Authorization: '' } }
)
.then((val) => {
if (val.status === 200) {
notifications.show({ notifications.show({
title: t`Password set`, title: t`Password set`,
message: t`The password was set successfully. You can now login with your new password`, message: t`The password was set successfully. You can now login with your new password`,
@ -71,16 +41,46 @@ export default function ResetPassword() {
autoClose: false autoClose: false
}); });
navigate('/login'); navigate('/login');
}
function passwordError(values: any) {
notifications.show({
title: t`Reset failed`,
message: values?.errors.map((e: any) => e.message).join('\n'),
color: 'red'
});
}
useEffect(() => {
// make sure we have a key
if (!key) {
invalidKey();
}
}, [key]);
function handleSet() {
// Set password with call to backend
api
.post(
apiUrl(ApiEndpoints.user_reset_set),
{
key: key,
password: simpleForm.values.password
},
{ headers: { Authorization: '' } }
)
.then((val) => {
if (val.status === 200) {
success();
} else { } else {
passwordError(val.data); passwordError(val.data);
} }
}) })
.catch((err) => { .catch((err) => {
if ( if (err.response?.status === 400) {
err.response?.status === 400 && passwordError(err.response.data);
err.response?.data?.token == 'Invalid value' } else if (err.response?.status === 401) {
) { success();
invalidToken();
} else { } else {
passwordError(err.response.data); passwordError(err.response.data);
} }
@ -99,12 +99,12 @@ export default function ResetPassword() {
<PasswordInput <PasswordInput
required required
label={t`Password`} label={t`Password`}
description={t`We will send you a link to login - if you are registered`} description={t`The desired new password`}
{...simpleForm.getInputProps('password')} {...simpleForm.getInputProps('password')}
/> />
</Stack> </Stack>
<Button type='submit' onClick={handleSet}> <Button type='submit' onClick={handleSet}>
<Trans>Send Email</Trans> <Trans>Send Password</Trans>
</Button> </Button>
</Stack> </Stack>
</Container> </Container>

View File

@ -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 (
<LanguageContext>
<Center mih='100vh'>
<Container w='md' miw={425}>
<Stack>
<Title>
<Trans>Verify Email</Trans>
</Title>
<Button type='submit' onClick={handleSet}>
<Trans>Verify</Trans>
</Button>
</Stack>
</Container>
</Center>
</LanguageContext>
);
}

View File

@ -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 (
<>
<Divider />
<QRCode data={url} />
<Text>
<Trans>Secret</Trans>
<br />
{secret}
</Text>
<TextInput
required
label={t`One-Time Password`}
description={t`Enter the TOTP code to ensure it registered correctly`}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
error={error}
/>
</>
);
}

View File

@ -3,59 +3,55 @@ import {
Alert, Alert,
Badge, Badge,
Button, Button,
Code,
Grid, Grid,
Group, Group,
Loader, Loader,
Modal,
Radio, Radio,
Stack, Stack,
Table, Table,
Text, Text,
TextInput, TextInput,
Title, Title
Tooltip
} from '@mantine/core'; } 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 { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { api } from '../../../../App';
import { api, queryClient } from '../../../../App';
import { YesNoButton } from '../../../../components/buttons/YesNoButton'; import { YesNoButton } from '../../../../components/buttons/YesNoButton';
import { PlaceholderPill } from '../../../../components/items/Placeholder'; import { StylishText } from '../../../../components/items/StylishText';
import { ApiEndpoints } from '../../../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
import { apiUrl } from '../../../../states/ApiState'; import { ProviderLogin, authApi } from '../../../../functions/auth';
import { useUserState } from '../../../../states/UserState'; import { apiUrl, useServerApiState } from '../../../../states/ApiState';
import type { AuthConfig, Provider } from '../../../../states/states';
import { QrRegistrationForm } from './QrRegistrationForm';
import { useReauth } from './useConfirm';
export function SecurityContent() { export function SecurityContent() {
const [isSsoEnabled, setIsSsoEnabled] = useState<boolean>(false); const [auth_config, sso_enabled] = useServerApiState((state) => [
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(false); state.auth_config,
state.sso_enabled
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]);
return ( return (
<Stack> <Stack>
<Title order={5}> <Title order={5}>
<Trans>Email</Trans> <Trans>Email Addresses</Trans>
</Title> </Title>
<EmailContent /> <EmailSection />
<Title order={5}> <Title order={5}>
<Trans>Single Sign On Accounts</Trans> <Trans>Single Sign On</Trans>
</Title> </Title>
{isSsoEnabled ? ( {sso_enabled() ? (
<SsoContent dataProvider={dataProvider} /> <ProviderSection auth_config={auth_config} />
) : ( ) : (
<Alert <Alert
icon={<IconAlertCircle size='1rem' />} icon={<IconAlertCircle size='1rem' />}
@ -66,65 +62,42 @@ export function SecurityContent() {
</Alert> </Alert>
)} )}
<Title order={5}> <Title order={5}>
<Trans>Multifactor</Trans> <Trans>Multifactor authentication</Trans>
</Title> </Title>
{isLoadingProvider ? ( <MfaSection />
<Loader />
) : (
<>
{isMfaEnabled ? (
<MfaContent />
) : (
<Alert
icon={<IconAlertCircle size='1rem' />}
title={t`Not enabled`}
color='yellow'
>
<Trans>
Multifactor authentication is not configured for your account{' '}
</Trans>
</Alert>
)}
</>
)}
<Title order={5}> <Title order={5}>
<Trans>Token</Trans> <Trans>Access Tokens</Trans>
</Title> </Title>
<TokenContent /> <TokenSection />
</Stack> </Stack>
); );
} }
function EmailContent() { function EmailSection() {
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const [newEmailValue, setNewEmailValue] = useState(''); const [newEmailValue, setNewEmailValue] = useState('');
const [user] = useUserState((state) => [state.user]);
const { isLoading, data, refetch } = useQuery({ const { isLoading, data, refetch } = useQuery({
queryKey: ['emails'], queryKey: ['emails'],
queryFn: () => 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) { function runServerAction(
api action: 'post' | 'put' | 'delete' = 'post',
.post(apiUrl(url, undefined, { id: value }), {}) data?: any
.then(() => { ) {
const vals: any = data || { email: value };
return authApi(
apiUrl(ApiEndpoints.auth_email),
undefined,
action,
vals
).then(() => {
refetch(); 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));
} }
if (isLoading) return <Loader />; if (isLoading) return <Loader />;
@ -132,6 +105,15 @@ function EmailContent() {
return ( return (
<Grid> <Grid>
<Grid.Col span={6}> <Grid.Col span={6}>
{emailAvailable ? (
<Alert
icon={<IconAlertCircle size='1rem' />}
title={t`Not configured`}
color='yellow'
>
<Trans>Currently no email addresses are registered.</Trans>
</Alert>
) : (
<Radio.Group <Radio.Group
value={value} value={value}
onChange={setValue} onChange={setValue}
@ -139,19 +121,19 @@ function EmailContent() {
label={t`The following email addresses are associated with your account:`} label={t`The following email addresses are associated with your account:`}
> >
<Stack mt='xs'> <Stack mt='xs'>
{data.map((link: any) => ( {data.map((email: any) => (
<Radio <Radio
key={link.id} key={email.email}
value={String(link.id)} value={String(email.email)}
label={ label={
<Group justify='space-between'> <Group justify='space-between'>
{link.email} {email.email}
{link.primary && ( {email.primary && (
<Badge color='blue'> <Badge color='blue'>
<Trans>Primary</Trans> <Trans>Primary</Trans>
</Badge> </Badge>
)} )}
{link.verified ? ( {email.verified ? (
<Badge color='green'> <Badge color='green'>
<Trans>Verified</Trans> <Trans>Verified</Trans>
</Badge> </Badge>
@ -166,6 +148,7 @@ function EmailContent() {
))} ))}
</Stack> </Stack>
</Radio.Group> </Radio.Group>
)}
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>
<Stack> <Stack>
@ -184,24 +167,44 @@ function EmailContent() {
<Grid.Col span={6}> <Grid.Col span={6}>
<Group> <Group>
<Button <Button
onClick={() => runServerAction(ApiEndpoints.user_email_primary)} onClick={() =>
runServerAction('post', { email: value, primary: true })
}
disabled={emailAvailable}
> >
<Trans>Make Primary</Trans> <Trans>Make Primary</Trans>
</Button> </Button>
<Button <Button
onClick={() => runServerAction(ApiEndpoints.user_email_verify)} onClick={() => runServerAction('put')}
disabled={emailAvailable}
> >
<Trans>Re-send Verification</Trans> <Trans>Re-send Verification</Trans>
</Button> </Button>
<Button <Button
onClick={() => runServerAction(ApiEndpoints.user_email_remove)} onClick={() => runServerAction('delete')}
disabled={emailAvailable}
> >
<Trans>Remove</Trans> <Trans>Remove</Trans>
</Button> </Button>
</Group> </Group>
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>
<Button onClick={addEmail}> <Button
onClick={() =>
runServerAction('post', { email: newEmailValue }).catch((err) => {
if (err.status == 400) {
showNotification({
title: t`Error while adding email`,
message: err.response.data.errors
.map((error: any) => error.message)
.join('\n'),
color: 'red',
icon: <IconX />
});
}
})
}
>
<Trans>Add Email</Trans> <Trans>Add Email</Trans>
</Button> </Button>
</Grid.Col> </Grid.Col>
@ -209,68 +212,51 @@ function EmailContent() {
); );
} }
function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) { function ProviderButton({ provider }: Readonly<{ provider: Provider }>) {
return (
<Button
key={provider.id}
variant='outline'
onClick={() => ProviderLogin(provider, 'connect')}
>
<Group justify='space-between'>{provider.name}</Group>
</Button>
);
}
function ProviderSection({
auth_config
}: Readonly<{ auth_config: AuthConfig | undefined }>) {
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const [currentProviders, setCurrentProviders] = useState<[]>(); const { isLoading, data, refetch } = useQuery({
const { isLoading, data } = useQuery({ queryKey: ['provider-list'],
queryKey: ['sso-list'],
queryFn: () => queryFn: () =>
api.get(apiUrl(ApiEndpoints.user_sso)).then((res) => res.data) authApi(apiUrl(ApiEndpoints.auth_providers)).then((res) => res.data.data)
}); });
useEffect(() => { const availableProviders = useMemo(() => {
if (dataProvider === undefined) return; if (!auth_config || !data) return [];
if (data === undefined) return;
const configuredProviders = data.map((item: any) => { const configuredProviders = data.map((item: any) => item.provider.id);
return item.provider; return auth_config.socialaccount.providers.filter(
}); (provider: any) => !configuredProviders.includes(provider.id)
function isAlreadyInUse(value: any) { );
return !configuredProviders.includes(value.id); }, [auth_config, data]);
}
// remove providers that are used currently
let newData = dataProvider.providers;
newData = newData.filter(isAlreadyInUse);
setCurrentProviders(newData);
}, [dataProvider, data]);
function removeProvider() { function removeProvider() {
api const [uid, provider] = value.split('$');
.post(apiUrl(ApiEndpoints.user_sso_remove, undefined, { id: value })) authApi(apiUrl(ApiEndpoints.auth_providers), undefined, 'delete', {
provider,
account: uid
})
.then(() => { .then(() => {
queryClient.removeQueries({ refetch();
queryKey: ['sso-list']
});
}) })
.catch((res) => console.log(res.data)); .catch((res) => console.log(res.data));
} }
/* renderer */
if (isLoading) return <Loader />; if (isLoading) return <Loader />;
function ProviderButton({ provider }: Readonly<{ provider: any }>) {
const button = (
<Button
key={provider.id}
component='a'
href={provider.connect}
variant='outline'
disabled={!provider.configured}
>
<Group justify='space-between'>
{provider.display_name}
{provider.configured == false && <IconAlertCircle />}
</Group>
</Button>
);
if (provider.configured) return button;
return (
<Tooltip label={t`Provider has not been configured`}>{button}</Tooltip>
);
}
return ( return (
<Grid> <Grid>
<Grid.Col span={6}> <Grid.Col span={6}>
@ -280,9 +266,7 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
title={t`Not configured`} title={t`Not configured`}
color='yellow' color='yellow'
> >
<Trans> <Trans>There are no providers connected to this account.</Trans>
There are no social network accounts connected to this account.{' '}
</Trans>
</Alert> </Alert>
) : ( ) : (
<Stack> <Stack>
@ -290,20 +274,20 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
value={value} value={value}
onChange={setValue} onChange={setValue}
name='sso_accounts' 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`}
> >
<Stack mt='xs'> <Stack mt='xs'>
{data.map((link: any) => ( {data.map((link: any) => (
<Radio <Radio
key={link.id} key={link.uid}
value={String(link.id)} value={[link.uid, link.provider.id].join('$')}
label={link.provider} label={`${link.provider.name}: ${link.display}`}
/> />
))} ))}
</Stack> </Stack>
</Radio.Group> </Radio.Group>
<Button onClick={removeProvider}> <Button onClick={removeProvider}>
<Trans>Remove</Trans> <Trans>Remove Provider Link</Trans>
</Button> </Button>
</Stack> </Stack>
)} )}
@ -311,33 +295,394 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
<Grid.Col span={6}> <Grid.Col span={6}>
<Stack> <Stack>
<Text>Add SSO Account</Text> <Text>Add SSO Account</Text>
{availableProviders === undefined ? (
<Text> <Text>
{currentProviders === undefined ? (
<Trans>Loading</Trans> <Trans>Loading</Trans>
</Text>
) : ( ) : (
<Stack gap='xs'> <Stack gap='xs'>
{currentProviders.map((provider: any) => ( {availableProviders.map((provider: any) => (
<ProviderButton key={provider.id} provider={provider} /> <ProviderButton key={provider.id} provider={provider} />
))} ))}
</Stack> </Stack>
)} )}
</Text>
</Stack> </Stack>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
); );
} }
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) => (
<Table.Tr key={`${token.created_at}-${token.type}`}>
<Table.Td>{token.type}</Table.Td>
<Table.Td>{parseDate(token.last_used_at)}</Table.Td>
<Table.Td>{parseDate(token.created_at)}</Table.Td>
<Table.Td>
{token.type == 'totp' && (
<Button color='red' onClick={removeTotp}>
<Trans>Remove</Trans>
</Button>
)}
{token.type == 'recovery_codes' && (
<Button onClick={viewRecoveryCodes}>
<Trans>View</Trans>
</Button>
)}
</Table.Td>
</Table.Tr>
));
}, [data, isLoading]);
const usedFactors: string[] = useMemo(() => {
if (isLoading || !data) return [];
return data.map((token: any) => token.type);
}, [data]);
if (isLoading) return <Loader />;
return ( return (
<> <>
MFA Details <ReauthModal />
<PlaceholderPill /> <Grid>
<Grid.Col span={6}>
{data.length == 0 ? (
<Alert icon={<IconAlertCircle size='1rem' />} color='yellow'>
<Trans>No factors configured</Trans>
</Alert>
) : (
<Table stickyHeader striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Trans>Type</Trans>
</Table.Th>
<Table.Th>
<Trans>Last used at</Trans>
</Table.Th>
<Table.Th>
<Trans>Created at</Trans>
</Table.Th>
<Table.Th>
<Trans>Actions</Trans>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
)}
</Grid.Col>
<Grid.Col span={6}>
<MfaAddSection
usedFactors={usedFactors}
refetch={refetch}
showRecoveryCodes={showRecoveryCodes}
/>
<Modal
opened={recoveryCodesOpen}
onClose={() => {
refetch();
closeRecoveryCodes();
}}
title={t`Recovery Codes`}
centered
>
<Title order={3}>
<Trans>Unused Codes</Trans>
</Title>
<Code>{recoveryCodes?.unused_codes?.join('\n')}</Code>
<Title order={3}>
<Trans>Used Codes</Trans>
</Title>
<Code>{recoveryCodes?.used_codes?.join('\n')}</Code>
</Modal>
</Grid.Col>
</Grid>
</> </>
); );
} }
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: <IconX />
});
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<string>('');
return (
<Stack>
<ReauthModal />
<Text>Add Factor</Text>
{possibleFactors.map((factor) => (
<Button
key={factor.type}
onClick={factor.function}
disabled={factor.used}
variant='outline'
>
{factor.name}
</Button>
))}
<Modal
opened={totpQrOpen}
onClose={closeTotpQr}
title={<StylishText size='lg'>{t`Register TOTP Token`}</StylishText>}
>
<Stack>
<QrRegistrationForm
url={totpQr?.totp_url ?? ''}
secret={totpQr?.secret ?? ''}
value={value}
error={totpError}
setValue={setValue}
/>
<Button
fullWidth
onClick={() =>
runActionWithFallback(
() =>
authApi(apiUrl(ApiEndpoints.auth_totp), undefined, 'post', {
code: value
})
.then(() => {
setTotpError('');
closeTotpQr();
refetch();
return ResultType.success;
})
.catch((error) => {
const errorMsg = t`Error registering TOTP token`;
setTotpError(
error.response?.data?.errors[0]?.message ?? errorMsg
);
hideNotification('totp-error');
showNotification({
id: 'totp-error',
title: t`Error`,
message: errorMsg,
color: 'red',
icon: <IconExclamationCircle />
});
return ResultType.error;
}),
getReauthText
)
}
>
<Trans>Submit</Trans>
</Button>
</Stack>
</Modal>
</Stack>
);
}
async function runActionWithFallback(
action: () => Promise<ResultType>,
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({ const { isLoading, data, refetch } = useQuery({
queryKey: ['token-list'], queryKey: ['token-list'],
queryFn: () => queryFn: () =>
@ -352,8 +697,9 @@ function TokenContent() {
}) })
.catch((res) => console.log(res.data)); .catch((res) => console.log(res.data));
} }
const rows = useMemo(() => { const rows = useMemo(() => {
if (isLoading || data === undefined) return null; if (isLoading || !data) return null;
return data.map((token: any) => ( return data.map((token: any) => (
<Table.Tr key={token.id}> <Table.Tr key={token.id}>
<Table.Td> <Table.Td>
@ -380,7 +726,6 @@ function TokenContent() {
)); ));
}, [data, isLoading]); }, [data, isLoading]);
/* renderer */
if (isLoading) return <Loader />; if (isLoading) return <Loader />;
if (data.length == 0) if (data.length == 0)

View File

@ -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 = () => (
<Modal opened={open} onClose={() => setOpen(false)}>
{label}
<Button onClick={() => onClick(false)}> Cancel </Button>
<Button onClick={() => onClick(true)}> OK </Button>
</Modal>
);
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<InputProps>({
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 = () => (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={t`Reauthentication`}
>
<Stack>
<TextInput
required
label={inputProps.label}
name={inputProps.name}
description={inputProps.description}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<Group justify='space-between'>
<Button onClick={() => onClick('', false)} color='red'>
<Trans>Cancel</Trans>
</Button>
<Button onClick={() => onClick(value, true)}>
<Trans>OK</Trans>
</Button>
</Group>
</Stack>
</Modal>
);
return [getReauthText, ReauthModal];
};

View File

@ -104,6 +104,8 @@ export const NotFound = Loadable(
lazy(() => import('./components/errors/NotFound')) lazy(() => import('./components/errors/NotFound'))
); );
export const Login = Loadable(lazy(() => import('./pages/Auth/Login'))); 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 Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In'))); export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset'))); export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset')));
@ -115,6 +117,9 @@ export const ChangePassword = Loadable(
export const ResetPassword = Loadable( export const ResetPassword = Loadable(
lazy(() => import('./pages/Auth/ResetPassword')) lazy(() => import('./pages/Auth/ResetPassword'))
); );
export const VerifyEmail = Loadable(
lazy(() => import('./pages/Auth/VerifyEmail'))
);
// Routes // Routes
export const routes = ( export const routes = (
@ -170,11 +175,15 @@ export const routes = (
</Route> </Route>
<Route path='/' errorElement={<ErrorPage />}> <Route path='/' errorElement={<ErrorPage />}>
<Route path='/login' element={<Login />} />, <Route path='/login' element={<Login />} />,
<Route path='/register' element={<Login />} />,
<Route path='/mfa' element={<MFALogin />} />,
<Route path='/mfa-setup' element={<MFASetup />} />,
<Route path='/logout' element={<Logout />} />, <Route path='/logout' element={<Logout />} />,
<Route path='/logged-in' element={<Logged_In />} /> <Route path='/logged-in' element={<Logged_In />} />
<Route path='/reset-password' element={<Reset />} /> <Route path='/reset-password' element={<Reset />} />
<Route path='/set-password' element={<ResetPassword />} /> <Route path='/set-password' element={<ResetPassword />} />
<Route path='/change-password' element={<ChangePassword />} /> <Route path='/change-password' element={<ChangePassword />} />
<Route path='/verify-email/:key' element={<VerifyEmail />} />
</Route> </Route>
</Routes> </Routes>
); );

View File

@ -4,18 +4,31 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import { api } from '../App'; import { api } from '../App';
import { emptyServerAPI } from '../defaults/defaults'; import { emptyServerAPI } from '../defaults/defaults';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import type { AuthProps, ServerAPIProps } from './states'; import type { AuthConfig, AuthContext, ServerAPIProps } from './states';
interface ServerApiStateProps { interface ServerApiStateProps {
server: ServerAPIProps; server: ServerAPIProps;
setServer: (newServer: ServerAPIProps) => void; setServer: (newServer: ServerAPIProps) => void;
fetchServerApiState: () => 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<ServerApiStateProps>()( export const useServerApiState = create<ServerApiStateProps>()(
persist( persist(
(set) => ({ (set, get) => ({
server: emptyServerAPI, server: emptyServerAPI,
setServer: (newServer: ServerAPIProps) => set({ server: newServer }), setServer: (newServer: ServerAPIProps) => set({ server: newServer }),
fetchServerApiState: async () => { fetchServerApiState: async () => {
@ -31,17 +44,36 @@ export const useServerApiState = create<ServerApiStateProps>()(
// Fetch login/SSO behaviour // Fetch login/SSO behaviour
await api await api
.get(apiUrl(ApiEndpoints.sso_providers), { .get(apiUrl(ApiEndpoints.auth_config), {
headers: { Authorization: '' } headers: { Authorization: '' }
}) })
.then((response) => { .then((response) => {
set({ auth_settings: response.data }); set({ auth_config: response.data.data });
}) })
.catch(() => { .catch(() => {
console.error('ERR: Error fetching SSO information'); 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', name: 'server-api-state',

View File

@ -13,7 +13,7 @@ import type { Setting, SettingsLookup } from './states';
export interface SettingsStateProps { export interface SettingsStateProps {
settings: Setting[]; settings: Setting[];
lookup: SettingsLookup; lookup: SettingsLookup;
fetchSettings: () => void; fetchSettings: () => Promise<boolean>;
endpoint: ApiEndpoints; endpoint: ApiEndpoints;
pathParams?: PathParams; pathParams?: PathParams;
getSetting: (key: string, default_value?: string) => string; // Return a raw setting value getSetting: (key: string, default_value?: string) => string; // Return a raw setting value
@ -29,10 +29,11 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
lookup: {}, lookup: {},
endpoint: ApiEndpoints.settings_global_list, endpoint: ApiEndpoints.settings_global_list,
fetchSettings: async () => { fetchSettings: async () => {
let success = true;
const { isLoggedIn } = useUserState.getState(); const { isLoggedIn } = useUserState.getState();
if (!isLoggedIn()) { if (!isLoggedIn()) {
return; return success;
} }
await api await api
@ -45,7 +46,10 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
}) })
.catch((_error) => { .catch((_error) => {
console.error('ERR: Error fetching global settings'); console.error('ERR: Error fetching global settings');
success = false;
}); });
return success;
}, },
getSetting: (key: string, default_value?: string) => { getSetting: (key: string, default_value?: string) => {
return get().lookup[key] ?? default_value ?? ''; return get().lookup[key] ?? default_value ?? '';
@ -65,10 +69,11 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
lookup: {}, lookup: {},
endpoint: ApiEndpoints.settings_user_list, endpoint: ApiEndpoints.settings_user_list,
fetchSettings: async () => { fetchSettings: async () => {
let success = true;
const { isLoggedIn } = useUserState.getState(); const { isLoggedIn } = useUserState.getState();
if (!isLoggedIn()) { if (!isLoggedIn()) {
return; return success;
} }
await api await api
@ -81,7 +86,10 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
}) })
.catch((_error) => { .catch((_error) => {
console.error('ERR: Error fetching user settings'); console.error('ERR: Error fetching user settings');
success = false;
}); });
return success;
}, },
getSetting: (key: string, default_value?: string) => { getSetting: (key: string, default_value?: string) => {
return get().lookup[key] ?? default_value ?? ''; return get().lookup[key] ?? default_value ?? '';
@ -110,6 +118,8 @@ export const createPluginSettingsState = ({
endpoint: ApiEndpoints.plugin_setting_list, endpoint: ApiEndpoints.plugin_setting_list,
pathParams, pathParams,
fetchSettings: async () => { fetchSettings: async () => {
let success = true;
await api await api
.get(apiUrl(ApiEndpoints.plugin_setting_list, undefined, { plugin })) .get(apiUrl(ApiEndpoints.plugin_setting_list, undefined, { plugin }))
.then((response) => { .then((response) => {
@ -121,7 +131,10 @@ export const createPluginSettingsState = ({
}) })
.catch((_error) => { .catch((_error) => {
console.error(`Error fetching plugin settings for plugin ${plugin}`); console.error(`Error fetching plugin settings for plugin ${plugin}`);
success = false;
}); });
return success;
}, },
getSetting: (key: string, default_value?: string) => { getSetting: (key: string, default_value?: string) => {
return get().lookup[key] ?? default_value ?? ''; return get().lookup[key] ?? default_value ?? '';
@ -153,6 +166,8 @@ export const createMachineSettingsState = ({
endpoint: ApiEndpoints.machine_setting_detail, endpoint: ApiEndpoints.machine_setting_detail,
pathParams, pathParams,
fetchSettings: async () => { fetchSettings: async () => {
let success = true;
await api await api
.get(apiUrl(ApiEndpoints.machine_setting_list, undefined, { machine })) .get(apiUrl(ApiEndpoints.machine_setting_list, undefined, { machine }))
.then((response) => { .then((response) => {
@ -169,7 +184,10 @@ export const createMachineSettingsState = ({
`Error fetching machine settings for machine ${machine} with type ${configType}:`, `Error fetching machine settings for machine ${machine} with type ${configType}:`,
error error
); );
success = false;
}); });
return success;
}, },
getSetting: (key: string, default_value?: string) => { getSetting: (key: string, default_value?: string) => {
return get().lookup[key] ?? default_value ?? ''; return get().lookup[key] ?? default_value ?? '';

View File

@ -1,3 +1,4 @@
import type { NavigateFunction } from 'react-router-dom';
import { setApiDefaults } from '../App'; import { setApiDefaults } from '../App';
import { useServerApiState } from './ApiState'; import { useServerApiState } from './ApiState';
import { useIconState } from './IconState'; import { useIconState } from './IconState';
@ -48,6 +49,11 @@ export interface ServerAPIProps {
target: null | string; target: null | string;
default_locale: null | string; default_locale: null | string;
django_admin: null | string; django_admin: null | string;
settings: {
sso_registration: null | boolean;
registration_enabled: null | boolean;
password_forgotten_enabled: null | boolean;
} | null;
customize: null | { customize: null | {
logo: string; logo: string;
splash: string; splash: string;
@ -56,22 +62,48 @@ export interface ServerAPIProps {
}; };
} }
export interface AuthProps { export interface AuthContext {
sso_enabled: boolean; status: number;
sso_registration: boolean; data: { flows: Flow[] };
mfa_required: boolean; meta: { is_authenticated: boolean };
providers: Provider[]; }
registration_enabled: boolean;
password_forgotten_enabled: 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 { export interface Provider {
id: string; id: string;
name: string; name: string;
configured: boolean; flows: string[];
login: string; client_id: string;
connect: string;
display_name: string;
} }
// Type interface defining a single 'setting' object // Type interface defining a single 'setting' object
@ -134,7 +166,9 @@ export type SettingsLookup = {
* Refetch all global state information. * Refetch all global state information.
* Necessary on login, or if locale is changed. * Necessary on login, or if locale is changed.
*/ */
export function fetchGlobalStates() { export async function fetchGlobalStates(
navigate?: NavigateFunction | undefined
) {
const { isLoggedIn } = useUserState.getState(); const { isLoggedIn } = useUserState.getState();
if (!isLoggedIn()) { if (!isLoggedIn()) {
@ -144,7 +178,12 @@ export function fetchGlobalStates() {
setApiDefaults(); setApiDefaults();
useServerApiState.getState().fetchServerApiState(); 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(); useGlobalSettingsState.getState().fetchSettings();
useGlobalStatusState.getState().fetchStatus(); useGlobalStatusState.getState().fetchStatus();
useIconState.getState().fetchIcons(); useIconState.getState().fetchIcons();

View File

@ -20,8 +20,12 @@ export default function MainView() {
const [allowMobile] = useLocalState((state) => [state.allowMobile]); const [allowMobile] = useLocalState((state) => [state.allowMobile]);
// Set initial login status // Set initial login status
useEffect(() => { useEffect(() => {
try {
// Local state initialization // Local state initialization
setApiDefaults(); setApiDefaults();
} catch (e) {
console.error(e);
}
}, []); }, []);
// Check if mobile // Check if mobile

View File

@ -71,6 +71,9 @@ export const test = baseTest.extend({
url != 'http://localhost:8000/this/does/not/exist.js' && url != 'http://localhost:8000/this/does/not/exist.js' &&
url != 'http://localhost:8000/api/user/me/' && url != 'http://localhost:8000/api/user/me/' &&
url != 'http://localhost:8000/api/user/token/' && 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 != 'http://localhost:8000/api/barcode/' &&
url != 'https://docs.inventree.org/en/versions.json' && url != 'https://docs.inventree.org/en/versions.json' &&
url != 'http://localhost:5173/favicon.ico' && url != 'http://localhost:5173/favicon.ico' &&

View File

@ -32,10 +32,10 @@ export const doQuickLogin = async (
password = password ?? user.password; password = password ?? user.password;
url = url ?? baseUrl; 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.waitForURL('**/platform/home');
await page.getByLabel('navigation-menu').waitFor(); await page.getByLabel('navigation-menu').waitFor({ timeout: 5000 });
await page.getByText(/InvenTree Demo Server -/).waitFor(); await page.getByText(/InvenTree Demo Server -/).waitFor();
// Wait for the dashboard to load // Wait for the dashboard to load

View File

@ -87,6 +87,7 @@ test('Login - Change Password', async ({ page }) => {
await page.getByLabel('action-menu-user-actions-change-password').click(); await page.getByLabel('action-menu-user-actions-change-password').click();
// First attempt with some errors // 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-1').fill('12345');
await page.getByLabel('input-password-2').fill('54321'); await page.getByLabel('input-password-2').fill('54321');
await page.getByRole('button', { name: 'Confirm' }).click(); 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('Password Changed').waitFor();
await page.getByText('The password was set successfully').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); await page.waitForTimeout(1000);
}); });

View File

@ -181,7 +181,6 @@ def content_excludes(
'exchange.exchangebackend', 'exchange.exchangebackend',
'common.notificationentry', 'common.notificationentry',
'common.notificationmessage', 'common.notificationmessage',
'user_sessions.session',
'importer.dataimportsession', 'importer.dataimportsession',
'importer.dataimportcolumnmap', 'importer.dataimportcolumnmap',
'importer.dataimportrow', 'importer.dataimportrow',
@ -1226,7 +1225,7 @@ def schema(
info(f"Exporting schema file to '{filename}'") 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: if not ignore_warnings:
cmd += ' --fail-on-warn' cmd += ' --fail-on-warn'