Move Factor instances to database

This commit is contained in:
Jens Langhammer 2019-02-16 09:52:37 +01:00
parent 57e5996513
commit 59a15c988f
19 changed files with 281 additions and 34 deletions

View file

@ -17,6 +17,9 @@
<li class="{% is_active 'passbook_admin:providers' 'passbook_admin:provider-create' 'passbook_admin:provider-update' 'passbook_admin:provider-delete' %}">
<a href="{% url 'passbook_admin:providers' %}">{% trans 'Providers' %}</a>
</li>
<li class="{% is_active 'passbook_admin:factors' 'passbook_admin:factor-create' 'passbook_admin:factor-update' 'passbook_admin:factor-delete' %}">
<a href="{% url 'passbook_admin:factors' %}">{% trans 'Factors' %}</a>
</li>
<li class="{% is_active 'passbook_admin:rules' 'passbook_admin:rule-create' 'passbook_admin:rule-update' 'passbook_admin:rule-delete' 'passbook_admin:rule-test' %}">
<a href="{% url 'passbook_admin:rules' %}">{% trans 'Rules' %}</a>
</li>

View file

@ -0,0 +1,48 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load utils %}
{% load admin_reflection %}
{% block title %}
{% title %}
{% endblock %}
{% block content %}
<div class="container">
<h1>{% trans "Factors" %}</h1>
<a href="{% url 'passbook_admin:factor-create' %}" class="btn btn-primary">
{% trans 'Create...' %}
</a>
<hr>
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>{% trans 'Name' %}</th>
<th>{% trans 'Type' %}</th>
<th>{% trans 'Order' %}</th>
<th>{% trans 'Enabled?' %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for factor in object_list %}
<tr>
<td>{{ factor.name }} ({{ factor.slug }})</td>
<td>{{ factor.type }}</td>
<td>{{ factor.order }}</td>
<td>{{ factor.enabled }}</td>
<td>
<a class="btn btn-default btn-sm" href="{% url 'passbook_admin:factor-update' pk=factor.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
<a class="btn btn-default btn-sm" href="{% url 'passbook_admin:factor-delete' pk=factor.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
{% get_links factor as links %}
{% for name, href in links.items %}
<a class="btn btn-default btn-sm" href="{{ href }}?back={{ request.get_full_path }}">{% trans name %}</a>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -2,8 +2,9 @@
from django.urls import include, path
from rest_framework_swagger.views import get_swagger_view
from passbook.admin.views import (applications, audit, groups, invitations,
overview, providers, rules, sources, users)
from passbook.admin.views import (applications, audit, factors, groups,
invitations, overview, providers, rules,
sources, users)
schema_view = get_swagger_view(title='passbook Admin Internal API')
@ -38,6 +39,14 @@ urlpatterns = [
providers.ProviderUpdateView.as_view(), name='provider-update'),
path('providers/<int:pk>/delete/',
providers.ProviderDeleteView.as_view(), name='provider-delete'),
# Factors
path('factors/', factors.FactorListView.as_view(), name='factors'),
path('factors/create/',
factors.FactorCreateView.as_view(), name='factor-create'),
path('factors/<uuid:pk>/update/',
factors.FactorUpdateView.as_view(), name='factor-update'),
path('factors/<uuid:pk>/delete/',
factors.FactorDeleteView.as_view(), name='factor-delete'),
# Invitations
path('invitations/', invitations.InvitationListView.as_view(), name='invitations'),
path('invitations/create/',

View file

@ -0,0 +1,50 @@
"""passbook Factor administration"""
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.forms.factor import FactorForm
from passbook.core.models import Factor
class FactorListView(AdminRequiredMixin, ListView):
"""Show list of all factors"""
model = Factor
template_name = 'administration/factor/list.html'
ordering = 'order'
def get_context_data(self, **kwargs):
kwargs['types'] = {
x.__name__: x._meta.verbose_name for x in Factor.__subclasses__()}
return super().get_context_data(**kwargs)
class FactorCreateView(SuccessMessageMixin, AdminRequiredMixin, CreateView):
"""Create new Factor"""
template_name = 'generic/create.html'
success_url = reverse_lazy('passbook_admin:factors')
success_message = _('Successfully created Factor')
form_class = FactorForm
class FactorUpdateView(SuccessMessageMixin, AdminRequiredMixin, UpdateView):
"""Update factor"""
model = Factor
template_name = 'generic/update.html'
success_url = reverse_lazy('passbook_admin:factors')
success_message = _('Successfully updated Factor')
form_class = FactorForm
class FactorDeleteView(SuccessMessageMixin, AdminRequiredMixin, DeleteView):
"""Delete factor"""
model = Factor
template_name = 'generic/delete.html'
success_url = reverse_lazy('passbook_admin:factors')
success_message = _('Successfully updated Factor')

View file

@ -4,8 +4,10 @@ from django.views.generic import FormView
from passbook.captcha_factor.forms import CaptchaForm
from passbook.core.auth.factor import AuthenticationFactor
from passbook.core.auth.factor_manager import MANAGER
@MANAGER.factor()
class CaptchaFactor(FormView, AuthenticationFactor):
"""Simple captcha checker, logic is handeled in django-captcha module"""

View file

@ -1,8 +1,12 @@
"""passbook core app config"""
from importlib import import_module
from logging import getLogger
from django.apps import AppConfig
from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__)
class PassbookCoreConfig(AppConfig):
"""passbook core app config"""
@ -13,3 +17,10 @@ class PassbookCoreConfig(AppConfig):
def ready(self):
import_module('passbook.core.rules')
factors_to_load = CONFIG.y('passbook.factors', [])
for factors_to_load in factors_to_load:
try:
import_module(factors_to_load)
LOGGER.info("Loaded %s", factors_to_load)
except ImportError as exc:
LOGGER.debug(exc)

