2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 20:15:44 +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 00bb6c5274.

* remove usage of git repo

* fix doc string

* extend schema generation to just patch in allauth

* patch allauth OAI ref names

* reduce types

* refactor code structure

* fix ref patching a bit more

* add param cleanup

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

* move fnc

* shorten names

* bump allauth

* re-add auth doc section

* fix doc structure

* revert playwrigth change

* ckean up browser only path

* clean up parameters that we do not use

* re-add 2fa required middleware

* fix mail sending hook

* fix password set texts

* Add forced mfa setup

* remove type

* adjust api_version

* Remove debug prints

* Add error message for TOTP creation

* Handle failed TOTP login

* fix reqs

* Add error on 409 during login

* fix tested url

* fix api_version

* fix allauth version

* minimize req diff

* further minimize diff

---------

Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair
2025-02-22 01:11:04 +01:00
committed by GitHub
parent 1f84f24514
commit 03278c56c9
56 changed files with 1814 additions and 2356 deletions

View File

@ -0,0 +1,74 @@
"""Middleware to require 2FA for users."""
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.utils.deprecation import MiddlewareMixin
from django.utils.translation import gettext_lazy as _
from allauth.account.authentication import get_authentication_records
from allauth.mfa.utils import is_mfa_enabled
from allauth.mfa.webauthn.internal.flows import did_use_passwordless_login
def is_multifactor_logged_in(request: HttpRequest) -> bool:
"""Check if the user is logged in with multifactor authentication."""
authns = get_authentication_records(request)
return is_mfa_enabled(request.user) and (
did_use_passwordless_login(request)
or any(record.get('method') == 'mfa' for record in authns)
)
class AllUserRequire2FAMiddleware(MiddlewareMixin):
"""Ensure that users have two-factor authentication enabled before they have access restricted endpoints.
Adapted from https://github.com/pennersr/django-allauth/issues/3649
"""
allowed_pages = [
'api-user-meta',
'api-user-me',
'api-user-roles',
'api-inventree-info',
'api-token',
# web platform urls
'password_reset_confirm',
'platform',
'platform-wildcard',
'web-assets',
]
app_names = ['headless']
require_2fa_message = _(
'You must enable two-factor authentication before doing anything else.'
)
def on_require_2fa(self, request: HttpRequest) -> HttpResponse:
"""Force user to mfa activation."""
return JsonResponse({'id': 'mfa_register'}, status=401)
def is_allowed_page(self, request: HttpRequest) -> bool:
"""Check if the current page can be accessed without mfa."""
return (
any(ref in self.app_names for ref in request.resolver_match.app_names)
or request.resolver_match.url_name in self.allowed_pages
or request.resolver_match.route == 'favicon.ico'
)
def enforce_2fa(self, request):
"""Check if 2fa should be enforced for this request."""
return True
def process_view(
self, request: HttpRequest, view_func, view_args, view_kwargs
) -> HttpResponse:
"""If set up enforce 2fa registration."""
if request.user.is_anonymous:
return None
if self.is_allowed_page(request):
return None
if is_multifactor_logged_in(request):
return None
if self.enforce_2fa(request):
return self.on_require_2fa(request)
return None

View File

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

View File

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

View File

@ -1,40 +0,0 @@
"""Overrides for registration view."""
from django.utils.translation import gettext_lazy as _
from allauth.account import app_settings as allauth_account_settings
from dj_rest_auth.app_settings import api_settings
from dj_rest_auth.registration.views import RegisterView
class CustomRegisterView(RegisterView):
"""Registers a new user.
Accepts the following POST parameters: username, email, password1, password2.
"""
# Fixes https://github.com/inventree/InvenTree/issues/8707
# This contains code from dj-rest-auth 7.0 - therefore the version was pinned
def get_response_data(self, user):
"""Override to fix check for auth_model."""
if (
allauth_account_settings.EMAIL_VERIFICATION
== allauth_account_settings.EmailVerificationMethod.MANDATORY
):
return {'detail': _('Verification e-mail sent.')}
if api_settings.USE_JWT:
data = {
'user': user,
'access': self.access_token,
'refresh': self.refresh_token,
}
return api_settings.JWT_SERIALIZER(
data, context=self.get_serializer_context()
).data
elif self.token_model:
# Only change in this block is below
return api_settings.TOKEN_SERIALIZER(
user.api_tokens.last(), context=self.get_serializer_context()
).data
return None

