Merge pull request #2221 from matmair/matmair/issue2201

2FA with TOTP
This commit is contained in:
Oliver 2021-12-12 21:04:41 +11:00 committed by GitHub
commit a56cf5ce49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 561 additions and 164 deletions

View File

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

View File

@ -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])}')

View File

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

View File

@ -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',
])

View File

@ -121,15 +121,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)),
@ -139,30 +150,22 @@ 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'^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', ),
@ -173,7 +176,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<uidb36>[0-9A-Za-z]+)-(?P<key>.+)/$", 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?

View File

@ -957,6 +957,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'),

View File

@ -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):

View File

@ -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" %}
<tr>
<th><h5>{% trans 'Signup' %}</h5></th>
<td colspan='4'></td>

View File

@ -42,193 +42,317 @@
</tr>
</table>
<div class='panel-heading'>
<div class='d-flex flex-span'>
<div class="row">
<div class='panel-heading'>
<div class='d-flex flex-span'>
<h4>{% trans "Email" %}</h4>
{% include "spacer.html" %}
</div>
</div>
</div>
<div class='row'>
<div class='col-sm-6'>
{% if user.emailaddress_set.all %}
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
<div class="col-sm-6">
{% if user.emailaddress_set.all %}
<p>{% trans 'The following email addresses are associated with your account:' %}</p>
<form action="{% url 'account_email' %}" class="email_list" method="post">
<form action="{% url 'account_email' %}" class="email_list" method="post">
{% csrf_token %}
<fieldset class="blockLabels">
<fieldset class="blockLabels">
{% for emailaddress in user.emailaddress_set.all %}
<div>
<div class="ctrlHolder">
<label for="email_radio_{{forloop.counter}}" class="{% if emailaddress.primary %}primary_email{%endif%}">
<input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked"{%endif %} value="{{emailaddress.email}}"/>
{% if emailaddress.primary %}
<b>{{ emailaddress.email }}</b>
{% else %}
{{ emailaddress.email }}
{% endif %}
</label>
{% if emailaddress.verified %}
<span class='badge badge-right rounded-pill bg-success'>{% trans "Verified" %}</span>
<div class="ctrlHolder">
<label for="email_radio_{{forloop.counter}}" class="{% if emailaddress.primary %}primary_email{%endif%}">
<input id="email_radio_{{forloop.counter}}" type="radio" name="email" {% if emailaddress.primary or user.emailaddress_set.count == 1 %}checked="checked" {%endif %} value="{{emailaddress.email}}" />
{% if emailaddress.primary %}
<b>{{ emailaddress.email }}</b>
{% else %}
<span class='badge badge-right rounded-pill bg-warning'>{% trans "Unverified" %}</span>
{{ emailaddress.email }}
{% endif %}
{% if emailaddress.primary %}<span class='badge badge-right rounded-pill bg-primary'>{% trans "Primary" %}</span>{% endif %}
</div>
</label>
{% if emailaddress.verified %}
<span class='badge badge-right rounded-pill bg-success'>{% trans "Verified" %}</span>
{% else %}
<span class='badge badge-right rounded-pill bg-warning'>{% trans "Unverified" %}</span>
{% endif %}
{% if emailaddress.primary %}<span class='badge badge-right rounded-pill bg-primary'>{% trans "Primary" %}</span>{% endif %}
</div>
</div>
{% endfor %}
<div class="buttonHolder">
<button class="btn btn-primary secondaryAction" type="submit" name="action_primary" >{% trans 'Make Primary' %}</button>
<button class="btn btn-primary secondaryAction" type="submit" name="action_primary">{% trans 'Make Primary' %}</button>
<button class="btn btn-primary secondaryAction" type="submit" name="action_send" {% if not mail_conf %}disabled{% endif %}>{% trans 'Re-send Verification' %}</button>
<button class="btn btn-primary primaryAction" type="submit" name="action_remove" >{% trans 'Remove' %}</button>
<button class="btn btn-primary primaryAction" type="submit" name="action_remove">{% trans 'Remove' %}</button>
</div>
</fieldset>
</form>
{% else %}
<div class='alert alert-block alert-danger'>
<strong>{% trans 'Warning:'%}</strong>
{% 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." %}
</div>
{% endif %}
</div>
{% if can_add_email %}
<div class="col-sm-6">
<h5>{% trans "Add Email Address" %}</h5>
<form method="post" action="{% url 'account_email' %}" class="add_email">
{% csrf_token %}
{{ add_email_form|crispy }}
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add Email" %}</button>
</form>
</div>
{% endif %}
</div>
<div class="row">
<div class='panel-heading'>
<h4>{% trans "Social Accounts" %}</h4>
</div>
<div class="col-md-6">
{% if social_form.accounts %}
<p>{% blocktrans %}You can sign in to your account using any of the following third party accounts:{% endblocktrans %}</p>
<form method="post" action="{% url 'socialaccount_connections' %}">
{% csrf_token %}
<fieldset>
{% if social_form.non_field_errors %}
<div id="errorMsg">{{ social_form.non_field_errors }}</div>
{% endif %}
{% for base_account in social_form.accounts %}
{% with base_account.get_provider_account as account %}
<div>
<label for="id_account_{{ base_account.id }}">
<input id="id_account_{{ base_account.id }}" type="radio" name="account"
value="{{ base_account.id }}" />
<span class="socialaccount_provider {{ base_account.provider }} {{ account.get_brand.id }}">
<span class='brand-icon'
brand_name='{{account.get_brand.id}}'></span>{{account.get_brand.name}}</span>
{{ account }}
</label>
</div>
{% endwith %}
{% endfor %}
<div>
<button class="btn btn-primary" type="submit">{% trans 'Remove' %}</button>
</div>
</fieldset>
</form>
{% else %}
<p><strong>{% trans 'Warning:'%}</strong>
{% 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." %}
</p>
<p>{% trans 'You currently have no social network accounts connected to this account.' %}</p>
{% endif %}
</div>
<div class="col-md-6">
<h5>{% trans 'Add a 3rd Party Account' %}</h5>
<div>
{% include "socialaccount/snippets/provider_list.html" with process="connect" %}
</div>
{% include "socialaccount/snippets/login_extra.html" %}
</div>
</div>
<div class="row">
<div class='panel-heading'>
<h4>{% trans "Multifactor" %}</h4>
</div>
<div class="col-md-6">
{% if user.staticdevice_set.all or user.totpdevice_set.all %}
<p>{% trans 'You have these factors available:' %}</p>
<table class="table table-striped">
<thead>
<th>Type</th>
<th>Name</th>
</thead>
<tbody>
{% for token in user.totpdevice_set.all %}
<tr>
<td>{% trans 'TOTP' %}</td>
<td>{{ token.name }}</td>
</tr>
{% endfor %}
{% for token in user.staticdevice_set.all %}
<tr>
<td>{% trans 'Static' %}</td>
<td>{{ token.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p><strong>{% trans 'Warning:'%}</strong>
{% trans "You currently do not have any factors set up." %}
</p>
{% endif %}
</div>
<div class="col-md-6">
<h5>{% trans "Change factors" %}</h5>
<a href="{% url 'two-factor-setup' %}" class="btn btn-primary {% if user.staticdevice_set.all and user.totpdevice_set.all %}disabled{% endif %}" role="button">{% trans "Setup multifactor" %}</a>
{% if user.staticdevice_set.all or user.totpdevice_set.all %}
<a href="{% url 'two-factor-remove' %}" class="btn btn-primary" role="button">{% trans "Remove multifactor" %}</a>
{% endif %}
</div>
</div>
<div class='row'>
<div class='panel-heading'>
<h4>{% trans "Theme Settings" %}</h4>
</div>
<div class='col-sm-6'>
{% if can_add_email %}
<h5>{% trans "Add Email Address" %}</h5>
<form method="post" action="{% url 'account_email' %}" class="add_email">
<form action='{% url "settings-appearance" %}' method='post'>
{% csrf_token %}
<label for="id_email" class=" requiredField">
E-mail<span class="asteriskField">*</span>
<input name='next' type='hidden' value='{% url "settings" %}'>
<label for='theme' class=' requiredField'>
{% trans "Select theme" %}
</label>
<div id="div_id_email" class="form-group input-group mb-3">
<div class='input-group-prepend'><span class='input-group-text'>@</span></div>
<input type="email" name="email" placeholder='{% trans "Enter e-mail address" %}' class="textinput textInput form-control" required="" id="id_email">
<div class='form-group input-group mb-3'>
<select id='theme' name='theme' class='select form-control'>
{% get_available_themes as themes %}
{% for theme in themes %}
<option value='{{ theme.key }}'>{{ theme.name }}</option>
{% endfor %}
</select>
<div class='input-group-append'>
<button class="btn btn-primary" name="action_add" type="submit">{% trans "Add Email" %}</button>
<input type="submit" value="{% trans 'Set Theme' %}" class="btn btn-primary">
</div>
</div>
</form>
{% endif %}
</div>
</div>
<div class='panel-heading'>
<h4>{% trans "Social Accounts" %}</h4>
</div>
<div>
{% if social_form.accounts %}
<p>{% blocktrans %}You can sign in to your account using any of the following third party accounts:{% endblocktrans %}</p>
<form method="post" action="{% url 'socialaccount_connections' %}">
{% csrf_token %}
<fieldset>
{% if social_form.non_field_errors %}
<div id="errorMsg">{{ social_form.non_field_errors }}</div>
{% endif %}
{% for base_account in social_form.accounts %}
{% with base_account.get_provider_account as account %}
<div>
<label for="id_account_{{ base_account.id }}">
<input id="id_account_{{ base_account.id }}" type="radio" name="account" value="{{ base_account.id }}"/>
<span class="socialaccount_provider {{ base_account.provider }} {{ account.get_brand.id }}">
<span class='brand-icon' brand_name='{{account.get_brand.id}}'></span>{{account.get_brand.name}}</span>
{{ account }}
</label>
<div class="row">
<div class='panel-heading'>
<h4>{% trans "Language Settings" %}</h4>
</div>
{% endwith %}
{% endfor %}
<div>
<button class="btn btn-primary" type="submit">{% trans 'Remove' %}</button>
</div>
</fieldset>
<div class="col">
<form action="{% url 'set_language' %}" method="post">
{% csrf_token %}
<input name="next" type="hidden" value="{% url 'settings' %}">
<label for='language' class=' requiredField'>
{% trans "Select language" %}
</label>
<div class='form-group input-group mb-3'>
<select name="language" class="select form-control w-25">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% if 'alllang' in request.GET %}{% define True as ALL_LANG %}{% endif %}
{% for language in languages %}
{% define language.code as lang_code %}
{% define locale_stats|keyvalue:lang_code as lang_translated %}
{% if lang_translated > 10 or lang_code == 'en' or lang_code == LANGUAGE_CODE %}{% define True as use_lang %}{% else %}{% define False as use_lang %}{% endif %}
{% if ALL_LANG or use_lang %}
<option value="{{ lang_code }}"{% if lang_code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ lang_code }})
{% if lang_translated %}
{% blocktrans %}{{ lang_translated }}% translated{% endblocktrans %}
{% else %}
{% if lang_code == 'en' %}-{% else %}{% trans 'No translations available' %}{% endif %}
{% endif %}
</option>
{% endif %}
{% endfor %}
</select>
<div class='input-group-append'>
<input type="submit" value="{% trans 'Set Language' %}" class="btn btn btn-primary">
</div>
<p>{% trans "Some languages are not complete" %}
{% if ALL_LANG %}
. <a href="{% url 'settings' %}">{% trans "Show only sufficent" %}</a>
{% else %}
and hidden. <a href="?alllang">{% trans "Show them too" %}</a>
{% endif %}
</p>
</div>
</form>
{% else %}
<div class='alert alert-block alert-warning'>
{% trans "There are no social network accounts connected to your InvenTree account" %}
</div>
{% endif %}
<br>
<h4>{% trans 'Add a 3rd Party Account' %}</h4>
<div>
{% include "socialaccount/snippets/provider_list.html" with process="connect" %}
<div class="col-sm-6">
<h4>{% trans "Help the translation efforts!" %}</h4>
<p>{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the
InvenTree web application is <a href="{{link}}">community contributed via crowdin</a>. Contributions are
welcomed and encouraged.{% endblocktrans %}</p>
</div>
{% include "socialaccount/snippets/login_extra.html" %}
<br>
</div>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Active Sessions" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if session_list.count > 1 %}
<form method="post" action="{% url 'session_delete_other' %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-default btn-danger" title='{% trans "Log out active sessions (except this one)" %}'>
{% trans "Log Out Active Sessions" %}
</button>
</form>
{% endif %}
<div class="row">
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Active Sessions" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if session_list.count > 1 %}
<form method="post" action="{% url 'session_delete_other' %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-default btn-danger" title='{% trans "Log out active sessions (except this one)" %}'>
{% trans "Log Out Active Sessions" %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
<div>
{% trans "<em>unknown on unknown</em>" as unknown_on_unknown %}
{% trans "<em>unknown</em>" as unknown %}
<table class="table table-striped table-condensed">
<thead>
<tr>
<th>{% trans "IP Address" %}</th>
<th>{% trans "Device" %}</th>
<th>{% trans "Last Activity" %}</th>
</tr>
</thead>
{% for object in session_list %}
<tr {% if object.session_key == session_key %}class="active"{% endif %}>
<td>{{ object.ip }}</td>
<td>{{ object.user_agent|device|default_if_none:unknown_on_unknown|safe }}</td>
<td>
{% 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 %}
</td>
</tr>
{% endfor %}
</table>
<div>
{% trans "<em>unknown on unknown</em>" as unknown_on_unknown %}
{% trans "<em>unknown</em>" as unknown %}
<table class="table table-striped table-condensed">
<thead>
<tr>
<th>{% trans "IP Address" %}</th>
<th>{% trans "Device" %}</th>
<th>{% trans "Last Activity" %}</th>
</tr>
</thead>
{% for object in session_list %}
<tr {% if object.session_key == session_key %}class="active"{% endif %}>
<td>{{ object.ip }}</td>
<td>{{ object.user_agent|device|default_if_none:unknown_on_unknown|safe }}</td>
<td>
{% 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 %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% 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 %}

View File

@ -0,0 +1,15 @@
{% extends "account/base.html" %}
{% load i18n crispy_forms_tags %}
{% block content %}
<h1>{% trans "Two-Factor Authentication" %}</h1>
<form method="post" class="login">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary">
{% trans 'Authenticate' %}
</button>
</form>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "account/base.html" %}
{% load i18n %}
{% block content %}
<h3>
{% trans "Two-Factor Authentication Backup Tokens" %}
</h3>
{% if backup_tokens %}
{% if reveal_tokens %}
<ul>
{% for token in backup_tokens %}
<li>{{ token.token }}</li>
{% endfor %}
</ul>
{% 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 %}
<br>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-primary w-100">
{% trans 'Generate backup tokens' %}
</button>
</form>
<br>
<a href="{% url 'settings' %}" class="btn btn-secondary w-100 btn-sm">{% trans "back to settings" %}</a>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "account/base.html" %}
{% load i18n %}
{% block content %}
<h3>
{% trans "Disable Two-Factor Authentication" %}
</h3>
<p>{% trans "Are you sure?" %}</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger w-100">
{% trans 'Disable Two-Factor' %}
</button>
</form>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "account/base.html" %}
{% load i18n crispy_forms_tags %}
{% block content %}
<h3>
{% trans "Setup Two-Factor Authentication" %}
</h3>
<h4>
{% trans 'Step 1' %}:
</h4>
<p>
{% trans 'Scan the QR code below with a token generator of your choice (for instance Google Authenticator).' %}
</p>
<div class="bg-light rounded">
<img src="{{ qr_code_url }}" class="mx-auto d-block"/>
</div>
<br>
<h4>
{% trans 'Step 2' %}:
</h4>
<p>
{% trans 'Input a token generated by the app:' %}
</p>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary btn-block w-100">
{% trans 'Verify' %}
</button>
</form>
<div>
<a href="{% url 'settings' %}" class="btn btn-secondary w-100 btn-sm mt-3">{% trans "back to settings" %}</a>
</div>
{% endblock %}

View File

@ -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': [
@ -236,7 +239,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,
@ -279,6 +282,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):
"""
@ -382,7 +409,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)

View File

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

View File

@ -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):
"""