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:
@ -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 \
|
||||
|
Reference in New Issue
Block a user