diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 929a299e93..598f518f27 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -12,7 +12,7 @@ on: - l10* env: - python_version: 3.7 + python_version: 3.8 node_version: 16 server_start_sleep: 60 @@ -229,6 +229,7 @@ jobs: cache: 'pip' - name: Install Dependencies run: | + sudo apt-get update sudo apt-get install libpq-dev pip3 install invoke pip3 install psycopg2 @@ -282,7 +283,8 @@ jobs: cache: 'pip' - name: Install Dependencies run: | - sudo apt-get install mysql-server libmysqlclient-dev + sudo apt-get update + sudo apt-get install libmysqlclient-dev pip3 install invoke pip3 install mysqlclient invoke install diff --git a/InvenTree/InvenTree/forms.py b/InvenTree/InvenTree/forms.py index 7a7668cf66..02b993d31b 100644 --- a/InvenTree/InvenTree/forms.py +++ b/InvenTree/InvenTree/forms.py @@ -4,12 +4,15 @@ Helper forms which subclass Django forms to provide additional functionality # -*- coding: utf-8 -*- from __future__ import unicode_literals +from urllib.parse import urlencode import logging from django.utils.translation import ugettext_lazy as _ from django import forms from django.contrib.auth.models import User, Group from django.conf import settings +from django.http import HttpResponseRedirect +from django.urls import reverse from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Field @@ -18,6 +21,9 @@ from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppende from allauth.account.forms import SignupForm, set_form_field_order from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.exceptions import ImmediateHttpResponse +from allauth_2fa.adapter import OTPAdapter +from allauth_2fa.utils import user_has_valid_totp_device from part.models import PartCategory from common.models import InvenTreeSetting @@ -278,7 +284,7 @@ class RegistratonMixin: return user -class CustomAccountAdapter(RegistratonMixin, DefaultAccountAdapter): +class CustomAccountAdapter(RegistratonMixin, OTPAdapter, DefaultAccountAdapter): """ Override of adapter to use dynamic settings """ @@ -297,3 +303,27 @@ class CustomSocialAccountAdapter(RegistratonMixin, DefaultSocialAccountAdapter): if InvenTreeSetting.get_setting('LOGIN_SIGNUP_SSO_AUTO', True): 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): + # 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 += u'?' + urlencode(request.GET) + + raise ImmediateHttpResponse( + response=HttpResponseRedirect(redirect_url) + ) + + # Otherwise defer to the original allauth adapter. + return super().login(request, user) diff --git a/InvenTree/InvenTree/management/commands/remove_mfa.py b/InvenTree/InvenTree/management/commands/remove_mfa.py new file mode 100644 index 0000000000..8c84920cc3 --- /dev/null +++ b/InvenTree/InvenTree/management/commands/remove_mfa.py @@ -0,0 +1,36 @@ +""" +Custom management command to remove MFA for a user +""" + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model + + +class Command(BaseCommand): + """ + Remove MFA for a user + """ + + def add_arguments(self, parser): + parser.add_argument('mail', type=str) + + def handle(self, *args, **kwargs): + + # general settings + mail = kwargs.get('mail') + if not mail: + raise KeyError('A mail is required') + user = get_user_model() + mfa_user = [*set(user.objects.filter(email=mail) | user.objects.filter(emailaddress__email=mail))] + + if len(mfa_user) == 0: + print('No user with this mail associated') + elif len(mfa_user) > 1: + print('More than one user found with this mail') + else: + # and clean out all MFA methods + # backup codes + mfa_user[0].staticdevice_set.all().delete() + # TOTP tokens + mfa_user[0].totpdevice_set.all().delete() + print(f'Removed all MFA methods for user {str(mfa_user[0])}') diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py index 2df90bc5b7..0d7a4f46f7 100644 --- a/InvenTree/InvenTree/middleware.py +++ b/InvenTree/InvenTree/middleware.py @@ -1,12 +1,18 @@ from django.shortcuts import HttpResponseRedirect -from django.urls import reverse_lazy +from django.urls import reverse_lazy, Resolver404 from django.db import connection from django.shortcuts import redirect +from django.conf.urls import include, url import logging import time import operator from rest_framework.authtoken.models import Token +from allauth_2fa.middleware import BaseRequire2FAMiddleware, AllauthTwoFactorMiddleware + +from InvenTree.urls import frontendpatterns +from common.models import InvenTreeSetting + logger = logging.getLogger("inventree") @@ -146,3 +152,28 @@ class QueryCountMiddleware(object): print(x[0], ':', x[1]) return response + + +url_matcher = url('', include(frontendpatterns)) + + +class Check2FAMiddleware(BaseRequire2FAMiddleware): + """check if user is required to have MFA enabled""" + def require_2fa(self, request): + # Superusers are require to have 2FA. + try: + if url_matcher.resolve(request.path[1:]): + return InvenTreeSetting.get_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): + try: + if not url_matcher.resolve(request.path[1:]): + super().process_request(request) + except Resolver404: + pass diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index c37ae886b1..ba2808a8dd 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -313,6 +313,12 @@ INSTALLED_APPS = [ 'allauth', # Base app for SSO 'allauth.account', # Extend user with accounts 'allauth.socialaccount', # Use 'social' providers + + '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 ] MIDDLEWARE = CONFIG.get('middleware', [ @@ -323,9 +329,12 @@ MIDDLEWARE = CONFIG.get('middleware', [ 'django.middleware.csrf.CsrfViewMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_otp.middleware.OTPMiddleware', # MFA support + 'InvenTree.middleware.CustomAllauthTwoFactorMiddleware', # Flow control for allauth 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'InvenTree.middleware.AuthRequiredMiddleware', + 'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA 'maintenance_mode.middleware.MaintenanceModeMiddleware', ]) diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 57d5c58ed9..f56315e49c 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -131,15 +131,26 @@ translated_javascript_urls = [ url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'), ] -urlpatterns = [ - url(r'^part/', include(part_urls)), - url(r'^manufacturer-part/', include(manufacturer_part_urls)), - url(r'^supplier-part/', include(supplier_part_urls)), - +backendpatterns = [ # "Dynamic" javascript files which are rendered using InvenTree templating. url(r'^js/dynamic/', include(dynamic_javascript_urls)), url(r'^js/i18n/', include(translated_javascript_urls)), + url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')), + url(r'^auth/?', auth_request), + + url(r'^api/', include(apipatterns)), + url(r'^api-doc/', include_docs_urls(title='InvenTree API')), + + # 3rd party endpoints + url(r'^markdownx/', include('markdownx.urls')), +] + +frontendpatterns = [ + url(r'^part/', include(part_urls)), + url(r'^manufacturer-part/', include(manufacturer_part_urls)), + url(r'^supplier-part/', include(supplier_part_urls)), + url(r'^common/', include(common_urls)), url(r'^stock/', include(stock_urls)), @@ -149,8 +160,6 @@ urlpatterns = [ url(r'^build/', include(build_urls)), - url(r'^auth/', include('rest_framework.urls', namespace='rest_framework')), - url(r'^settings/', include(settings_urls)), url(r'^notifications/', include(notifications_urls)), @@ -158,23 +167,17 @@ urlpatterns = [ url(r'^edit-user/', EditUserView.as_view(), name='edit-user'), url(r'^set-password/', SetPasswordView.as_view(), name='set-password'), - url(r'^admin/error_log/', include('error_report.urls')), - url(r'^admin/shell/', include('django_admin_shell.urls')), - url(r'^admin/', admin.site.urls, name='inventree-admin'), - url(r'^index/', IndexView.as_view(), name='index'), url(r'^search/', SearchView.as_view(), name='search'), url(r'^stats/', DatabaseStatsView.as_view(), name='stats'), - url(r'^auth/?', auth_request), - - url(r'^api/', include(apipatterns)), - url(r'^api-doc/', include_docs_urls(title='InvenTree API')), - # plugin urls get_plugin_urls(), # appends currently loaded plugin urls = None - url(r'^markdownx/', include('markdownx.urls')), + # admin sites + url(r'^admin/error_log/', include('error_report.urls')), + url(r'^admin/shell/', include('django_admin_shell.urls')), + url(r'^admin/', admin.site.urls, name='inventree-admin'), # DB user sessions url(r'^accounts/sessions/other/delete/$', view=CustomSessionDeleteOtherView.as_view(), name='session_delete_other', ), @@ -185,7 +188,13 @@ urlpatterns = [ url(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'), url(r'^accounts/social/connections/', CustomConnectionsView.as_view(), name='socialaccount_connections'), url(r"^accounts/password/reset/key/(?P[0-9A-Za-z]+)-(?P.+)/$", CustomPasswordResetFromKeyView.as_view(), name="account_reset_password_from_key"), - url(r'^accounts/', include('allauth.urls')), # included urlpatterns + url(r'^accounts/', include('allauth_2fa.urls')), # MFA support + url(r'^accounts/', include('allauth.urls')), # included urlpatterns +] + +urlpatterns = [ + url('', include(frontendpatterns)), + url('', include(backendpatterns)), ] # Server running in "DEBUG" mode? diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 1052e48b53..d194f96118 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -962,6 +962,12 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'default': '', 'choices': settings_group_options }, + 'LOGIN_ENFORCE_MFA': { + 'name': _('Enforce MFA'), + 'description': _('Users must use multifactor security.'), + 'default': False, + 'validator': bool, + }, 'ENABLE_PLUGINS_URL': { 'name': _('Enable URL integration'), 'description': _('Enable plugins to add URL routes'), diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 609acec917..2d9ae4dc30 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -11,7 +11,7 @@ from django.utils.translation import ugettext_lazy as _ from mptt.fields import TreeNodeChoiceField from InvenTree.forms import HelperForm -from InvenTree.helpers import GetExportFormats, clean_decimal +from InvenTree.helpers import clean_decimal from InvenTree.fields import RoundingDecimalFormField import common.models @@ -55,36 +55,6 @@ class PartImageDownloadForm(HelperForm): ] -class BomExportForm(forms.Form): - """ Simple form to let user set BOM export options, - before exporting a BOM (bill of materials) file. - """ - - file_format = forms.ChoiceField(label=_("File Format"), help_text=_("Select output file format")) - - cascading = forms.BooleanField(label=_("Cascading"), required=False, initial=True, help_text=_("Download cascading / multi-level BOM")) - - levels = forms.IntegerField(label=_("Levels"), required=True, initial=0, help_text=_("Select maximum number of BOM levels to export (0 = all levels)")) - - parameter_data = forms.BooleanField(label=_("Include Parameter Data"), required=False, initial=False, help_text=_("Include part parameters data in exported BOM")) - - stock_data = forms.BooleanField(label=_("Include Stock Data"), required=False, initial=False, help_text=_("Include part stock data in exported BOM")) - - manufacturer_data = forms.BooleanField(label=_("Include Manufacturer Data"), required=False, initial=True, help_text=_("Include part manufacturer data in exported BOM")) - - supplier_data = forms.BooleanField(label=_("Include Supplier Data"), required=False, initial=True, help_text=_("Include part supplier data in exported BOM")) - - def get_choices(self): - """ BOM export format choices """ - - return [(x, x.upper()) for x in GetExportFormats()] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['file_format'].choices = self.get_choices() - - class BomDuplicateForm(HelperForm): """ Simple confirmation form for BOM duplication. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index cf9d34a44c..820962b613 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -475,9 +475,9 @@ class BomItemSerializer(InvenTreeModelSerializer): validated = serializers.BooleanField(read_only=True, source='is_line_valid') - purchase_price_min = MoneyField(max_digits=10, decimal_places=6, read_only=True) + purchase_price_min = MoneyField(max_digits=19, decimal_places=4, read_only=True) - purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True) + purchase_price_max = MoneyField(max_digits=19, decimal_places=4, read_only=True) purchase_price_avg = serializers.SerializerMethodField() diff --git a/InvenTree/part/templates/part/bom_upload/upload_file.html b/InvenTree/part/templates/part/bom_upload/upload_file.html index 6775176ede..40411f074a 100644 --- a/InvenTree/part/templates/part/bom_upload/upload_file.html +++ b/InvenTree/part/templates/part/bom_upload/upload_file.html @@ -32,7 +32,7 @@
{% trans "Requirements for BOM upload" %}:
@@ -60,4 +60,8 @@ enableSidebar('bom-upload'); -{% endblock js_ready %} +$('#bom-template-download').click(function() { + downloadBomTemplate(); +}); + +{% endblock js_ready %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index f1b47bc4e2..d16de22e1b 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -620,13 +620,7 @@ }); $("#download-bom").click(function () { - launchModalForm("{% url 'bom-export' part.id %}", - { - success: function(response) { - location.href = response.url; - }, - } - ); + exportBom({{ part.id }}); }); {% if report_enabled %} diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 6e742dc571..af35cf9c1f 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -1192,14 +1192,10 @@ class BomExport(AjaxView): """ model = Part - form_class = part_forms.BomExportForm ajax_form_title = _("Export Bill of Materials") role_required = 'part.view' - def get(self, request, *args, **kwargs): - return self.renderJsonResponse(request, self.form_class()) - def post(self, request, *args, **kwargs): # Extract POSTed form data diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index d16575af2b..4fc5330f8b 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -11,7 +11,7 @@ from importlib import reload from django.apps import apps from django.conf import settings from django.db.utils import OperationalError, ProgrammingError -from django.conf.urls import url +from django.conf.urls import url, include from django.urls import clear_url_caches from django.contrib import admin from django.utils.text import slugify @@ -412,7 +412,7 @@ class Plugins: self.plugins_inactive = {} def _update_urls(self): - from InvenTree.urls import urlpatterns + from InvenTree.urls import urlpatterns as global_pattern, frontendpatterns as urlpatterns from plugin.urls import get_plugin_urls for index, a in enumerate(urlpatterns): @@ -421,6 +421,9 @@ class Plugins: urlpatterns[index] = url(r'^admin/', admin.site.urls, name='inventree-admin') elif a.app_name == 'plugin': urlpatterns[index] = get_plugin_urls() + + # replace frontendpatterns + global_pattern[0] = url('', include(urlpatterns)) clear_url_caches() def _reload_apps(self, force_reload: bool = False): diff --git a/InvenTree/templates/InvenTree/settings/login.html b/InvenTree/templates/InvenTree/settings/login.html index 96d986d6c7..b7f32465c6 100644 --- a/InvenTree/templates/InvenTree/settings/login.html +++ b/InvenTree/templates/InvenTree/settings/login.html @@ -16,6 +16,7 @@ {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %} {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %} + {% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %}
{% trans 'Signup' %}
diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index cfc5b421e3..ae04568f53 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -42,193 +42,317 @@ -
-
+
+
+

{% trans "Email" %}

- {% include "spacer.html" %} +
-
-
-
- {% if user.emailaddress_set.all %} -

{% trans 'The following email addresses are associated with your account:' %}

+
+ {% if user.emailaddress_set.all %} +

{% trans 'The following email addresses are associated with your account:' %}

-
+ + {% if can_add_email %} +
+
{% trans "Add Email Address" %}
+ +
+ {% csrf_token %} + {{ add_email_form|crispy }} + +
+
+ {% endif %} +
+ +
+
+

{% trans "Social Accounts" %}

+
+ +
+ {% if social_form.accounts %} +

{% blocktrans %}You can sign in to your account using any of the following third party accounts:{% endblocktrans %}

+ + +
+ {% csrf_token %} + +
+ {% if social_form.non_field_errors %} +
{{ social_form.non_field_errors }}
+ {% endif %} + + {% for base_account in social_form.accounts %} + {% with base_account.get_provider_account as account %} +
+ +
+ {% endwith %} + {% endfor %} + +
+ +
+
+
{% else %} -

{% trans 'Warning:'%} - {% trans "You currently do not have any email address set up. You should really add an email address so you can receive notifications, reset your password, etc." %} -

- +

{% trans 'You currently have no social network accounts connected to this account.' %}

{% endif %}
+ +
+
{% trans 'Add a 3rd Party Account' %}
+
+ {% include "socialaccount/snippets/provider_list.html" with process="connect" %} +
+ {% include "socialaccount/snippets/login_extra.html" %} +
+
+ +
+
+

{% trans "Multifactor" %}

+
+ +
+ {% if user.staticdevice_set.all or user.totpdevice_set.all %} +

{% trans 'You have these factors available:' %}

+ + + + + + + + {% for token in user.totpdevice_set.all %} + + + + + {% endfor %} + {% for token in user.staticdevice_set.all %} + + + + + {% endfor %} + +
TypeName
{% trans 'TOTP' %}{{ token.name }}
{% trans 'Static' %}{{ token.name }}
+ + {% else %} +

{% trans 'Warning:'%} + {% trans "You currently do not have any factors set up." %} +

+ + {% endif %} +
+ +
+
{% trans "Change factors" %}
+ {% trans "Setup multifactor" %} + {% if user.staticdevice_set.all or user.totpdevice_set.all %} + {% trans "Remove multifactor" %} + {% endif %} +
+
+ +
+
+

{% trans "Theme Settings" %}

+
+
- {% if can_add_email %} -
{% trans "Add Email Address" %}
- -
+ {% csrf_token %} - -
-
-

{% trans "Social Accounts" %}

-
- -
- {% if social_form.accounts %} -

{% blocktrans %}You can sign in to your account using any of the following third party accounts:{% endblocktrans %}

- - -
- {% csrf_token %} - -
- {% if social_form.non_field_errors %} -
{{ social_form.non_field_errors }}
- {% endif %} - - {% for base_account in social_form.accounts %} - {% with base_account.get_provider_account as account %} -
- +
+
+

{% trans "Language Settings" %}

- {% endwith %} - {% endfor %} -
- -
- -
- +
+ + {% csrf_token %} + + +
+ +
+ +
+

{% trans "Some languages are not complete" %} + {% if ALL_LANG %} + . {% trans "Show only sufficent" %} + {% else %} + and hidden. {% trans "Show them too" %} + {% endif %} +

+
- - {% else %} -
- {% trans "There are no social network accounts connected to your InvenTree account" %}
- {% endif %} - -
-

{% trans 'Add a 3rd Party Account' %}

-
- {% include "socialaccount/snippets/provider_list.html" with process="connect" %} +
+

{% trans "Help the translation efforts!" %}

+

{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the + InvenTree web application is community contributed via crowdin. Contributions are + welcomed and encouraged.{% endblocktrans %}

- {% include "socialaccount/snippets/login_extra.html" %} -
-
-
-
-

{% trans "Active Sessions" %}

- {% include "spacer.html" %} -
- {% if session_list.count > 1 %} -
- {% csrf_token %} - -
- {% endif %} +
+
+
+

{% trans "Active Sessions" %}

+ {% include "spacer.html" %} +
+ {% if session_list.count > 1 %} +
+ {% csrf_token %} + +
+ {% endif %} +
-
-
- {% trans "unknown on unknown" as unknown_on_unknown %} - {% trans "unknown" as unknown %} - - - - - - - - - {% for object in session_list %} - - - - - - {% endfor %} -
{% trans "IP Address" %}{% trans "Device" %}{% trans "Last Activity" %}
{{ object.ip }}{{ object.user_agent|device|default_if_none:unknown_on_unknown|safe }} - {% if object.session_key == session_key %} - {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago (this session){% endblocktrans %} - {% else %} - {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago{% endblocktrans %} - {% endif %} -
+
+ {% trans "unknown on unknown" as unknown_on_unknown %} + {% trans "unknown" as unknown %} + + + + + + + + + {% for object in session_list %} + + + + + + {% endfor %} +
{% trans "IP Address" %}{% trans "Device" %}{% trans "Last Activity" %}
{{ object.ip }}{{ object.user_agent|device|default_if_none:unknown_on_unknown|safe }} + {% if object.session_key == session_key %} + {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago (this session){% endblocktrans %} + {% else %} + {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago{% endblocktrans %} + {% endif %} +
+
{% endblock %} {% block js_ready %} (function() { - var message = "{% trans 'Do you really want to remove the selected email address?' %}"; - var actions = document.getElementsByName('action_remove'); - if (actions.length) { - actions[0].addEventListener("click", function(e) { - if (! confirm(message)) { - e.preventDefault(); - } - }); - } +var message = "{% trans 'Do you really want to remove the selected email address?' %}"; +var actions = document.getElementsByName('action_remove'); +if (actions.length) { +actions[0].addEventListener("click", function(e) { +if (! confirm(message)) { +e.preventDefault(); +} +}); +} })(); {% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/allauth_2fa/authenticate.html b/InvenTree/templates/allauth_2fa/authenticate.html new file mode 100644 index 0000000000..1a392fc44a --- /dev/null +++ b/InvenTree/templates/allauth_2fa/authenticate.html @@ -0,0 +1,15 @@ +{% extends "account/base.html" %} +{% load i18n crispy_forms_tags %} + +{% block content %} +

{% trans "Two-Factor Authentication" %}

+ + +{% endblock %} diff --git a/InvenTree/templates/allauth_2fa/backup_tokens.html b/InvenTree/templates/allauth_2fa/backup_tokens.html new file mode 100644 index 0000000000..2fe30e8222 --- /dev/null +++ b/InvenTree/templates/allauth_2fa/backup_tokens.html @@ -0,0 +1,33 @@ +{% extends "account/base.html" %} +{% load i18n %} + +{% block content %} +

+ {% trans "Two-Factor Authentication Backup Tokens" %} +

+ +{% if backup_tokens %} + {% if reveal_tokens %} +
    + {% for token in backup_tokens %} +
  • {{ token.token }}
  • + {% endfor %} +
+ {% else %} + {% trans 'Backup tokens have been generated, but are not revealed here for security reasons. Press the button below to generate new ones.' %} + {% endif %} +{% else %} + {% trans 'No tokens. Press the button below to generate some.' %} +{% endif %} + +
+
+ {% csrf_token %} + +
+
+{% trans "back to settings" %} + +{% endblock %} diff --git a/InvenTree/templates/allauth_2fa/remove.html b/InvenTree/templates/allauth_2fa/remove.html new file mode 100644 index 0000000000..fb9d15ae32 --- /dev/null +++ b/InvenTree/templates/allauth_2fa/remove.html @@ -0,0 +1,18 @@ +{% extends "account/base.html" %} +{% load i18n %} + +{% block content %} +

+ {% trans "Disable Two-Factor Authentication" %} +

+ +

{% trans "Are you sure?" %}

+ +
+ {% csrf_token %} + +
+ +{% endblock %} diff --git a/InvenTree/templates/allauth_2fa/setup.html b/InvenTree/templates/allauth_2fa/setup.html new file mode 100644 index 0000000000..ce795a3c98 --- /dev/null +++ b/InvenTree/templates/allauth_2fa/setup.html @@ -0,0 +1,42 @@ +{% extends "account/base.html" %} +{% load i18n crispy_forms_tags %} + +{% block content %} +

+ {% trans "Setup Two-Factor Authentication" %} +

+ +

+ {% trans 'Step 1' %}: +

+ +

+ {% trans 'Scan the QR code below with a token generator of your choice (for instance Google Authenticator).' %} +

+ +
+ +
+
+ +

+ {% trans 'Step 2' %}: +

+ +

+ {% trans 'Input a token generated by the app:' %} +

+ +
+ {% csrf_token %} + {{ form|crispy }} + + +
+ + +{% endblock %} diff --git a/InvenTree/templates/js/dynamic/nav.js b/InvenTree/templates/js/dynamic/nav.js index 28f9a26d8d..eed098f162 100644 --- a/InvenTree/templates/js/dynamic/nav.js +++ b/InvenTree/templates/js/dynamic/nav.js @@ -175,7 +175,6 @@ function enableBreadcrumbTree(options) { for (var i = 0; i < data.length; i++) { node = data[i]; - node.nodes = []; nodes[node.pk] = node; node.selectable = false; @@ -193,10 +192,17 @@ function enableBreadcrumbTree(options) { node = data[i]; if (node.parent != null) { - nodes[node.parent].nodes.push(node); + if (nodes[node.parent].nodes) { + nodes[node.parent].nodes.push(node); + } else { + nodes[node.parent].nodes = [node]; + } if (node.state.expanded) { - nodes[node.parent].state.expanded = true; + while (node.parent != null) { + nodes[node.parent].state.expanded = true; + node = nodes[node.parent]; + } } } else { @@ -212,7 +218,6 @@ function enableBreadcrumbTree(options) { collapseIcon: 'fa fa-chevron-down', }); - setBreadcrumbTreeState(label, state); } } ); @@ -220,26 +225,11 @@ function enableBreadcrumbTree(options) { $('#breadcrumb-tree-toggle').click(function() { // Add callback to "collapse" and "expand" the sidebar - // By default, the menu is "expanded" - var state = localStorage.getItem(`inventree-tree-state-${label}`) || 'expanded'; + // Toggle treeview visibilty + $('#breadcrumb-tree-collapse').toggle(); - // We wish to "toggle" the state! - setBreadcrumbTreeState(label, state == 'expanded' ? 'collapsed' : 'expanded'); }); - // Set the initial state (default = expanded) - var state = localStorage.getItem(`inventree-tree-state-${label}`) || 'expanded'; - - function setBreadcrumbTreeState(label, state) { - - if (state == 'collapsed') { - $('#breadcrumb-tree-collapse').hide(100); - } else { - $('#breadcrumb-tree-collapse').show(100); - } - - localStorage.setItem(`inventree-tree-state-${label}`, state); - } } /* diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 3cde5bca61..ffd8195e07 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -2,6 +2,7 @@ /* globals constructForm, + exportFormatOptions, imageHoverIcon, inventreeGet, inventreePut, @@ -14,6 +15,8 @@ */ /* exported + downloadBomTemplate, + exportBom, newPartFromBomWizard, loadBomTable, loadUsedInTable, @@ -21,12 +24,121 @@ removeColFromBomWizard, */ -/* BOM management functions. - * Requires follwing files to be loaded first: - * - api.js - * - part.js - * - modals.js +function downloadBomTemplate(options={}) { + + var format = options.format; + + if (!format) { + format = inventreeLoad('bom-export-format', 'csv'); + } + + constructFormBody({}, { + title: '{% trans "Download BOM Template" %}', + fields: { + format: { + label: '{% trans "Format" %}', + help_text: '{% trans "Select file format" %}', + required: true, + type: 'choice', + value: format, + choices: exportFormatOptions(), + } + }, + onSubmit: function(fields, opts) { + var format = getFormFieldValue('format', fields['format'], opts); + + // Save the format for next time + inventreeSave('bom-export-format', format); + + // Hide the modal + $(opts.modal).modal('hide'); + + // Download the file + location.href = `{% url "bom-upload-template" %}?format=${format}`; + + } + }); +} + + +/** + * Export BOM (Bill of Materials) for the specified Part instance */ +function exportBom(part_id, options={}) { + + constructFormBody({}, { + title: '{% trans "Export BOM" %}', + fields: { + format: { + label: '{% trans "Format" %}', + help_text: '{% trans "Select file format" %}', + required: true, + type: 'choice', + value: inventreeLoad('bom-export-format', 'csv'), + choices: exportFormatOptions(), + }, + cascading: { + label: '{% trans "Cascading" %}', + help_text: '{% trans "Download cascading / multi-level BOM" %}', + type: 'boolean', + value: inventreeLoad('bom-export-cascading', true), + }, + levels: { + label: '{% trans "Levels" %}', + help_text: '{% trans "Select maximum number of BOM levels to export (0 = all levels)" %}', + type: 'integer', + value: 0, + min_value: 0, + }, + parameter_data: { + label: '{% trans "Include Parameter Data" %}', + help_text: '{% trans "Include part parameter data in exported BOM" %}', + type: 'boolean', + value: inventreeLoad('bom-export-parameter_data', false), + }, + stock_data: { + label: '{% trans "Include Stock Data" %}', + help_text: '{% trans "Include part stock data in exported BOM" %}', + type: 'boolean', + value: inventreeLoad('bom-export-stock_data', false), + }, + manufacturer_data: { + label: '{% trans "Include Manufacturer Data" %}', + help_text: '{% trans "Include part manufacturer data in exported BOM" %}', + type: 'boolean', + value: inventreeLoad('bom-export-manufacturer_data', false), + }, + supplier_data: { + label: '{% trans "Include Supplier Data" %}', + help_text: '{% trans "Include part supplier data in exported BOM" %}', + type: 'boolean', + value: inventreeLoad('bom-export-supplier_data', false), + } + }, + onSubmit: function(fields, opts) { + + // Extract values from the form + var field_names = ['format', 'cascading', 'levels', 'parameter_data', 'stock_data', 'manufacturer_data', 'supplier_data']; + + var url = `/part/${part_id}/bom-download/?`; + + field_names.forEach(function(fn) { + var val = getFormFieldValue(fn, fields[fn], opts); + + // Update user preferences + inventreeSave(`bom-export-${fn}`, val); + + url += `${fn}=${val}&`; + }); + + $(opts.modal).modal('hide'); + + // Redirect to the BOM file download + location.href = url; + } + }); + +} function bomItemFields() { diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index def7e41358..43e8d5ce62 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -811,7 +811,9 @@ function updateFieldValue(name, value, field, options) { switch (field.type) { case 'boolean': - el.prop('checked', value); + if (value == true || value.toString().toLowerCase() == 'true') { + el.prop('checked'); + } break; case 'related field': // Clear? @@ -2034,8 +2036,15 @@ function constructInputOptions(name, classes, type, parameters) { } if (parameters.value != null) { - // Existing value? - opts.push(`value='${parameters.value}'`); + if (parameters.type == 'boolean') { + // Special consideration of a boolean (checkbox) value + if (parameters.value == true || parameters.value.toString().toLowerCase() == 'true') { + opts.push('checked'); + } + } else { + // Existing value? + opts.push(`value='${parameters.value}'`); + } } else if (parameters.default != null) { // Otherwise, a defualt value? opts.push(`value='${parameters.default}'`); diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index b09097a3bf..3339907987 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -73,6 +73,9 @@ class RuleSet(models.Model): 'socialaccount_socialaccount', 'socialaccount_socialapp', 'socialaccount_socialtoken', + 'otp_totp_totpdevice', + 'otp_static_statictoken', + 'otp_static_staticdevice', 'plugin_pluginconfig' ], 'part_category': [ @@ -237,7 +240,7 @@ class RuleSet(models.Model): given the app_model name, and the permission type. """ - app, model = model.split('_') + model, app = split_model(model) return "{app}.{perm}_{model}".format( app=app, @@ -280,6 +283,30 @@ class RuleSet(models.Model): return self.RULESET_MODELS.get(self.name, []) +def split_model(model): + """get modelname and app from modelstring""" + *app, model = model.split('_') + + # handle models that have + if len(app) > 1: + app = '_'.join(app) + else: + app = app[0] + + return model, app + + +def split_permission(app, perm): + """split permission string into permission and model""" + permission_name, *model = perm.split('_') + # handle models that have underscores + if len(model) > 1: + app += '_' + '_'.join(model[:-1]) + perm = permission_name + '_' + model[-1:][0] + model = model[-1:][0] + return perm, model + + def update_group_roles(group, debug=False): """ @@ -383,7 +410,7 @@ def update_group_roles(group, debug=False): (app, perm) = permission_string.split('.') - (permission_name, model) = perm.split('_') + perm, model = split_permission(app, perm) try: content_type = ContentType.objects.get(app_label=app, model=model) diff --git a/requirements.txt b/requirements.txt index 8b4fdfe640..c6cc4effff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Please keep this list sorted -Django==3.2.5 # Django package +Django==3.2.10 # Django package certifi # Certifi is (most likely) installed through one of the requirements above coreapi==2.3.0 # API documentation coverage==5.3 # Unit test coverage @@ -7,6 +7,7 @@ coveralls==2.1.2 # Coveralls linking (for Travis) cryptography==3.4.8 # Cryptography support django-admin-shell==0.1.2 # Python shell for the admin interface django-allauth==0.45.0 # SSO for external providers via OpenID +django-allauth-2fa==0.8 # MFA / 2FA django-cleanup==5.1.0 # Manage deletion of old / unused uploaded files django-cors-headers==3.2.0 # CORS headers extension for DRF django-crispy-forms==1.11.2 # Form helpers diff --git a/tasks.py b/tasks.py index 7408bb40b5..4d5d7ff6c8 100644 --- a/tasks.py +++ b/tasks.py @@ -154,6 +154,18 @@ def clean_settings(c): manage(c, "clean_settings") +@task(help={'mail': 'mail of the user whos MFA should be disabled'}) +def remove_mfa(c, mail=''): + """ + Remove MFA for a user + """ + + if not mail: + print('You must provide a users mail') + + manage(c, f"remove_mfa {mail}") + + @task(post=[rebuild_models, rebuild_thumbnails]) def migrate(c): """