View file

@ -0,0 +1,25 @@
"""Authentication Factor Manager"""
from logging import getLogger
LOGGER = getLogger(__name__)
class AuthenticationFactorManager:
"""Manager to hold all Factors."""
__factors = []
def factor(self):
"""Class decorator to register classes inline."""
def inner_wrapper(cls):
self.__factors.append(cls)
LOGGER.debug("Registered factor '%s'", cls.__name__)
return cls
return inner_wrapper
@property
def all(self):
"""Get list of all registered factors"""
return self.__factors
MANAGER = AuthenticationFactorManager()

View file

View file

@ -8,13 +8,15 @@ from django.utils.translation import gettext as _
from django.views.generic import FormView
from passbook.core.auth.factor import AuthenticationFactor
from passbook.core.auth.mfa import MultiFactorAuthenticator
from passbook.core.auth.factor_manager import MANAGER
from passbook.core.auth.view import AuthenticationView
from passbook.core.forms.authentication import AuthenticationBackendFactorForm
from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__)
@MANAGER.factor()
class AuthenticationBackendFactor(FormView, AuthenticationFactor):
"""Authentication factor which authenticates against django's AuthBackend"""
@ -34,7 +36,7 @@ class AuthenticationBackendFactor(FormView, AuthenticationFactor):
if user:
# User instance returned from authenticate() has .backend property set
self.authenticator.pending_user = user
self.request.session[MultiFactorAuthenticator.SESSION_USER_BACKEND] = user.backend
self.request.session[AuthenticationView.SESSION_USER_BACKEND] = user.backend
return self.authenticator.user_ok()
# No user was found -> invalid credentials
LOGGER.debug("Invalid credentials")

View file

@ -2,10 +2,12 @@
from logging import getLogger
from passbook.core.auth.factor import AuthenticationFactor
from passbook.core.auth.factor_manager import MANAGER
LOGGER = getLogger(__name__)
@MANAGER.factor()
class DummyFactor(AuthenticationFactor):
"""Dummy factor for testing with multiple factors"""

View file