View File

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

View File

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

View File

@ -0,0 +1,110 @@
"""Check if there are any pending database migrations, and run them."""
from pathlib import Path
from typing import TypeVar
from django.conf import settings
import structlog
import yaml
from drf_spectacular.management.commands import spectacular
T = TypeVar('T')
logger = structlog.get_logger('inventree')
dja_path_prefix = '/_allauth/{client}/v1/'
dja_ref_prefix = 'allauth'
dja_clean_params = [
'#/components/parameters/allauth.SessionToken',
'#/components/parameters/allauth.Client',
]
def prep_name(ref):
"""Prepend django-allauth to all ref names."""
return f'{dja_ref_prefix}.{ref}'
def sub_component_name(name: T) -> T:
"""Clean up component references."""
if not isinstance(name, str):
return name
s = name.split('/')
if len(s) == 4 and s[1] == 'components':
s[3] = prep_name(s[3])
return '/'.join(s)
def clean_params(params):
"""Clean refs of unwanted parameters.
We don't use them in our API, we only support allauths browser APIs endpoints.
"""
return [p for p in params if p['$ref'] not in dja_clean_params]
class Command(spectacular.Command):
"""Overwritten command to include django-allauth schemas."""
def proccess_refs(self, value):
"""Prepend ref names."""
if isinstance(value, str):
return sub_component_name(value)
elif isinstance(value, list):
return [self.proccess_refs(v) for v in value]
elif isinstance(value, dict):
return {k: self.proccess_refs(v) for k, v in value.items()}
return value
def handle(self, *args, **kwargs):
"""Extended schema generation that patches in django-allauth schemas."""
from allauth.headless.spec.internal import schema
# gather paths
org_path = Path(schema.__file__).parent.parent / 'doc/openapi.yaml'
with open(org_path, 'rb') as f:
spec = yaml.safe_load(f)
paths = {}
# Reformat paths
for p_name, p_spec in spec['paths'].items():
# strip path name
p_name = p_name.removeprefix(dja_path_prefix).removeprefix(
'/_allauth/browser/v1/'
)
# fix refs
for m_name, m_spec in p_spec.items():
if m_spec.get('operationId', None) is None:
m_spec['operationId'] = (
f'{dja_ref_prefix}_{p_name.replace("/", "_")}_{m_name}'
)
# update all refs
for key, value in m_spec.items():
if key in ['parameters', 'responses', 'requestBody']:
m_spec[key] = self.proccess_refs(value)
# patch out unwanted parameters - we don't use it
if params := m_spec.get('parameters', None):
m_spec['parameters'] = clean_params(params)
# prefix path name
paths[f'/api/auth/v1/{p_name}'] = p_spec
settings.SPECTACULAR_SETTINGS['APPEND_PATHS'] = paths
components = {}
# Reformat components
for c_name, c_spec in spec['components'].items():
new_component = {}
for sc_name, sc_spec in c_spec.items():
new_component[prep_name(sc_name)] = self.proccess_refs(sc_spec)
components[c_name] = new_component
# Remove unused parameters
for p in dja_clean_params:
components['parameters'].pop(p.replace('#/components/parameters/', ''))
settings.SPECTACULAR_SETTINGS['APPEND_COMPONENTS'] = components
super().handle(*args, **kwargs)

View File

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

View File

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

View File

