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

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

View File

@ -1,13 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 313
INVENTREE_API_VERSION = 314
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v314 - 2025-02-17 : https://github.com/inventree/InvenTree/pull/6293
- Removes a considerable amount of old auth endpoints
- Introduces allauth-provided auth endpoints
v313 - 2025-02-17 : https://github.com/inventree/InvenTree/pull/9087
- Adds instance id optionally to the info view endpoint

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."""
from urllib.parse import urlencode
from django import forms
from django.conf import settings
from django.contrib.auth.models import Group
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -12,20 +11,15 @@ from django.utils.translation import gettext_lazy as _
import structlog
from allauth.account.adapter import DefaultAccountAdapter
from allauth.account.forms import LoginForm, SignupForm, set_form_field_order
from allauth.core.exceptions import ImmediateHttpResponse
from allauth.headless.adapter import DefaultHeadlessAdapter
from allauth.headless.tokens.sessions import SessionTokenStrategy
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth_2fa.adapter import OTPAdapter
from allauth_2fa.forms import TOTPDeviceForm
from allauth_2fa.utils import user_has_valid_totp_device
from dj_rest_auth.registration.serializers import (
RegisterSerializer as DjRestRegisterSerializer,
)
from rest_framework import serializers
import InvenTree.helpers_model
import InvenTree.sso
from common.settings import get_global_setting
from InvenTree.exceptions import log_error
from users.models import ApiToken
logger = structlog.get_logger('inventree')
@ -92,16 +86,6 @@ class CustomSignupForm(SignupForm):
return cleaned_data
class CustomTOTPDeviceForm(TOTPDeviceForm):
"""Ensure that db registration is enabled."""
def __init__(self, user, metadata=None, **kwargs):
"""Override to check if registration is open."""
if not settings.MFA_ENABLED:
raise forms.ValidationError(_('MFA Registration is disabled.'))
super().__init__(user, metadata, **kwargs)
def registration_enabled():
"""Determine whether user registration is enabled."""
if (
@ -177,19 +161,7 @@ class RegistrationMixin:
return user
class CustomUrlMixin:
"""Mixin to set urls."""
def get_email_confirmation_url(self, request, emailconfirmation):
"""Custom email confirmation (activation) url."""
url = reverse('account_confirm_email', args=[emailconfirmation.key])
return InvenTree.helpers_model.construct_absolute_url(url)
class CustomAccountAdapter(
CustomUrlMixin, RegistrationMixin, OTPAdapter, DefaultAccountAdapter
):
class CustomAccountAdapter(RegistrationMixin, DefaultAccountAdapter):
"""Override of adapter to use dynamic settings."""
def send_mail(self, template_prefix, email, context):
@ -207,16 +179,14 @@ class CustomAccountAdapter(
return False
def get_email_confirmation_url(self, request, emailconfirmation):
"""Construct the email confirmation url."""
url = super().get_email_confirmation_url(request, emailconfirmation)
url = InvenTree.helpers_model.construct_absolute_url(url)
return url
def send_password_reset_mail(self, user, email, context):
"""Send the password reset mail."""
if not get_global_setting('LOGIN_ENABLE_PWD_FORGOT'):
raise PermissionDenied('Password reset is disabled')
return super().send_password_reset_mail(user, email, context)
class CustomSocialAccountAdapter(
CustomUrlMixin, RegistrationMixin, DefaultSocialAccountAdapter
):
class CustomSocialAccountAdapter(RegistrationMixin, DefaultSocialAccountAdapter):
"""Override of adapter to use dynamic settings."""
def is_auto_signup_allowed(self, request, sociallogin):
@ -225,29 +195,6 @@ class CustomSocialAccountAdapter(
return super().is_auto_signup_allowed(request, sociallogin)
return False
# from OTPAdapter
def has_2fa_enabled(self, user):
"""Returns True if the user has 2FA configured."""
return user_has_valid_totp_device(user)
def login(self, request, user):
"""Ensure user is send to 2FA before login if enabled."""
# Require two-factor authentication if it has been configured.
if self.has_2fa_enabled(user):
# Cast to string for the case when this is not a JSON serializable
# object, e.g. a UUID.
request.session['allauth_2fa_user_id'] = str(user.id)
redirect_url = reverse('two-factor-authenticate')
# Add GET parameters to the URL if they exist.
if request.GET:
redirect_url += '?' + urlencode(request.GET)
raise ImmediateHttpResponse(response=HttpResponseRedirect(redirect_url))
# Otherwise defer to the original allauth adapter.
return super().login(request, user)
def authentication_error(
self, request, provider_id, error=None, exception=None, extra_context=None
):
@ -264,15 +211,37 @@ class CustomSocialAccountAdapter(
log_error(path, error_name=error, error_data=exception)
logger.error("SSO error for provider '%s' - check admin error log", provider_id)
def get_connect_redirect_url(self, request, socialaccount):
"""Redirect to the frontend after connecting an account."""
return request.build_absolute_uri(f'/{settings.FRONTEND_URL_BASE}/')
# override dj-rest-auth
class RegisterSerializer(DjRestRegisterSerializer):
"""Registration requires email, password (twice) and username."""
email = serializers.EmailField()
class CustomHeadlessAdapter(DefaultHeadlessAdapter):
"""Override of adapter to use dynamic settings."""
def save(self, request):
"""Override to check if registration is open."""
if registration_enabled():
return super().save(request)
raise forms.ValidationError(_('Registration is disabled.'))
def get_frontend_url(self, urlname, **kwargs):
"""Get the frontend URL for the given URL name respecting the request."""
HEADLESS_FRONTEND_URLS = {
'account_confirm_email': 'verify-email/{key}',
'account_reset_password': 'reset-password',
'account_reset_password_from_key': 'set-password?key={key}',
'account_signup': 'register',
'socialaccount_login_error': 'social-login-error',
}
if urlname not in HEADLESS_FRONTEND_URLS:
raise ValueError(
f'URL name "{urlname}" not found in HEADLESS_FRONTEND_URLS'
)
return self.request.build_absolute_uri(
f'/{settings.FRONTEND_URL_BASE}/{HEADLESS_FRONTEND_URLS[urlname].format(**kwargs)}'
)
class DRFTokenStrategy(SessionTokenStrategy):
"""Strategy that InvenTrees own included Token model."""
def create_access_token(self, request):
"""Create a new access token for the user."""
token, _ = ApiToken.objects.get_or_create(user=request.user)
return token.key

View File

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

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.http import HttpResponse
from django.shortcuts import redirect
from django.urls import Resolver404, include, path, resolve, reverse_lazy
from django.urls import resolve, reverse_lazy
from django.utils.deprecation import MiddlewareMixin
import structlog
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
from error_report.middleware import ExceptionProcessor
from common.settings import get_global_setting
from InvenTree.AllUserRequire2FAMiddleware import AllUserRequire2FAMiddleware
from InvenTree.cache import create_session_cache, delete_session_cache
from InvenTree.urls import frontendpatterns
from users.models import ApiToken
logger = structlog.get_logger('inventree')
@ -137,32 +136,12 @@ class AuthRequiredMiddleware:
return response
url_matcher = path('', include(frontendpatterns))
class Check2FAMiddleware(AllUserRequire2FAMiddleware):
"""Ensure that mfa is enforced if set so."""
class Check2FAMiddleware(BaseRequire2FAMiddleware):
"""Check if user is required to have MFA enabled."""
def require_2fa(self, request):
"""Use setting to check if MFA should be enforced for frontend page."""
try:
if url_matcher.resolve(request.path[1:]):
def enforce_2fa(self, request):
"""Use setting to check if MFA should be enforced."""
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):

View File

@ -18,7 +18,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import django.conf.locale
import django.core.exceptions
from django.core.validators import URLValidator
from django.http import Http404
from django.http import Http404, HttpResponseGone
import structlog
from dotenv import load_dotenv
@ -63,7 +63,6 @@ CONFIG = config.load_config_data(set_cache=True)
# Load VERSION data if it exists
version_file = BASE_DIR.parent.parent.parent.joinpath('VERSION')
if version_file.exists():
print('load version from file')
load_dotenv(version_file)
# Default action is to run the system in Debug mode
@ -266,7 +265,8 @@ INSTALLED_APPS = [
# Core django modules
'django.contrib.auth',
'django.contrib.contenttypes',
'user_sessions', # db user sessions
'django.contrib.sessions',
'django.contrib.humanize',
'whitenoise.runserver_nostatic',
'django.contrib.messages',
'django.contrib.staticfiles',
@ -290,13 +290,13 @@ INSTALLED_APPS = [
'django_structlog', # Structured logging
'allauth', # Base app for SSO
'allauth.account', # Extend user with accounts
'allauth.headless', # APIs for auth
'allauth.socialaccount', # Use 'social' providers
'allauth.mfa', # MFA for for allauth
'allauth.usersessions', # DB sessions
'django_otp', # OTP is needed for MFA - base package
'django_otp.plugins.otp_totp', # Time based OTP
'django_otp.plugins.otp_static', # Backup codes
'allauth_2fa', # MFA flow for allauth
'dj_rest_auth', # Authentication APIs - dj-rest-auth
'dj_rest_auth.registration', # Registration APIs - dj-rest-auth'
'drf_spectacular', # API documentation
'django_ical', # For exporting calendars
]
@ -306,7 +306,8 @@ MIDDLEWARE = CONFIG.get(
[
'django.middleware.security.SecurityMiddleware',
'x_forwarded_for.middleware.XForwardedForMiddleware',
'user_sessions.middleware.SessionMiddleware', # db user sessions
'django.contrib.sessions.middleware.SessionMiddleware',
'allauth.usersessions.middleware.UserSessionsMiddleware', # DB user sessions
'django.middleware.locale.LocaleMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'corsheaders.middleware.CorsMiddleware',
@ -314,8 +315,6 @@ MIDDLEWARE = CONFIG.get(
'django.middleware.common.CommonMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'InvenTree.middleware.InvenTreeRemoteUserMiddleware', # Remote / proxy auth
'django_otp.middleware.OTPMiddleware', # MFA support
'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth
'allauth.account.middleware.AccountMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
@ -548,31 +547,13 @@ if DEBUG:
'rest_framework.renderers.BrowsableAPIRenderer'
)
# JWT switch
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
REST_USE_JWT = USE_JWT
# dj-rest-auth
REST_AUTH = {
'SESSION_LOGIN': True,
'TOKEN_MODEL': 'users.models.ApiToken',
'TOKEN_CREATOR': 'users.models.default_create_token',
'USE_JWT': USE_JWT,
'REGISTER_SERIALIZER': 'InvenTree.auth_overrides.RegisterSerializer',
}
OLD_PASSWORD_FIELD_ENABLED = True
# JWT settings - rest_framework_simplejwt
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
if USE_JWT:
JWT_AUTH_COOKIE = 'inventree-auth'
JWT_AUTH_REFRESH_COOKIE = 'inventree-token'
REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'].append(
'dj_rest_auth.jwt_auth.JWTCookieAuthentication'
)
INSTALLED_APPS.append('rest_framework_simplejwt')
# WSGI default setting
WSGI_APPLICATION = 'InvenTree.wsgi.application'
@ -911,13 +892,8 @@ if GLOBAL_CACHE_ENABLED: # pragma: no cover
# as well
Q_CLUSTER['django_redis'] = 'worker'
# database user sessions
SESSION_ENGINE = 'user_sessions.backends.db'
LOGOUT_REDIRECT_URL = get_setting(
'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index'
)
SILENCED_SYSTEM_CHECKS = ['admin.E410', 'templates.E003', 'templates.W003']
SILENCED_SYSTEM_CHECKS = ['templates.E003', 'templates.W003']
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
@ -1192,6 +1168,7 @@ USE_X_FORWARDED_PORT = get_boolean_setting(
# Refer to the django-cors-headers documentation for more information
# Ref: https://github.com/adamchainz/django-cors-headers
# Extract CORS options from configuration file
CORS_ALLOW_ALL_ORIGINS = get_boolean_setting(
'INVENTREE_CORS_ORIGIN_ALLOW_ALL', config_key='cors.allow_all', default_value=DEBUG
@ -1238,6 +1215,11 @@ else:
if CORS_ALLOWED_ORIGIN_REGEXES:
logger.info('CORS: Whitelisted origin regexes: %s', CORS_ALLOWED_ORIGIN_REGEXES)
# Load settings for the frontend interface
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url']
# region auth
for app in SOCIAL_BACKENDS:
# Ensure that the app starts with 'allauth.socialaccount.providers'
social_prefix = 'allauth.socialaccount.providers.'
@ -1262,6 +1244,9 @@ SOCIALACCOUNT_OPENID_CONNECT_URL_PREFIX = ''
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = get_setting(
'INVENTREE_LOGIN_CONFIRM_DAYS', 'login_confirm_days', 3, typecast=int
)
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
ACCOUNT_EMAIL_NOTIFICATIONS = True
USERSESSIONS_TRACK_ACTIVITY = True
# allauth rate limiting: https://docs.allauth.org/en/latest/account/rate_limits.html
# The default login rate limit is "5/m/user,5/m/ip,5/m/key"
@ -1306,12 +1291,27 @@ ACCOUNT_FORMS = {
'disconnect': 'allauth.socialaccount.forms.DisconnectForm',
}
ALLAUTH_2FA_FORMS = {'setup': 'InvenTree.auth_overrides.CustomTOTPDeviceForm'}
# Determine if multi-factor authentication is enabled for this server (default = True)
MFA_ENABLED = get_boolean_setting('INVENTREE_MFA_ENABLED', 'mfa_enabled', True)
SOCIALACCOUNT_ADAPTER = 'InvenTree.auth_overrides.CustomSocialAccountAdapter'
ACCOUNT_ADAPTER = 'InvenTree.auth_overrides.CustomAccountAdapter'
HEADLESS_ADAPTER = 'InvenTree.auth_overrides.CustomHeadlessAdapter'
ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = True
HEADLESS_ONLY = True
HEADLESS_TOKEN_STRATEGY = 'InvenTree.auth_overrides.DRFTokenStrategy'
MFA_ENABLED = get_boolean_setting(
'INVENTREE_MFA_ENABLED', 'mfa_enabled', True
) # TODO re-implement
MFA_SUPPORTED_TYPES = get_setting(
'INVENTREE_MFA_SUPPORTED_TYPES',
'mfa_supported_types',
['totp', 'recovery_codes'],
typecast=list,
)
LOGOUT_REDIRECT_URL = get_setting(
'INVENTREE_LOGOUT_REDIRECT_URL', 'logout_redirect_url', 'index'
)
# endregion auth
# Markdownify configuration
# Ref: https://django-markdownify.readthedocs.io/en/latest/settings.html
@ -1354,7 +1354,7 @@ MARKDOWNIFY = {
}
# Ignore these error types for in-database error logging
IGNORED_ERRORS = [Http404, django.core.exceptions.PermissionDenied]
IGNORED_ERRORS = [Http404, HttpResponseGone, django.core.exceptions.PermissionDenied]
# Maintenance mode
MAINTENANCE_MODE_RETRY_AFTER = 10
@ -1378,10 +1378,6 @@ CUSTOMIZE = get_setting(
'INVENTREE_CUSTOMIZE', 'customize', default_value=None, typecast=dict
)
# Load settings for the frontend interface
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url']
if DEBUG:
logger.info('InvenTree running with DEBUG enabled')

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

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.generic.base import RedirectView
from dj_rest_auth.registration.views import (
ConfirmEmailView,
SocialAccountDisconnectView,
SocialAccountListView,
)
from allauth.headless.urls import Client, build_urlpatterns
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from sesame.views import LoginView
@ -29,7 +25,6 @@ import plugin.api
import report.api
import stock.api
import users.api
from InvenTree.auth_override_views import CustomRegisterView
from plugin.urls import get_plugin_urls
from web.urls import urlpatterns as platform_urls
@ -42,14 +37,6 @@ from .api import (
VersionView,
)
from .magic_login import GetSimpleLoginView
from .social_auth_urls import (
EmailListView,
EmailPrimaryView,
EmailRemoveView,
EmailVerifyView,
SocialProviderListView,
get_provider_urls,
)
from .views import auth_request
admin.site.site_header = 'InvenTree Admin'
@ -107,59 +94,17 @@ apipatterns = [
path(
'auth/',
include([
re_path(
r'^registration/account-confirm-email/(?P<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(
'login-redirect/',
users.api.LoginRedirect.as_view(),
name='api-login-redirect',
),
path('', include('dj_rest_auth.urls')),
path(
'',
include(
(build_urlpatterns(Client.BROWSER), 'headless'), namespace='browser'
),
), # Allauth headless logic (only the browser client is included as we only use sessions based auth there)
]),
),
# Magic login URLs
@ -175,8 +120,16 @@ apipatterns = [
backendpatterns = [
path('auth/', include('rest_framework.urls', namespace='rest_framework')),
path('auth/', auth_request),
path(
'auth/', include('rest_framework.urls', namespace='rest_framework')
), # Used for (DRF) browsable API auth
path('auth/', auth_request), # Used for proxies to check if user is authenticated
path('accounts/', include('allauth.urls')),
path(
'accounts/login/',
RedirectView.as_view(url=f'/{settings.FRONTEND_URL_BASE}', permanent=False),
name='account_login',
), # Add a redirect for login views
path('api/', include(apipatterns)),
path('api-doc/', SpectacularRedocView.as_view(url_name='schema'), name='api-doc'),
]
@ -193,19 +146,7 @@ if settings.INVENTREE_ADMIN_ENABLED:
]
urlpatterns += backendpatterns
frontendpatterns = [
*platform_urls,
# Add a redirect for login views
path(
'accounts/login/',
RedirectView.as_view(url=f'/{settings.FRONTEND_URL_BASE}', permanent=False),
name='account_login',
),
path('accounts/', include('allauth_2fa.urls')), # MFA support
]
urlpatterns += frontendpatterns
urlpatterns += platform_urls
# Append custom plugin URLs (if custom plugin support is enabled)
if settings.PLUGINS_ENABLED:

View File

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

View File

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

View File

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

View File

@ -9,15 +9,5 @@
{% block content %}
<h3>{% trans "Authentication Failure" %}</h3>
<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>
{% trans "You have been logged out from InvenTree." %}
{% endblock content %}

View File

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

View File

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

View File

@ -11,61 +11,9 @@
{% trans 'Site is in Maintenance' %}
{% endblock page_title %}
{% block body_class %}login-screen{% endblock body_class %}
{% block body %}
<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" 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>
<h3>{% block body_title %}{% trans 'Site is in Maintenance' %}{% endblock body_title %}</h3>
{% block content %}
{% trans 'The site is currently in maintenance and should be up again soon!' %}
{% endblock content %}
{% 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" %}
<!-- CSS -->
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
<title>
{% block page_title %}
{% endblock page_title %}
</title>
</head>
<body class='{% block body_class %}{% endblock body_class %}'>
<body>
{% block body %}
{% endblock body %}
</body>
</html>

View File

@ -2,22 +2,14 @@
import datetime
from django.contrib.auth import authenticate, get_user, login, logout
from django.contrib.auth import get_user, login
from django.contrib.auth.models import Group, User
from django.http.response import HttpResponse
from django.shortcuts import redirect
from django.urls import include, path, re_path, reverse
from django.urls import include, path, re_path
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic.base import RedirectView
import structlog
from allauth.account import app_settings
from allauth.account.adapter import get_adapter
from allauth_2fa.utils import user_has_valid_totp_device
from dj_rest_auth.views import LoginView, LogoutView
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
from rest_framework import exceptions, permissions
from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import authentication_classes
from rest_framework.generics import DestroyAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@ -25,7 +17,6 @@ from rest_framework.views import APIView
import InvenTree.helpers
import InvenTree.permissions
from common.models import InvenTreeSetting
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import (
ListAPI,
@ -224,96 +215,6 @@ class GroupList(GroupMixin, ListCreateAPI):
ordering_fields = ['name']
@authentication_classes([BasicAuthentication])
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged in')}
)
)
class Login(LoginView):
"""API view for logging in via API."""
def post(self, request, *args, **kwargs):
"""API view for logging in via API."""
_data = request.data.copy()
_data.update(request.POST.copy())
if not _data.get('mfa', None):
logger.info('No MFA requested - Proceeding')
return super().post(request, *args, **kwargs)
# Check if login credentials valid
user = authenticate(
request, username=_data.get('username'), password=_data.get('password')
)
if user is None:
logger.info('Invalid login - Aborting')
return HttpResponse(status=401)
# Check if user has mfa set up
if not user_has_valid_totp_device(user):
logger.info('No MFA set up - Proceeding')
return super().post(request, *args, **kwargs)
# Stage login and redirect to 2fa
request.session['allauth_2fa_user_id'] = str(user.id)
request.session['allauth_2fa_login'] = {
'email_verification': app_settings.EMAIL_VERIFICATION,
'signal_kwargs': None,
'signup': False,
'email': None,
'redirect_url': reverse('platform'),
}
logger.info('Redirecting to 2fa - Proceeding')
return redirect(reverse('two-factor-authenticate'))
def process_login(self):
"""Process the login request, ensure that MFA is enforced if required."""
# Normal login process
ret = super().process_login()
user = self.request.user
adapter = get_adapter(self.request)
# User requires 2FA or MFA is enforced globally - no logins via API
if adapter.has_2fa_enabled(user) or InvenTreeSetting.get_setting(
'LOGIN_ENFORCE_MFA'
):
logout(self.request)
logger.info('User was logged out because MFA is required - Aborting')
raise exceptions.PermissionDenied('MFA required for this user')
return ret
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged out')}
)
)
class Logout(LogoutView):
"""API view for logging out via API."""
serializer_class = None
def post(self, request):
"""Logout the current user.
Deletes user token associated with request.
"""
from InvenTree.middleware import get_token_from_request
if request.user:
token_key = get_token_from_request(request)
if token_key:
try:
token = ApiToken.objects.get(key=token_key, user=request.user)
token.delete()
except ApiToken.DoesNotExist: # pragma: no cover
pass
return super().logout(request)
class GetAuthToken(APIView):
"""Return authentication token for an authenticated user."""
@ -396,7 +297,7 @@ class LoginRedirect(RedirectView):
user_urls = [
path('roles/', RoleDetails.as_view(), name='api-user-roles'),
path('token/', GetAuthToken.as_view(), name='api-token'),
path('token/', ensure_csrf_cookie(GetAuthToken.as_view()), name='api-token'),
path(
'tokens/',
include([

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_static_statictoken',
'otp_static_staticdevice',
'mfa_authenticator',
'plugin_pluginconfig',
'plugin_pluginsetting',
'plugin_notificationusersetting',
@ -353,7 +354,8 @@ class RuleSet(models.Model):
'error_report_error',
'exchange_rate',
'exchange_exchangebackend',
'user_sessions_session',
'usersessions_usersession',
'sessions_session',
# Django-q
'django_q_ormq',
'django_q_failure',

View File

@ -110,15 +110,6 @@ class UserAPITests(InvenTreeAPITestCase):
self.assertIn('name', response.data)
self.assertIn('permissions', response.data)
def test_logout(self):
"""Test api logout endpoint."""
token_key = self.get(url=reverse('api-token')).data['token']
self.client.logout()
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token_key)
self.post(reverse('api-logout'), expected_code=200)
self.get(reverse('api-token'), expected_code=401)
def test_login_redirect(self):
"""Test login redirect endpoint."""
response = self.get(reverse('api-login-redirect'), expected_code=302)
@ -233,16 +224,6 @@ class UserTokenTests(InvenTreeAPITestCase):
self.client.get(me, expected_code=200)
def test_builtin_token(self):
"""Test the built-in token authentication."""
response = self.post(
reverse('rest_login'),
{'username': self.username, 'password': self.password},
expected_code=200,
)
self.assertIn('key', response.data)
self.assertTrue(response.data['key'].startswith('inv-'))
def test_token_api(self):
"""Test the token API."""
url = reverse('api-token-list')

View File

@ -24,3 +24,36 @@ class TestForwardMigrations(MigratorTestCase):
User = self.new_state.apps.get_model('auth', 'user')
self.assertEqual(User.objects.count(), 2)
class MFAMigrations(MigratorTestCase):
"""Test entire schema migration sequence for the users app."""
migrate_from = ('users', '0012_alter_ruleset_can_view')
migrate_to = ('users', '0013_migrate_mfa_20240408_1659')
def prepare(self):
"""Setup the initial state of the database before migrations."""
User = self.old_state.apps.get_model('auth', 'user')
TOTPDevice = self.old_state.apps.get_model('otp_totp', 'TOTPDevice')
StaticDevice = self.old_state.apps.get_model('otp_static', 'StaticDevice')
abc = User.objects.create(
username='fred', email='fred@fred.com', password='password'
)
TOTPDevice.objects.create(user=abc, confirmed=True, key='1234')
abc1 = User.objects.create(
username='brad', email='brad@fred.com', password='password'
)
TOTPDevice.objects.create(user=abc1, confirmed=False, key='1234')
StaticDevice.objects.create(user=abc1, confirmed=True)
def test_users_exist(self):
"""Test that users exist in the database."""
User = self.new_state.apps.get_model('auth', 'user')
Authenticator = self.new_state.apps.get_model('mfa', 'Authenticator')
self.assertEqual(User.objects.count(), 2)
# 2 Tokens - both for user 1
self.assertEqual(Authenticator.objects.count(), 2)
self.assertEqual([1, 1], [i.user_id for i in Authenticator.objects.all()])

View File

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

View File

@ -18,7 +18,7 @@ class RedirectAssetView(TemplateView):
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 = [
@ -31,7 +31,7 @@ urlpatterns = [
spa_view,
name='password_reset_confirm',
),
re_path('.*', spa_view),
re_path('.*', spa_view, name='platform-wildcard'),
]),
),
assets_path,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,16 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
import axios from 'axios';
import type { NavigateFunction } from 'react-router-dom';
import type { AxiosRequestConfig } from 'axios';
import type { Location, NavigateFunction } from 'react-router-dom';
import { api, setApiDefaults } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState';
import { apiUrl, useServerApiState } from '../states/ApiState';
import { useLocalState } from '../states/LocalState';
import { useUserState } from '../states/UserState';
import { fetchGlobalStates } from '../states/states';
import { type Provider, fetchGlobalStates } from '../states/states';
import { showLoginNotification } from './notifications';
import { generateUrl } from './urls';
export function followRedirect(navigate: NavigateFunction, redirect: any) {
let url = redirect?.redirectUrl ?? '/home';
@ -59,24 +60,29 @@ function post(path: string, params: any, method = 'post') {
* If login is successful, an API token will be returned.
* This API token is used for any future API requests.
*/
export const doBasicLogin = async (username: string, password: string) => {
export const doBasicLogin = async (
username: string,
password: string,
navigate: NavigateFunction
) => {
const { host } = useLocalState.getState();
const { clearUserState, setToken, fetchUserState } = useUserState.getState();
const { setAuthContext } = useServerApiState.getState();
if (username.length == 0 || password.length == 0) {
return;
}
clearCsrfCookie();
await ensureCsrf();
const login_url = apiUrl(ApiEndpoints.user_login);
let result = false;
let loginDone = false;
let success = false;
// Attempt login with
await api
.post(
login_url,
apiUrl(ApiEndpoints.auth_login),
{
username: username,
password: password
@ -86,33 +92,41 @@ export const doBasicLogin = async (username: string, password: string) => {
}
)
.then((response) => {
if (response.status == 200) {
if (response.data.key) {
setToken(response.data.key);
result = true;
}
setAuthContext(response.data?.data);
if (response.status == 200 && response.data?.meta?.is_authenticated) {
setToken(response.data.meta.access_token);
loginDone = true;
success = true;
}
})
.catch((err) => {
if (
err?.response?.status == 403 &&
err?.response?.data?.detail == 'MFA required for this user'
) {
post(apiUrl(ApiEndpoints.user_login), {
username: username,
password: password,
csrfmiddlewaretoken: getCsrfCookie(),
mfa: true
if (err?.response?.status == 401) {
setAuthContext(err.response.data?.data);
const mfa_flow = err.response.data.data.flows.find(
(flow: any) => flow.id == 'mfa_authenticate'
);
if (mfa_flow && mfa_flow.is_pending == true) {
success = true;
navigate('/mfa');
}
} else if (err?.response?.status == 409) {
notifications.show({
title: t`Already logged in`,
message: t`There is a conflicting session on the server for this browser. Please logout of that first.`,
color: 'red',
autoClose: false
});
}
});
if (result) {
if (loginDone) {
await fetchUserState();
fetchGlobalStates();
} else {
// see if mfa registration is required
await fetchGlobalStates(navigate);
} else if (!success) {
clearUserState();
}
return success;
};
/**
@ -125,8 +139,9 @@ export const doLogout = async (navigate: NavigateFunction) => {
// Logout from the server session
if (isLoggedIn() || !!getCsrfCookie()) {
await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => {});
await authApi(apiUrl(ApiEndpoints.auth_session), undefined, 'delete').catch(
() => {}
);
showLoginNotification({
title: t`Logged Out`,
message: t`Successfully logged out`
@ -158,12 +173,19 @@ export const doSimpleLogin = async (email: string) => {
return mail;
};
export function handleReset(navigate: any, values: { email: string }) {
api
.post(apiUrl(ApiEndpoints.user_reset), values, {
headers: { Authorization: '' }
})
.then((val) => {
export async function ensureCsrf() {
const cookie = getCsrfCookie();
if (cookie == undefined) {
await api.get(apiUrl(ApiEndpoints.user_token)).catch(() => {});
}
}
export function handleReset(
navigate: NavigateFunction,
values: { email: string }
) {
ensureCsrf();
api.post(apiUrl(ApiEndpoints.user_reset), values).then((val) => {
if (val.status === 200) {
notifications.show({
title: t`Mail delivery successful`,
@ -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.
*
@ -209,7 +268,7 @@ export const checkLoginState = async (
message: t`Successfully logged in`
});
fetchGlobalStates();
fetchGlobalStates(navigate);
followRedirect(navigate, redirect);
};
@ -257,3 +316,45 @@ export function clearCsrfCookie() {
document.cookie =
'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
}
export async function ProviderLogin(
provider: Provider,
process: 'login' | 'connect' = 'login'
) {
await ensureCsrf();
post(generateUrl(apiUrl(ApiEndpoints.auth_provider_redirect)), {
provider: provider.id,
callback_url: generateUrl('/logged-in'),
process: process,
csrfmiddlewaretoken: getCsrfCookie()
});
}
/**
* Makes an API request with session tokens using the provided URL, configuration, method, and data.
*
* @param url - The URL to which the request is sent.
* @param config - Optional Axios request configuration.
* @param method - The HTTP method to use for the request. Defaults to 'get'.
* @param data - Optional data to be sent with the request.
* @returns A promise that resolves to the response of the API request.
*/
export function authApi(
url: string,
config: AxiosRequestConfig | undefined = undefined,
method: 'get' | 'post' | 'put' | 'delete' = 'get',
data?: any
) {
const requestConfig = config || {};
// set method
requestConfig.method = method;
// set data
if (data) {
requestConfig.data = data;
}
// use normal api
return api(url, requestConfig);
}

View File

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

View File

@ -47,6 +47,14 @@ export default function Login() {
const location = useLocation();
const [searchParams] = useSearchParams();
useEffect(() => {
if (location.pathname === '/register') {
setMode.close();
} else {
setMode.open();
}
}, [location]);
const LoginMessage = useMemo(() => {
const val = server.customize?.login_message;
if (val) {
@ -95,7 +103,8 @@ export default function Login() {
if (searchParams.has('login') && searchParams.has('password')) {
doBasicLogin(
searchParams.get('login') ?? '',
searchParams.get('password') ?? ''
searchParams.get('password') ?? '',
navigate
).then(() => {
followRedirect(navigate, location?.state);
});
@ -135,7 +144,10 @@ export default function Login() {
</StylishText>
<Divider p='xs' />
{loginMode ? <AuthenticationForm /> : <RegistrationForm />}
<ModeSelector loginMode={loginMode} setMode={setMode} />
<ModeSelector
loginMode={loginMode}
changePage={(newPage) => navigate(`/${newPage}`)}
/>
{LoginMessage}
</Paper>
<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 navigate = useNavigate();
const token = searchParams.get('token');
const uid = searchParams.get('uid');
const key = searchParams.get('key');
function invalidToken() {
function invalidKey() {
notifications.show({
title: t`Token invalid`,
message: t`You need to provide a valid token to set a new password. Check your inbox for a reset link.`,
title: t`Key invalid`,
message: t`You need to provide a valid key to set a new password. Check your inbox for a reset link.`,
color: 'red'
});
navigate('/login');
}
function passwordError(values: any) {
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) {
function success() {
notifications.show({
title: t`Password set`,
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
});
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 {
passwordError(val.data);
}
})
.catch((err) => {
if (
err.response?.status === 400 &&
err.response?.data?.token == 'Invalid value'
) {
invalidToken();
if (err.response?.status === 400) {
passwordError(err.response.data);
} else if (err.response?.status === 401) {
success();
} else {
passwordError(err.response.data);
}
@ -99,12 +99,12 @@ export default function ResetPassword() {
<PasswordInput
required
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')}
/>
</Stack>
<Button type='submit' onClick={handleSet}>
<Trans>Send Email</Trans>
<Trans>Send Password</Trans>
</Button>
</Stack>
</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,
Badge,
Button,
Code,
Grid,
Group,
Loader,
Modal,
Radio,
Stack,
Table,
Text,
TextInput,
Title,
Tooltip
Title
} from '@mantine/core';
import { IconAlertCircle, IconAt } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks';
import { hideNotification, showNotification } from '@mantine/notifications';
import {
IconAlertCircle,
IconAt,
IconExclamationCircle,
IconX
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { api, queryClient } from '../../../../App';
import { useMemo, useState } from 'react';
import { api } from '../../../../App';
import { YesNoButton } from '../../../../components/buttons/YesNoButton';
import { PlaceholderPill } from '../../../../components/items/Placeholder';
import { StylishText } from '../../../../components/items/StylishText';
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
import { apiUrl } from '../../../../states/ApiState';
import { useUserState } from '../../../../states/UserState';
import { ProviderLogin, authApi } from '../../../../functions/auth';
import { apiUrl, useServerApiState } from '../../../../states/ApiState';
import type { AuthConfig, Provider } from '../../../../states/states';
import { QrRegistrationForm } from './QrRegistrationForm';
import { useReauth } from './useConfirm';
export function SecurityContent() {
const [isSsoEnabled, setIsSsoEnabled] = useState<boolean>(false);
const [isMfaEnabled, setIsMfaEnabled] = useState<boolean>(false);
const { isLoading: isLoadingProvider, data: dataProvider } = useQuery({
queryKey: ['sso-providers'],
queryFn: () =>
api.get(apiUrl(ApiEndpoints.sso_providers)).then((res) => res.data)
});
// evaluate if security options are enabled
useEffect(() => {
if (dataProvider === undefined) return;
// check if SSO is enabled on the server
setIsSsoEnabled(dataProvider.sso_enabled || false);
// check if MFa is enabled
setIsMfaEnabled(dataProvider.mfa_required || false);
}, [dataProvider]);
const [auth_config, sso_enabled] = useServerApiState((state) => [
state.auth_config,
state.sso_enabled
]);
return (
<Stack>
<Title order={5}>
<Trans>Email</Trans>
<Trans>Email Addresses</Trans>
</Title>
<EmailContent />
<EmailSection />
<Title order={5}>
<Trans>Single Sign On Accounts</Trans>
<Trans>Single Sign On</Trans>
</Title>
{isSsoEnabled ? (
<SsoContent dataProvider={dataProvider} />
{sso_enabled() ? (
<ProviderSection auth_config={auth_config} />
) : (
<Alert
icon={<IconAlertCircle size='1rem' />}
@ -66,65 +62,42 @@ export function SecurityContent() {
</Alert>
)}
<Title order={5}>
<Trans>Multifactor</Trans>
<Trans>Multifactor authentication</Trans>
</Title>
{isLoadingProvider ? (
<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>
)}
</>
)}
<MfaSection />
<Title order={5}>
<Trans>Token</Trans>
<Trans>Access Tokens</Trans>
</Title>
<TokenContent />
<TokenSection />
</Stack>
);
}
function EmailContent() {
function EmailSection() {
const [value, setValue] = useState<string>('');
const [newEmailValue, setNewEmailValue] = useState('');
const [user] = useUserState((state) => [state.user]);
const { isLoading, data, refetch } = useQuery({
queryKey: ['emails'],
queryFn: () =>
api.get(apiUrl(ApiEndpoints.user_emails)).then((res) => res.data)
authApi(apiUrl(ApiEndpoints.auth_email)).then((res) => res.data.data)
});
const emailAvailable = useMemo(() => {
return data == undefined || data.length == 0;
}, [data]);
function runServerAction(url: ApiEndpoints) {
api
.post(apiUrl(url, undefined, { id: value }), {})
.then(() => {
function runServerAction(
action: 'post' | 'put' | 'delete' = 'post',
data?: any
) {
const vals: any = data || { email: value };
return authApi(
apiUrl(ApiEndpoints.auth_email),
undefined,
action,
vals
).then(() => {
refetch();
})
.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 />;
@ -132,6 +105,15 @@ function EmailContent() {
return (
<Grid>
<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
value={value}
onChange={setValue}
@ -139,19 +121,19 @@ function EmailContent() {
label={t`The following email addresses are associated with your account:`}
>
<Stack mt='xs'>
{data.map((link: any) => (
{data.map((email: any) => (
<Radio
key={link.id}
value={String(link.id)}
key={email.email}
value={String(email.email)}
label={
<Group justify='space-between'>
{link.email}
{link.primary && (
{email.email}
{email.primary && (
<Badge color='blue'>
<Trans>Primary</Trans>
</Badge>
)}
{link.verified ? (
{email.verified ? (
<Badge color='green'>
<Trans>Verified</Trans>
</Badge>
@ -166,6 +148,7 @@ function EmailContent() {
))}
</Stack>
</Radio.Group>
)}
</Grid.Col>
<Grid.Col span={6}>
<Stack>
@ -184,24 +167,44 @@ function EmailContent() {
<Grid.Col span={6}>
<Group>
<Button
onClick={() => runServerAction(ApiEndpoints.user_email_primary)}
onClick={() =>
runServerAction('post', { email: value, primary: true })
}
disabled={emailAvailable}
>
<Trans>Make Primary</Trans>
</Button>
<Button
onClick={() => runServerAction(ApiEndpoints.user_email_verify)}
onClick={() => runServerAction('put')}
disabled={emailAvailable}
>
<Trans>Re-send Verification</Trans>
</Button>
<Button
onClick={() => runServerAction(ApiEndpoints.user_email_remove)}
onClick={() => runServerAction('delete')}
disabled={emailAvailable}
>
<Trans>Remove</Trans>
</Button>
</Group>
</Grid.Col>
<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>
</Button>
</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 [currentProviders, setCurrentProviders] = useState<[]>();
const { isLoading, data } = useQuery({
queryKey: ['sso-list'],
const { isLoading, data, refetch } = useQuery({
queryKey: ['provider-list'],
queryFn: () =>
api.get(apiUrl(ApiEndpoints.user_sso)).then((res) => res.data)
authApi(apiUrl(ApiEndpoints.auth_providers)).then((res) => res.data.data)
});
useEffect(() => {
if (dataProvider === undefined) return;
if (data === undefined) return;
const availableProviders = useMemo(() => {
if (!auth_config || !data) return [];
const configuredProviders = data.map((item: any) => {
return item.provider;
});
function isAlreadyInUse(value: any) {
return !configuredProviders.includes(value.id);
}
// remove providers that are used currently
let newData = dataProvider.providers;
newData = newData.filter(isAlreadyInUse);
setCurrentProviders(newData);
}, [dataProvider, data]);
const configuredProviders = data.map((item: any) => item.provider.id);
return auth_config.socialaccount.providers.filter(
(provider: any) => !configuredProviders.includes(provider.id)
);
}, [auth_config, data]);
function removeProvider() {
api
.post(apiUrl(ApiEndpoints.user_sso_remove, undefined, { id: value }))
const [uid, provider] = value.split('$');
authApi(apiUrl(ApiEndpoints.auth_providers), undefined, 'delete', {
provider,
account: uid
})
.then(() => {
queryClient.removeQueries({
queryKey: ['sso-list']
});
refetch();
})
.catch((res) => console.log(res.data));
}
/* renderer */
if (isLoading) return <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 (
<Grid>
<Grid.Col span={6}>
@ -280,9 +266,7 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
title={t`Not configured`}
color='yellow'
>
<Trans>
There are no social network accounts connected to this account.{' '}
</Trans>
<Trans>There are no providers connected to this account.</Trans>
</Alert>
) : (
<Stack>
@ -290,20 +274,20 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
value={value}
onChange={setValue}
name='sso_accounts'
label={t`You can sign in to your account using any of the following third party accounts`}
label={t`You can sign in to your account using any of the following providers`}
>
<Stack mt='xs'>
{data.map((link: any) => (
<Radio
key={link.id}
value={String(link.id)}
label={link.provider}
key={link.uid}
value={[link.uid, link.provider.id].join('$')}
label={`${link.provider.name}: ${link.display}`}
/>
))}
</Stack>
</Radio.Group>
<Button onClick={removeProvider}>
<Trans>Remove</Trans>
<Trans>Remove Provider Link</Trans>
</Button>
</Stack>
)}
@ -311,33 +295,394 @@ function SsoContent({ dataProvider }: Readonly<{ dataProvider: any }>) {
<Grid.Col span={6}>
<Stack>
<Text>Add SSO Account</Text>
{availableProviders === undefined ? (
<Text>
{currentProviders === undefined ? (
<Trans>Loading</Trans>
</Text>
) : (
<Stack gap='xs'>
{currentProviders.map((provider: any) => (
{availableProviders.map((provider: any) => (
<ProviderButton key={provider.id} provider={provider} />
))}
</Stack>
)}
</Text>
</Stack>
</Grid.Col>
</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 (
<>
MFA Details
<PlaceholderPill />
<ReauthModal />
<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({
queryKey: ['token-list'],
queryFn: () =>
@ -352,8 +697,9 @@ function TokenContent() {
})
.catch((res) => console.log(res.data));
}
const rows = useMemo(() => {
if (isLoading || data === undefined) return null;
if (isLoading || !data) return null;
return data.map((token: any) => (
<Table.Tr key={token.id}>
<Table.Td>
@ -380,7 +726,6 @@ function TokenContent() {
));
}, [data, isLoading]);
/* renderer */
if (isLoading) return <Loader />;
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'))
);
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
export const MFALogin = Loadable(lazy(() => import('./pages/Auth/MFALogin')));
export const MFASetup = Loadable(lazy(() => import('./pages/Auth/MFASetup')));
export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset')));
@ -115,6 +117,9 @@ export const ChangePassword = Loadable(
export const ResetPassword = Loadable(
lazy(() => import('./pages/Auth/ResetPassword'))
);
export const VerifyEmail = Loadable(
lazy(() => import('./pages/Auth/VerifyEmail'))
);
// Routes
export const routes = (
@ -170,11 +175,15 @@ export const routes = (
</Route>
<Route path='/' errorElement={<ErrorPage />}>
<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='/logged-in' element={<Logged_In />} />
<Route path='/reset-password' element={<Reset />} />
<Route path='/set-password' element={<ResetPassword />} />
<Route path='/change-password' element={<ChangePassword />} />
<Route path='/verify-email/:key' element={<VerifyEmail />} />
</Route>
</Routes>
);

View File

@ -4,18 +4,31 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import { api } from '../App';
import { emptyServerAPI } from '../defaults/defaults';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import type { AuthProps, ServerAPIProps } from './states';
import type { AuthConfig, AuthContext, ServerAPIProps } from './states';
interface ServerApiStateProps {
server: ServerAPIProps;
setServer: (newServer: ServerAPIProps) => void;
fetchServerApiState: () => void;
auth_settings?: AuthProps;
auth_config?: AuthConfig;
auth_context?: AuthContext;
setAuthContext: (auth_context: AuthContext) => void;
sso_enabled: () => boolean;
registration_enabled: () => boolean;
sso_registration_enabled: () => boolean;
password_forgotten_enabled: () => boolean;
}
function get_server_setting(val: any) {
if (val === null || val === undefined) {
return false;
}
return val;
}
export const useServerApiState = create<ServerApiStateProps>()(
persist(
(set) => ({
(set, get) => ({
server: emptyServerAPI,
setServer: (newServer: ServerAPIProps) => set({ server: newServer }),
fetchServerApiState: async () => {
@ -31,17 +44,36 @@ export const useServerApiState = create<ServerApiStateProps>()(
// Fetch login/SSO behaviour
await api
.get(apiUrl(ApiEndpoints.sso_providers), {
.get(apiUrl(ApiEndpoints.auth_config), {
headers: { Authorization: '' }
})
.then((response) => {
set({ auth_settings: response.data });
set({ auth_config: response.data.data });
})
.catch(() => {
console.error('ERR: Error fetching SSO information');
});
},
status: undefined
auth_config: undefined,
auth_context: undefined,
setAuthContext(auth_context) {
set({ auth_context });
},
sso_enabled: () => {
const data = get().auth_config?.socialaccount.providers;
return !(data === undefined || data.length == 0);
},
registration_enabled: () => {
return get_server_setting(get().server?.settings?.registration_enabled);
},
sso_registration_enabled: () => {
return get_server_setting(get().server?.settings?.sso_registration);
},
password_forgotten_enabled: () => {
return get_server_setting(
get().server?.settings?.password_forgotten_enabled
);
}
}),
{
name: 'server-api-state',

View File

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

View File

@ -1,3 +1,4 @@
import type { NavigateFunction } from 'react-router-dom';
import { setApiDefaults } from '../App';
import { useServerApiState } from './ApiState';
import { useIconState } from './IconState';
@ -48,6 +49,11 @@ export interface ServerAPIProps {
target: null | string;
default_locale: null | string;
django_admin: null | string;
settings: {
sso_registration: null | boolean;
registration_enabled: null | boolean;
password_forgotten_enabled: null | boolean;
} | null;
customize: null | {
logo: string;
splash: string;
@ -56,22 +62,48 @@ export interface ServerAPIProps {
};
}
export interface AuthProps {
sso_enabled: boolean;
sso_registration: boolean;
mfa_required: boolean;
providers: Provider[];
registration_enabled: boolean;
password_forgotten_enabled: boolean;
export interface AuthContext {
status: number;
data: { flows: Flow[] };
meta: { is_authenticated: boolean };
}
export enum FlowEnum {
VerifyEmail = 'verify_email',
Login = 'login',
Signup = 'signup',
ProviderRedirect = 'provider_redirect',
ProviderSignup = 'provider_signup',
ProviderToken = 'provider_token',
MfaAuthenticate = 'mfa_authenticate',
Reauthenticate = 'reauthenticate',
MfaReauthenticate = 'mfa_reauthenticate'
}
export interface Flow {
id: FlowEnum;
providers?: string[];
is_pending?: boolean[];
}
export interface AuthConfig {
account: {
authentication_method: string;
};
socialaccount: { providers: Provider[] };
mfa: {
supported_types: string[];
};
usersessions: {
track_activity: boolean;
};
}
export interface Provider {
id: string;
name: string;
configured: boolean;
login: string;
connect: string;
display_name: string;
flows: string[];
client_id: string;
}
// Type interface defining a single 'setting' object
@ -134,7 +166,9 @@ export type SettingsLookup = {
* Refetch all global state information.
* Necessary on login, or if locale is changed.
*/
export function fetchGlobalStates() {
export async function fetchGlobalStates(
navigate?: NavigateFunction | undefined
) {
const { isLoggedIn } = useUserState.getState();
if (!isLoggedIn()) {
@ -144,7 +178,12 @@ export function fetchGlobalStates() {
setApiDefaults();
useServerApiState.getState().fetchServerApiState();
useUserSettingsState.getState().fetchSettings();
const result = await useUserSettingsState.getState().fetchSettings();
if (!result && navigate) {
console.log('MFA is required - setting up');
// call mfa setup
navigate('/mfa-setup');
}
useGlobalSettingsState.getState().fetchSettings();
useGlobalStatusState.getState().fetchStatus();
useIconState.getState().fetchIcons();

View File

@ -20,8 +20,12 @@ export default function MainView() {
const [allowMobile] = useLocalState((state) => [state.allowMobile]);
// Set initial login status
useEffect(() => {
try {
// Local state initialization
setApiDefaults();
} catch (e) {
console.error(e);
}
}, []);
// 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/api/user/me/' &&
url != 'http://localhost:8000/api/user/token/' &&
url != 'http://localhost:8000/api/auth/v1/auth/login' &&
url != 'http://localhost:8000/api/auth/v1/auth/session' &&
url != 'http://localhost:8000/api/auth/v1/account/password/change' &&
url != 'http://localhost:8000/api/barcode/' &&
url != 'https://docs.inventree.org/en/versions.json' &&
url != 'http://localhost:5173/favicon.ico' &&

View File

@ -32,10 +32,10 @@ export const doQuickLogin = async (
password = password ?? user.password;
url = url ?? baseUrl;
await navigate(page, `${url}/login/?login=${username}&password=${password}`);
await navigate(page, `${url}/login?login=${username}&password=${password}`);
await page.waitForURL('**/platform/home');
await page.getByLabel('navigation-menu').waitFor();
await page.getByLabel('navigation-menu').waitFor({ timeout: 5000 });
await page.getByText(/InvenTree Demo Server -/).waitFor();
// Wait for the dashboard to load

View File

@ -87,6 +87,7 @@ test('Login - Change Password', async ({ page }) => {
await page.getByLabel('action-menu-user-actions-change-password').click();
// First attempt with some errors
await page.getByLabel('password', { exact: true }).fill('youshallnotpass');
await page.getByLabel('input-password-1').fill('12345');
await page.getByLabel('input-password-2').fill('54321');
await page.getByRole('button', { name: 'Confirm' }).click();
@ -105,9 +106,5 @@ test('Login - Change Password', async ({ page }) => {
await page.getByText('Password Changed').waitFor();
await page.getByText('The password was set successfully').waitFor();
// Should have redirected to the index page
await page.waitForURL('**/platform/home**');
await page.getByText('InvenTree Demo Server - Norman Nothington');
await page.waitForTimeout(1000);
});

View File

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