@ -1,20 +1,19 @@
"""passbook multi-factor authentication engine"""
from logging import getLogger
from django.conf import settings
from django.contrib.auth import login
from django.contrib.auth.mixins import UserPassesTestMixin
from django.shortcuts import get_object_or_404, redirect, reverse
from django.views.generic import View
from passbook.core.models import User
from passbook.core.models import Factor, User
from passbook.core.views.utils import PermissionDeniedView
from passbook.lib.utils.reflection import class_to_path, path_to_class
LOGGER = getLogger(__name__)
class MultiFactorAuthenticator(UserPassesTestMixin, View):
class AuthenticationView(UserPassesTestMixin, View):
"""Wizard-like Multi-factor authenticator"""
SESSION_FACTOR = 'passbook_factor'
@ -25,8 +24,6 @@ class MultiFactorAuthenticator(UserPassesTestMixin, View):
pending_user = None
pending_factors = []
factors = settings.AUTHENTICATION_FACTORS.copy()
_current_factor = None
# Allow only not authenticated users to login
@ -34,29 +31,38 @@ class MultiFactorAuthenticator(UserPassesTestMixin, View):
return self.request.user.is_authenticated is False
def handle_no_permission(self):
# Function from UserPassesTestMixin
if 'next' in self.request.GET:
return redirect(self.request.GET.get('next'))
return redirect(reverse('passbook_core:overview'))
def dispatch(self, request, *args, **kwargs):
# Extract pending user from session (only remember uid)
if MultiFactorAuthenticator.SESSION_PENDING_USER in request.session:
if AuthenticationView.SESSION_PENDING_USER in request.session:
self.pending_user = get_object_or_404(
User, id=self.request.session[MultiFactorAuthenticator.SESSION_PENDING_USER])
User, id=self.request.session[AuthenticationView.SESSION_PENDING_USER])
else:
# No Pending user, redirect to login screen
return redirect(reverse('passbook_core:auth-login'))
# Write pending factors to session
if MultiFactorAuthenticator.SESSION_PENDING_FACTORS in request.session:
self.pending_factors = request.session[MultiFactorAuthenticator.SESSION_PENDING_FACTORS]
if AuthenticationView.SESSION_PENDING_FACTORS in request.session:
self.pending_factors = request.session[AuthenticationView.SESSION_PENDING_FACTORS]
else:
self.pending_factors = self.factors.copy()
# Get an initial list of factors which are currently enabled
# and apply to the current user. We check rules here and block the request
_all_factors = Factor.objects.filter(enabled=True)
self.pending_factors = []
for factor in _all_factors:
if factor.passes(self.pending_user):
self.pending_factors.append(_all_factors)
# self.pending_factors = Factor
# Read and instantiate factor from session
factor_class = None
if MultiFactorAuthenticator.SESSION_FACTOR not in request.session:
if AuthenticationView.SESSION_FACTOR not in request.session:
factor_class = self.pending_factors[0]
else:
factor_class = request.session[MultiFactorAuthenticator.SESSION_FACTOR]
factor_class = request.session[AuthenticationView.SESSION_FACTOR]
# Instantiate Next Factor and pass request
factor = path_to_class(factor_class)
self._current_factor = factor(self)
self._current_factor.request = request
@ -81,11 +87,11 @@ class MultiFactorAuthenticator(UserPassesTestMixin, View):
next_factor = None
if self.pending_factors:
next_factor = self.pending_factors.pop()
self.request.session[MultiFactorAuthenticator.SESSION_PENDING_FACTORS] = \
self.request.session[AuthenticationView.SESSION_PENDING_FACTORS] = \
self.pending_factors
self.request.session[MultiFactorAuthenticator.SESSION_FACTOR] = next_factor
self.request.session[AuthenticationView.SESSION_FACTOR] = next_factor
LOGGER.debug("Rendering Factor is %s", next_factor)
return redirect(reverse('passbook_core:mfa'))
return redirect(reverse('passbook_core:auth-process', kwargs={'factor': next_factor}))
# User passed all factors
LOGGER.debug("User passed all factors, logging in")
return self._user_passed()
@ -94,12 +100,12 @@ class MultiFactorAuthenticator(UserPassesTestMixin, View):
"""Show error message, user cannot login.
This should only be shown if user authenticated successfully, but is disabled/locked/etc"""
LOGGER.debug("User invalid")
return redirect(reverse('passbook_core:mfa-denied'))
return redirect(reverse('passbook_core:auth-denied'))
def _user_passed(self):
"""User Successfully passed all factors"""
# user = authenticate(request=self.request, )
backend = self.request.session[MultiFactorAuthenticator.SESSION_USER_BACKEND]
backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND]
login(self.request, self.pending_user, backend=backend)
LOGGER.debug("Logged in user %s", self.pending_user)
# Cleanup

View file

@ -0,0 +1,25 @@
"""passbook administration forms"""
from django import forms
from passbook.core.auth.factor_manager import MANAGER
from passbook.core.models import Factor
from passbook.lib.utils.reflection import class_to_path
def get_factors():
"""Return list of factors for Select Widget"""
for factor in MANAGER.all:
yield (class_to_path(factor), factor.__name__)
class FactorForm(forms.ModelForm):
"""Form to create/edit Factors"""
class Meta:
model = Factor
fields = ['name', 'slug', 'order', 'rules', 'type', 'enabled']
widgets = {
'type': forms.Select(choices=get_factors()),
'name': forms.TextInput(),
'order': forms.NumberInput(),
}

View file