@ -1,293 +0,0 @@
"""API endpoints for social authentication with allauth."""
from importlib import import_module
from django.conf import settings
from django.urls import NoReverseMatch, include, path, reverse
import allauth.socialaccount.providers.openid_connect.views as oidc_views
import structlog
from allauth.account.models import EmailAddress
from allauth.socialaccount import providers
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import serializers
from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
import InvenTree.sso
from common.settings import get_global_setting
from InvenTree.auth_overrides import registration_enabled
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer
logger = structlog.get_logger('inventree')
class GenericOAuth2ApiLoginView(OAuth2LoginView):
"""Api view to login a user with a social account."""
def dispatch(self, request, *args, **kwargs):
"""Dispatch the regular login view directly."""
return self.login(request, *args, **kwargs)
class GenericOAuth2ApiConnectView(GenericOAuth2ApiLoginView):
"""Api view to connect a social account to the current user."""
def dispatch(self, request, *args, **kwargs):
"""Dispatch the connect request directly."""
# Override the request method be in connection mode
request.GET = request.GET.copy()
request.GET['process'] = 'connect'
# Resume the dispatch
return super().dispatch(request, *args, **kwargs)
def handle_oauth2(adapter: OAuth2Adapter, provider=None):
"""Define urls for oauth2 endpoints."""
return [
path(
'login/',
GenericOAuth2ApiLoginView.adapter_view(adapter),
name=f'{provider.id}_api_login',
),
path(
'connect/',
GenericOAuth2ApiConnectView.adapter_view(adapter),
name=f'{provider.id}_api_connect',
),
]
def handle_oidc(provider):
"""Define urls for oidc endpoints."""
return [
path(
'login/',
lambda x: oidc_views.login(x, provider.id),
name=f'{provider.id}_api_login',
),
path(
'connect/',
lambda x: oidc_views.callback(x, provider.id),
name=f'{provider.id}_api_connect',
),
]
legacy = {
'twitter': 'twitter_oauth2',
'bitbucket': 'bitbucket_oauth2',
'linkedin': 'linkedin_oauth2',
'vimeo': 'vimeo_oauth2',
'openid': 'openid_connect',
} # legacy connectors
# Collect urls for all loaded providers
def get_provider_urls() -> list:
"""Collect urls for all loaded providers.
Returns:
list: List of urls for all loaded providers.
"""
auth_provider_routes = []
for name, provider in providers.registry.provider_map.items():
try:
prov_mod = import_module(provider.get_package() + '.views')
except ImportError:
logger.exception('Could not import authentication provider %s', name)
continue
# Try to extract the adapter class
adapters = [
cls
for cls in prov_mod.__dict__.values()
if isinstance(cls, type)
and cls != OAuth2Adapter
and issubclass(cls, OAuth2Adapter)
]
# Get urls
urls = []
if len(adapters) == 1:
if provider.id == 'openid_connect':
urls = handle_oidc(provider)
else:
urls = handle_oauth2(adapter=adapters[0], provider=provider)
elif provider.id in legacy:
logger.warning(
'`%s` is not supported on platform UI. Use `%s` instead.',
provider.id,
legacy[provider.id],
)
continue
else:
logger.error(
'Found handler that is not yet ready for platform UI: `%s`. Open an feature request on GitHub if you need it implemented.',
provider.id,
)
continue
auth_provider_routes += [path(f'{provider.id}/', include(urls))]
return auth_provider_routes
class SocialProviderListResponseSerializer(serializers.Serializer):
"""Serializer for the SocialProviderListView."""
class SocialProvider(serializers.Serializer):
"""Serializer for the SocialProviderListResponseSerializer."""
id = serializers.CharField()
name = serializers.CharField()
configured = serializers.BooleanField()
login = serializers.URLField()
connect = serializers.URLField()
display_name = serializers.CharField()
sso_enabled = serializers.BooleanField()
sso_registration = serializers.BooleanField()
mfa_required = serializers.BooleanField()
providers = SocialProvider(many=True)
registration_enabled = serializers.BooleanField()
password_forgotten_enabled = serializers.BooleanField()
class SocialProviderListView(ListAPI):
"""List of available social providers."""
permission_classes = (AllowAny,)
serializer_class = EmptySerializer
@extend_schema(
responses={200: OpenApiResponse(response=SocialProviderListResponseSerializer)}
)
def get(self, request, *args, **kwargs):
"""Get the list of providers."""
provider_list = []
for provider in providers.registry.provider_map.values():
provider_data = {
'id': provider.id,
'name': provider.name,
'configured': False,
}
try:
provider_data['login'] = request.build_absolute_uri(
reverse(f'{provider.id}_api_login')
)
except NoReverseMatch:
provider_data['login'] = None
try:
provider_data['connect'] = request.build_absolute_uri(
reverse(f'{provider.id}_api_connect')
)
except NoReverseMatch:
provider_data['connect'] = None
provider_data['configured'] = InvenTree.sso.check_provider(provider)
provider_data['display_name'] = InvenTree.sso.provider_display_name(
provider
)
provider_list.append(provider_data)
data = {
'sso_enabled': InvenTree.sso.sso_login_enabled(),
'sso_registration': InvenTree.sso.sso_registration_enabled(),
'mfa_required': settings.MFA_ENABLED
and get_global_setting('LOGIN_ENFORCE_MFA'),
'mfa_enabled': settings.MFA_ENABLED,
'providers': provider_list,
'registration_enabled': registration_enabled(),
'password_forgotten_enabled': get_global_setting('LOGIN_ENABLE_PWD_FORGOT'),
}
return Response(data)
class EmailAddressSerializer(InvenTreeModelSerializer):
"""Serializer for the EmailAddress model."""
class Meta:
"""Meta options for EmailAddressSerializer."""
model = EmailAddress
fields = '__all__'
class EmptyEmailAddressSerializer(InvenTreeModelSerializer):
"""Empty Serializer for the EmailAddress model."""
class Meta:
"""Meta options for EmailAddressSerializer."""
model = EmailAddress
fields = []
class EmailListView(ListCreateAPI):
"""List of registered email addresses for current users."""
permission_classes = (IsAuthenticated,)
serializer_class = EmailAddressSerializer
def get_queryset(self):
"""Only return data for current user."""
return EmailAddress.objects.filter(user=self.request.user)
class EmailActionMixin(CreateAPI):
"""Mixin to modify email addresses for current users."""
serializer_class = EmptyEmailAddressSerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
"""Filter queryset for current user."""
return EmailAddress.objects.filter(
user=self.request.user, pk=self.kwargs['pk']
).first()
@extend_schema(responses={200: OpenApiResponse(response=EmailAddressSerializer)})
def post(self, request, *args, **kwargs):
"""Filter item, run action and return data."""
email = self.get_queryset()
if not email:
raise NotFound
self.special_action(email, request, *args, **kwargs)
return Response(EmailAddressSerializer(email).data)
class EmailVerifyView(EmailActionMixin):
"""Re-verify an email for a currently logged in user."""
def special_action(self, email, request, *args, **kwargs):
"""Send confirmation."""
if email.verified:
return
email.send_confirmation(request)
class EmailPrimaryView(EmailActionMixin):
"""Make an email for a currently logged in user primary."""
def special_action(self, email, *args, **kwargs):
"""Mark email as primary."""
if email.primary:
return
email.set_as_primary()
class EmailRemoveView(EmailActionMixin):
"""Remove an email for a currently logged in user."""
def special_action(self, email, *args, **kwargs):
"""Delete email."""
email.delete()

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,48 @@
# Generated by Django 3.2.23 on 2024-01-19 16:59
import base64
from django.db import migrations
from allauth.mfa.adapter import get_adapter
from allauth.mfa.models import Authenticator
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
def move_mfa(apps, schema_editor):
"""Data migration to switch to django-allauth's new built-in MFA."""
adapter = get_adapter()
authenticators = []
for totp in TOTPDevice.objects.filter(confirmed=True).iterator():
recovery_codes = set()
for sdevice in StaticDevice.objects.filter(
confirmed=True, user_id=totp.user_id
).iterator():
recovery_codes.update(sdevice.token_set.values_list('token', flat=True))
secret = base64.b32encode(bytes.fromhex(totp.key)).decode('ascii')
totp_authenticator = Authenticator(
user_id=totp.user_id,
type=Authenticator.Type.TOTP,
data={'secret': adapter.encrypt(secret)},
)
authenticators.append(totp_authenticator)
authenticators.append(
Authenticator(
user_id=totp.user_id,
type=Authenticator.Type.RECOVERY_CODES,
data={'migrated_codes': [adapter.encrypt(c) for c in recovery_codes]},
)
)
Authenticator.objects.bulk_create(authenticators)
class Migration(migrations.Migration):
dependencies = [('users', '0012_alter_ruleset_can_view'),
('otp_static', '0002_throttling'),
('otp_totp', '0002_auto_20190420_0723'),
('mfa', '0002_authenticator_timestamps'),]
operations = [
migrations.RunPython(move_mfa, reverse_code=migrations.RunPython.noop)
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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