mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-09 16:58: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:
parent
1f84f24514
commit
03278c56c9
docs/docs/start
src
backend
InvenTree
InvenTree
AllUserRequire2FAMiddleware.pyapi.pyapi_version.pyauth_override_views.pyauth_overrides.pymagic_login.py
config_template.yamlmanagement/commands
middleware.pysettings.pysocial_auth_urls.pystatic/css
test_auth.pyurls.pygeneric/states
templates
users
web
frontend
src
tests
@ -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
|
||||
|
||||
|
@ -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
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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)
|
||||
|
110
src/backend/InvenTree/InvenTree/management/commands/schema.py
Normal file
110
src/backend/InvenTree/InvenTree/management/commands/schema.py
Normal 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)
|
@ -6,16 +6,15 @@ from django.conf import settings
|
||||
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import Resolver404, include, path, resolve, reverse_lazy
|
||||
from django.urls import resolve, reverse_lazy
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
import structlog
|
||||
from allauth_2fa.middleware import AllauthTwoFactorMiddleware, BaseRequire2FAMiddleware
|
||||
from error_report.middleware import ExceptionProcessor
|
||||
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.AllUserRequire2FAMiddleware import AllUserRequire2FAMiddleware
|
||||
from InvenTree.cache import create_session_cache, delete_session_cache
|
||||
from InvenTree.urls import frontendpatterns
|
||||
from users.models import ApiToken
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
@ -137,32 +136,12 @@ class AuthRequiredMiddleware:
|
||||
return response
|
||||
|
||||
|
||||
url_matcher = path('', include(frontendpatterns))
|
||||
class Check2FAMiddleware(AllUserRequire2FAMiddleware):
|
||||
"""Ensure that mfa is enforced if set so."""
|
||||
|
||||
|
||||
class Check2FAMiddleware(BaseRequire2FAMiddleware):
|
||||
"""Check if user is required to have MFA enabled."""
|
||||
|
||||
def require_2fa(self, request):
|
||||
"""Use setting to check if MFA should be enforced for frontend page."""
|
||||
try:
|
||||
if url_matcher.resolve(request.path[1:]):
|
||||
return get_global_setting('LOGIN_ENFORCE_MFA')
|
||||
except Resolver404:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
|
||||
"""This function ensures only frontend code triggers the MFA auth cycle."""
|
||||
|
||||
def process_request(self, request):
|
||||
"""Check if requested url is frontend and enforce MFA check."""
|
||||
try:
|
||||
if not url_matcher.resolve(request.path[1:]):
|
||||
super().process_request(request)
|
||||
except Resolver404:
|
||||
pass
|
||||
def enforce_2fa(self, request):
|
||||
"""Use setting to check if MFA should be enforced."""
|
||||
return get_global_setting('LOGIN_ENFORCE_MFA')
|
||||
|
||||
|
||||
class InvenTreeRemoteUserMiddleware(PersistentRemoteUserMiddleware):
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
@ -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>
|
||||
|
@ -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([
|
||||
|
@ -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)
|
||||
]
|
@ -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',
|
||||
|
@ -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')
|
||||
|
@ -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()])
|
||||
|
@ -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):
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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 \
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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] = [];
|
||||
}
|
||||
errors[val.param].push(val.message);
|
||||
}
|
||||
let err_msg = '';
|
||||
if (err.response?.data?.non_field_errors) {
|
||||
err_msg = err.response.data.non_field_errors;
|
||||
|
||||
for (const key in errors) {
|
||||
registrationForm.setFieldError(key, errors[key]);
|
||||
}
|
||||
|
||||
showLoginNotification({
|
||||
title: t`Input error`,
|
||||
message: t`Check your input and try again. ` + err_msg,
|
||||
message: t`Check your input and try again. `,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const both_reg_enabled =
|
||||
auth_settings?.registration_enabled && auth_settings?.sso_registration;
|
||||
const both_reg_enabled = registration_enabled() && sso_registration();
|
||||
return (
|
||||
<>
|
||||
{auth_settings?.registration_enabled && (
|
||||
{registration_enabled() && (
|
||||
<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>
|
||||
|
@ -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]
|
||||
|
@ -20,6 +20,7 @@ export const emptyServerAPI = {
|
||||
target: null,
|
||||
default_locale: null,
|
||||
django_admin: null,
|
||||
settings: null,
|
||||
customize: null
|
||||
};
|
||||
|
||||
|
@ -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/',
|
||||
|
@ -1,15 +1,16 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import axios from 'axios';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { Location, NavigateFunction } from 'react-router-dom';
|
||||
import { api, setApiDefaults } from '../App';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { apiUrl, useServerApiState } from '../states/ApiState';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
import { useUserState } from '../states/UserState';
|
||||
import { fetchGlobalStates } from '../states/states';
|
||||
import { type Provider, fetchGlobalStates } from '../states/states';
|
||||
import { showLoginNotification } from './notifications';
|
||||
import { generateUrl } from './urls';
|
||||
|
||||
export function followRedirect(navigate: NavigateFunction, redirect: any) {
|
||||
let url = redirect?.redirectUrl ?? '/home';
|
||||
@ -59,24 +60,29 @@ function post(path: string, params: any, method = 'post') {
|
||||
* If login is successful, an API token will be returned.
|
||||
* This API token is used for any future API requests.
|
||||
*/
|
||||
export const doBasicLogin = async (username: string, password: string) => {
|
||||
export const doBasicLogin = async (
|
||||
username: string,
|
||||
password: string,
|
||||
navigate: NavigateFunction
|
||||
) => {
|
||||
const { host } = useLocalState.getState();
|
||||
const { clearUserState, setToken, fetchUserState } = useUserState.getState();
|
||||
const { setAuthContext } = useServerApiState.getState();
|
||||
|
||||
if (username.length == 0 || password.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearCsrfCookie();
|
||||
await ensureCsrf();
|
||||
|
||||
const login_url = apiUrl(ApiEndpoints.user_login);
|
||||
|
||||
let result = false;
|
||||
let loginDone = false;
|
||||
let success = false;
|
||||
|
||||
// Attempt login with
|
||||
await api
|
||||
.post(
|
||||
login_url,
|
||||
apiUrl(ApiEndpoints.auth_login),
|
||||
{
|
||||
username: username,
|
||||
password: password
|
||||
@ -86,33 +92,41 @@ export const doBasicLogin = async (username: string, password: string) => {
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
if (response.data.key) {
|
||||
setToken(response.data.key);
|
||||
result = true;
|
||||
}
|
||||
setAuthContext(response.data?.data);
|
||||
if (response.status == 200 && response.data?.meta?.is_authenticated) {
|
||||
setToken(response.data.meta.access_token);
|
||||
loginDone = true;
|
||||
success = true;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (
|
||||
err?.response?.status == 403 &&
|
||||
err?.response?.data?.detail == 'MFA required for this user'
|
||||
) {
|
||||
post(apiUrl(ApiEndpoints.user_login), {
|
||||
username: username,
|
||||
password: password,
|
||||
csrfmiddlewaretoken: getCsrfCookie(),
|
||||
mfa: true
|
||||
if (err?.response?.status == 401) {
|
||||
setAuthContext(err.response.data?.data);
|
||||
const mfa_flow = err.response.data.data.flows.find(
|
||||
(flow: any) => flow.id == 'mfa_authenticate'
|
||||
);
|
||||
if (mfa_flow && mfa_flow.is_pending == true) {
|
||||
success = true;
|
||||
navigate('/mfa');
|
||||
}
|
||||
} else if (err?.response?.status == 409) {
|
||||
notifications.show({
|
||||
title: t`Already logged in`,
|
||||
message: t`There is a conflicting session on the server for this browser. Please logout of that first.`,
|
||||
color: 'red',
|
||||
autoClose: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (result) {
|
||||
if (loginDone) {
|
||||
await fetchUserState();
|
||||
fetchGlobalStates();
|
||||
} else {
|
||||
// see if mfa registration is required
|
||||
await fetchGlobalStates(navigate);
|
||||
} else if (!success) {
|
||||
clearUserState();
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -125,8 +139,9 @@ export const doLogout = async (navigate: NavigateFunction) => {
|
||||
|
||||
// Logout from the server session
|
||||
if (isLoggedIn() || !!getCsrfCookie()) {
|
||||
await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => {});
|
||||
|
||||
await authApi(apiUrl(ApiEndpoints.auth_session), undefined, 'delete').catch(
|
||||
() => {}
|
||||
);
|
||||
showLoginNotification({
|
||||
title: t`Logged Out`,
|
||||
message: t`Successfully logged out`
|
||||
@ -158,26 +173,70 @@ export const doSimpleLogin = async (email: string) => {
|
||||
return mail;
|
||||
};
|
||||
|
||||
export function handleReset(navigate: any, values: { email: string }) {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.user_reset), values, {
|
||||
headers: { Authorization: '' }
|
||||
export async function ensureCsrf() {
|
||||
const cookie = getCsrfCookie();
|
||||
if (cookie == undefined) {
|
||||
await api.get(apiUrl(ApiEndpoints.user_token)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleReset(
|
||||
navigate: NavigateFunction,
|
||||
values: { email: string }
|
||||
) {
|
||||
ensureCsrf();
|
||||
api.post(apiUrl(ApiEndpoints.user_reset), values).then((val) => {
|
||||
if (val.status === 200) {
|
||||
notifications.show({
|
||||
title: t`Mail delivery successful`,
|
||||
message: t`Check your inbox for a reset link. This only works if you have an account. Check in spam too.`,
|
||||
color: 'green',
|
||||
autoClose: false
|
||||
});
|
||||
navigate('/login');
|
||||
} else {
|
||||
notifications.show({
|
||||
title: t`Reset failed`,
|
||||
message: t`Check your input and try again.`,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function handleMfaLogin(
|
||||
navigate: NavigateFunction,
|
||||
location: Location<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);
|
||||
})
|
||||
.then((val) => {
|
||||
if (val.status === 200) {
|
||||
.catch((err) => {
|
||||
if (err?.response?.status == 409) {
|
||||
notifications.show({
|
||||
title: t`Mail delivery successful`,
|
||||
message: t`Check your inbox for a reset link. This only works if you have an account. Check in spam too.`,
|
||||
color: 'green',
|
||||
title: t`Already logged in`,
|
||||
message: t`There is a conflicting session on the server for this browser. Please logout of that first.`,
|
||||
color: 'red',
|
||||
autoClose: false
|
||||
});
|
||||
navigate('/login');
|
||||
} else {
|
||||
notifications.show({
|
||||
title: t`Reset failed`,
|
||||
message: t`Check your input and try again.`,
|
||||
color: 'red'
|
||||
});
|
||||
const errors = err.response?.data?.errors;
|
||||
let msg = t`An error occurred`;
|
||||
|
||||
if (errors) {
|
||||
msg = errors.map((e: any) => e.message).join(', ');
|
||||
}
|
||||
setError(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -209,7 +268,7 @@ export const checkLoginState = async (
|
||||
message: t`Successfully logged in`
|
||||
});
|
||||
|
||||
fetchGlobalStates();
|
||||
fetchGlobalStates(navigate);
|
||||
|
||||
followRedirect(navigate, redirect);
|
||||
};
|
||||
@ -257,3 +316,45 @@ export function clearCsrfCookie() {
|
||||
document.cookie =
|
||||
'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
}
|
||||
|
||||
export async function ProviderLogin(
|
||||
provider: Provider,
|
||||
process: 'login' | 'connect' = 'login'
|
||||
) {
|
||||
await ensureCsrf();
|
||||
post(generateUrl(apiUrl(ApiEndpoints.auth_provider_redirect)), {
|
||||
provider: provider.id,
|
||||
callback_url: generateUrl('/logged-in'),
|
||||
process: process,
|
||||
csrfmiddlewaretoken: getCsrfCookie()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an API request with session tokens using the provided URL, configuration, method, and data.
|
||||
*
|
||||
* @param url - The URL to which the request is sent.
|
||||
* @param config - Optional Axios request configuration.
|
||||
* @param method - The HTTP method to use for the request. Defaults to 'get'.
|
||||
* @param data - Optional data to be sent with the request.
|
||||
* @returns A promise that resolves to the response of the API request.
|
||||
*/
|
||||
export function authApi(
|
||||
url: string,
|
||||
config: AxiosRequestConfig | undefined = undefined,
|
||||
method: 'get' | 'post' | 'put' | 'delete' = 'get',
|
||||
data?: any
|
||||
) {
|
||||
const requestConfig = config || {};
|
||||
|
||||
// set method
|
||||
requestConfig.method = method;
|
||||
|
||||
// set data
|
||||
if (data) {
|
||||
requestConfig.data = data;
|
||||
}
|
||||
|
||||
// use normal api
|
||||
return api(url, requestConfig);
|
||||
}
|
||||
|
@ -19,12 +19,14 @@ import { StylishText } from '../../components/items/StylishText';
|
||||
import { ProtectedRoute } from '../../components/nav/Layout';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { clearCsrfCookie } from '../../functions/auth';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
|
||||
export default function Set_Password() {
|
||||
const simpleForm = useForm({
|
||||
initialValues: {
|
||||
current_password: '',
|
||||
new_password1: '',
|
||||
new_password2: ''
|
||||
}
|
||||
@ -35,8 +37,10 @@ export default function Set_Password() {
|
||||
|
||||
function passwordError(values: any) {
|
||||
let message: any =
|
||||
values?.new_password ||
|
||||
values?.new_password2 ||
|
||||
values?.new_password1 ||
|
||||
values?.current_password ||
|
||||
values?.error ||
|
||||
t`Password could not be changed`;
|
||||
|
||||
@ -55,27 +59,45 @@ export default function Set_Password() {
|
||||
}
|
||||
|
||||
function handleSet() {
|
||||
const { clearUserState } = useUserState.getState();
|
||||
|
||||
// check if passwords match
|
||||
if (simpleForm.values.new_password1 !== simpleForm.values.new_password2) {
|
||||
passwordError({ new_password2: t`The two password fields didn’t match` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set password with call to backend
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.user_change_password), {
|
||||
new_password1: simpleForm.values.new_password1,
|
||||
new_password2: simpleForm.values.new_password2
|
||||
.post(apiUrl(ApiEndpoints.auth_pwd_change), {
|
||||
current_password: simpleForm.values.current_password,
|
||||
new_password: simpleForm.values.new_password2
|
||||
})
|
||||
.then((val) => {
|
||||
if (val.status === 200) {
|
||||
passwordError(val.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.status === 401) {
|
||||
notifications.show({
|
||||
title: t`Password Changed`,
|
||||
message: t`The password was set successfully. You can now login with your new password`,
|
||||
color: 'green',
|
||||
autoClose: false
|
||||
});
|
||||
clearUserState();
|
||||
clearCsrfCookie();
|
||||
navigate('/login');
|
||||
} else {
|
||||
passwordError(val.data);
|
||||
// compile errors
|
||||
const errors: { [key: string]: string[] } = {};
|
||||
for (const val of err.response.data.errors) {
|
||||
if (!errors[val.param]) {
|
||||
errors[val.param] = [];
|
||||
}
|
||||
errors[val.param].push(val.message);
|
||||
}
|
||||
passwordError(errors);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
passwordError(err.response.data);
|
||||
});
|
||||
}
|
||||
|
||||
@ -97,6 +119,13 @@ export default function Set_Password() {
|
||||
)}
|
||||
<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'
|
||||
|
@ -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
|
||||
|
59
src/frontend/src/pages/Auth/MFALogin.tsx
Normal file
59
src/frontend/src/pages/Auth/MFALogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
76
src/frontend/src/pages/Auth/MFASetup.tsx
Normal file
76
src/frontend/src/pages/Auth/MFASetup.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -22,32 +22,41 @@ export default function ResetPassword() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const token = searchParams.get('token');
|
||||
const uid = searchParams.get('uid');
|
||||
const key = searchParams.get('key');
|
||||
|
||||
function invalidToken() {
|
||||
function invalidKey() {
|
||||
notifications.show({
|
||||
title: t`Token invalid`,
|
||||
message: t`You need to provide a valid token to set a new password. Check your inbox for a reset link.`,
|
||||
title: t`Key invalid`,
|
||||
message: t`You need to provide a valid key to set a new password. Check your inbox for a reset link.`,
|
||||
color: 'red'
|
||||
});
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
function success() {
|
||||
notifications.show({
|
||||
title: t`Password set`,
|
||||
message: t`The password was set successfully. You can now login with your new password`,
|
||||
color: 'green',
|
||||
autoClose: false
|
||||
});
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
function passwordError(values: any) {
|
||||
notifications.show({
|
||||
title: t`Reset failed`,
|
||||
message: values?.new_password2 || values?.new_password1 || values?.token,
|
||||
message: values?.errors.map((e: any) => e.message).join('\n'),
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// make sure we have a token
|
||||
if (!token || !uid) {
|
||||
invalidToken();
|
||||
// make sure we have a key
|
||||
if (!key) {
|
||||
invalidKey();
|
||||
}
|
||||
}, [token]);
|
||||
}, [key]);
|
||||
|
||||
function handleSet() {
|
||||
// Set password with call to backend
|
||||
@ -55,32 +64,23 @@ export default function ResetPassword() {
|
||||
.post(
|
||||
apiUrl(ApiEndpoints.user_reset_set),
|
||||
{
|
||||
uid: uid,
|
||||
token: token,
|
||||
new_password1: simpleForm.values.password,
|
||||
new_password2: simpleForm.values.password
|
||||
key: key,
|
||||
password: simpleForm.values.password
|
||||
},
|
||||
{ headers: { Authorization: '' } }
|
||||
)
|
||||
.then((val) => {
|
||||
if (val.status === 200) {
|
||||
notifications.show({
|
||||
title: t`Password set`,
|
||||
message: t`The password was set successfully. You can now login with your new password`,
|
||||
color: 'green',
|
||||
autoClose: false
|
||||
});
|
||||
navigate('/login');
|
||||
success();
|
||||
} else {
|
||||
passwordError(val.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (
|
||||
err.response?.status === 400 &&
|
||||
err.response?.data?.token == 'Invalid value'
|
||||
) {
|
||||
invalidToken();
|
||||
if (err.response?.status === 400) {
|
||||
passwordError(err.response.data);
|
||||
} else if (err.response?.status === 401) {
|
||||
success();
|
||||
} else {
|
||||
passwordError(err.response.data);
|
||||
}
|
||||
@ -99,12 +99,12 @@ export default function ResetPassword() {
|
||||
<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>
|
||||
|
61
src/frontend/src/pages/Auth/VerifyEmail.tsx
Normal file
61
src/frontend/src/pages/Auth/VerifyEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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(() => {
|
||||
refetch();
|
||||
})
|
||||
.catch((res) => console.log(res.data));
|
||||
}
|
||||
|
||||
function addEmail() {
|
||||
api
|
||||
.post(apiUrl(ApiEndpoints.user_emails), {
|
||||
email: newEmailValue,
|
||||
user: user?.pk
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
})
|
||||
.catch((res) => console.log(res.data));
|
||||
function runServerAction(
|
||||
action: 'post' | 'put' | 'delete' = 'post',
|
||||
data?: any
|
||||
) {
|
||||
const vals: any = data || { email: value };
|
||||
return authApi(
|
||||
apiUrl(ApiEndpoints.auth_email),
|
||||
undefined,
|
||||
action,
|
||||
vals
|
||||
).then(() => {
|
||||
refetch();
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
@ -132,40 +105,50 @@ function EmailContent() {
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<Radio.Group
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
name='email_accounts'
|
||||
label={t`The following email addresses are associated with your account:`}
|
||||
>
|
||||
<Stack mt='xs'>
|
||||
{data.map((link: any) => (
|
||||
<Radio
|
||||
key={link.id}
|
||||
value={String(link.id)}
|
||||
label={
|
||||
<Group justify='space-between'>
|
||||
{link.email}
|
||||
{link.primary && (
|
||||
<Badge color='blue'>
|
||||
<Trans>Primary</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{link.verified ? (
|
||||
<Badge color='green'>
|
||||
<Trans>Verified</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color='yellow'>
|
||||
<Trans>Unverified</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
{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}
|
||||
name='email_accounts'
|
||||
label={t`The following email addresses are associated with your account:`}
|
||||
>
|
||||
<Stack mt='xs'>
|
||||
{data.map((email: any) => (
|
||||
<Radio
|
||||
key={email.email}
|
||||
value={String(email.email)}
|
||||
label={
|
||||
<Group justify='space-between'>
|
||||
{email.email}
|
||||
{email.primary && (
|
||||
<Badge color='blue'>
|
||||
<Trans>Primary</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{email.verified ? (
|
||||
<Badge color='green'>
|
||||
<Trans>Verified</Trans>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color='yellow'>
|
||||
<Trans>Unverified</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
<Text>
|
||||
{currentProviders === undefined ? (
|
||||
{availableProviders === undefined ? (
|
||||
<Text>
|
||||
<Trans>Loading</Trans>
|
||||
) : (
|
||||
<Stack gap='xs'>
|
||||
{currentProviders.map((provider: any) => (
|
||||
<ProviderButton key={provider.id} provider={provider} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap='xs'>
|
||||
{availableProviders.map((provider: any) => (
|
||||
<ProviderButton key={provider.id} provider={provider} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</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)
|
||||
|
@ -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];
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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',
|
||||
|
@ -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 ?? '';
|
||||
|
@ -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();
|
||||
|
@ -20,8 +20,12 @@ export default function MainView() {
|
||||
const [allowMobile] = useLocalState((state) => [state.allowMobile]);
|
||||
// Set initial login status
|
||||
useEffect(() => {
|
||||
// Local state initialization
|
||||
setApiDefaults();
|
||||
try {
|
||||
// Local state initialization
|
||||
setApiDefaults();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check if mobile
|
||||
|
@ -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' &&
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
});
|
||||
|
3
tasks.py
3
tasks.py
@ -181,7 +181,6 @@ def content_excludes(
|
||||
'exchange.exchangebackend',
|
||||
'common.notificationentry',
|
||||
'common.notificationmessage',
|
||||
'user_sessions.session',
|
||||
'importer.dataimportsession',
|
||||
'importer.dataimportcolumnmap',
|
||||
'importer.dataimportrow',
|
||||
@ -1226,7 +1225,7 @@ def schema(
|
||||
|
||||
info(f"Exporting schema file to '{filename}'")
|
||||
|
||||
cmd = f'spectacular --file {filename} --validate --color'
|
||||
cmd = f'schema --file {filename} --validate --color'
|
||||
|
||||
if not ignore_warnings:
|
||||
cmd += ' --fail-on-warn'
|
||||
|
Loading…
x
Reference in New Issue
Block a user