@ -0,0 +1,28 @@
# Generated by Django 2.1.7 on 2019-02-14 15:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0002_auto_20190208_1514'),
]
operations = [
migrations.CreateModel(
name='Factor',
fields=[
('rulemodel_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.RuleModel')),
('name', models.TextField()),
('slug', models.SlugField(unique=True)),
('order', models.IntegerField()),
('type', models.TextField()),
],
options={
'abstract': False,
},
bases=('passbook_core.rulemodel',),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 2.1.7 on 2019-02-15 15:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0003_factor'),
]
operations = [
migrations.AddField(
model_name='factor',
name='enabled',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='application',
name='provider',
field=models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='passbook_core.Provider'),
),
]

View file

@ -64,6 +64,18 @@ class RuleModel(UUIDModel, CreatedUpdatedModel):
return True
@reversion.register()
class Factor(RuleModel):
"""Authentication factor, multiple instances of the same Factor can be used"""
name = models.TextField()
slug = models.SlugField(unique=True)
order = models.IntegerField()
type = models.TextField(unique=True)
enabled = models.BooleanField(default=True)
def __str__(self):
return "Factor %s" % self.slug
class Application(RuleModel):
"""Every Application which uses passbook for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to
@ -73,7 +85,7 @@ class Application(RuleModel):
slug = models.SlugField()
launch_url = models.URLField(null=True, blank=True)
icon_url = models.TextField(null=True, blank=True)
provider = models.OneToOneField('Provider', null=True,
provider = models.OneToOneField('Provider', null=True, blank=True,
default=None, on_delete=models.SET_DEFAULT)
skip_authorization = models.BooleanField(default=False)

View file

@ -50,11 +50,6 @@ AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'passbook.oauth_client.backends.AuthorizedServiceBackend'
]
AUTHENTICATION_FACTORS = [
'passbook.core.auth.backend_factor.AuthenticationBackendFactor',
'passbook.core.auth.dummy.DummyFactor',
'passbook.captcha_factor.factor.CaptchaFactor',
]
# Application definition

View file

@ -6,7 +6,7 @@ from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
from passbook.core.auth import mfa
from passbook.core.auth import view
from passbook.core.views import authentication, overview, user
from passbook.lib.utils.reflection import get_apps
@ -19,8 +19,9 @@ core_urls = [
path('auth/login/', authentication.LoginView.as_view(), name='auth-login'),
path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'),
path('auth/sign_up/', authentication.SignUpView.as_view(), name='auth-sign-up'),
path('auth/mfa/', mfa.MultiFactorAuthenticator.as_view(), name='mfa'),
path('auth/mfa/denied/', mfa.MFAPermissionDeniedView.as_view(), name='mfa-denied'),
path('auth/process/', view.AuthenticationView.as_view(), name='auth-process'),
path('auth/process/<slug:factor>/', view.AuthenticationView.as_view(), name='auth-process'),
path('auth/process/denied/', view.MFAPermissionDeniedView.as_view(), name='auth-denied'),
# User views
path('user/', user.UserSettingsView.as_view(), name='user-settings'),
path('user/delete/', user.UserDeleteView.as_view(), name='user-delete'),

View file

@ -11,7 +11,7 @@ from django.utils.translation import ugettext as _
from django.views import View
from django.views.generic import FormView
from passbook.core.auth.mfa import MultiFactorAuthenticator
from passbook.core.auth.view import AuthenticationView
from passbook.core.forms.authentication import LoginForm, SignUpForm
from passbook.core.models import Invitation, Source, User
from passbook.core.signals import invitation_used, user_signed_up
@ -62,9 +62,9 @@ class LoginView(UserPassesTestMixin, FormView):
if not pre_user:
# No user found
return self.invalid_login(self.request)
if MultiFactorAuthenticator.SESSION_FACTOR in self.request.session:
del self.request.session[MultiFactorAuthenticator.SESSION_FACTOR]
self.request.session[MultiFactorAuthenticator.SESSION_PENDING_USER] = pre_user.pk
if AuthenticationView.SESSION_FACTOR in self.request.session:
del self.request.session[AuthenticationView.SESSION_FACTOR]
self.request.session[AuthenticationView.SESSION_PENDING_USER] = pre_user.pk
return redirect(reverse('passbook_core:mfa'))
def invalid_login(self, request: HttpRequest, disabled_user: User = None) -> HttpResponse:

View file

@ -59,6 +59,10 @@ passbook:
uid_fields:
- username
- email
factors:
- passbook.core.auth.factors.backend
- passbook.core.auth.factors.dummy
- passbook.captcha_factor.factor
session:
remember_age: 2592000 # 60 * 60 * 24 * 30, one month
# Provider-specific settings