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' %}"> <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> <a href="{% url 'passbook_admin:providers' %}">{% trans 'Providers' %}</a>
</li> </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' %}"> <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> <a href="{% url 'passbook_admin:rules' %}">{% trans 'Rules' %}</a>
</li> </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 django.urls import include, path
from rest_framework_swagger.views import get_swagger_view from rest_framework_swagger.views import get_swagger_view
from passbook.admin.views import (applications, audit, groups, invitations, from passbook.admin.views import (applications, audit, factors, groups,
overview, providers, rules, sources, users) invitations, overview, providers, rules,
sources, users)
schema_view = get_swagger_view(title='passbook Admin Internal API') schema_view = get_swagger_view(title='passbook Admin Internal API')
@ -38,6 +39,14 @@ urlpatterns = [
providers.ProviderUpdateView.as_view(), name='provider-update'), providers.ProviderUpdateView.as_view(), name='provider-update'),
path('providers/<int:pk>/delete/', path('providers/<int:pk>/delete/',
providers.ProviderDeleteView.as_view(), name='provider-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 # Invitations
path('invitations/', invitations.InvitationListView.as_view(), name='invitations'), path('invitations/', invitations.InvitationListView.as_view(), name='invitations'),
path('invitations/create/', 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.captcha_factor.forms import CaptchaForm
from passbook.core.auth.factor import AuthenticationFactor from passbook.core.auth.factor import AuthenticationFactor
from passbook.core.auth.factor_manager import MANAGER
@MANAGER.factor()
class CaptchaFactor(FormView, AuthenticationFactor): class CaptchaFactor(FormView, AuthenticationFactor):
"""Simple captcha checker, logic is handeled in django-captcha module""" """Simple captcha checker, logic is handeled in django-captcha module"""

View File

@ -1,8 +1,12 @@
"""passbook core app config""" """passbook core app config"""
from importlib import import_module from importlib import import_module
from logging import getLogger
from django.apps import AppConfig from django.apps import AppConfig
from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__)
class PassbookCoreConfig(AppConfig): class PassbookCoreConfig(AppConfig):
"""passbook core app config""" """passbook core app config"""
@ -13,3 +17,10 @@ class PassbookCoreConfig(AppConfig):
def ready(self): def ready(self):
import_module('passbook.core.rules') 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 django.views.generic import FormView
from passbook.core.auth.factor import AuthenticationFactor 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.core.forms.authentication import AuthenticationBackendFactorForm
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
@MANAGER.factor()
class AuthenticationBackendFactor(FormView, AuthenticationFactor): class AuthenticationBackendFactor(FormView, AuthenticationFactor):
"""Authentication factor which authenticates against django's AuthBackend""" """Authentication factor which authenticates against django's AuthBackend"""
@ -34,7 +36,7 @@ class AuthenticationBackendFactor(FormView, AuthenticationFactor):
if user: if user:
# User instance returned from authenticate() has .backend property set # User instance returned from authenticate() has .backend property set
self.authenticator.pending_user = user 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() return self.authenticator.user_ok()
# No user was found -> invalid credentials # No user was found -> invalid credentials
LOGGER.debug("Invalid credentials") LOGGER.debug("Invalid credentials")

View File

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

View File

@ -1,20 +1,19 @@
"""passbook multi-factor authentication engine""" """passbook multi-factor authentication engine"""
from logging import getLogger from logging import getLogger
from django.conf import settings
from django.contrib.auth import login from django.contrib.auth import login
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.shortcuts import get_object_or_404, redirect, reverse from django.shortcuts import get_object_or_404, redirect, reverse
from django.views.generic import View 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.core.views.utils import PermissionDeniedView
from passbook.lib.utils.reflection import class_to_path, path_to_class from passbook.lib.utils.reflection import class_to_path, path_to_class
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
class MultiFactorAuthenticator(UserPassesTestMixin, View): class AuthenticationView(UserPassesTestMixin, View):
"""Wizard-like Multi-factor authenticator""" """Wizard-like Multi-factor authenticator"""
SESSION_FACTOR = 'passbook_factor' SESSION_FACTOR = 'passbook_factor'
@ -25,8 +24,6 @@ class MultiFactorAuthenticator(UserPassesTestMixin, View):
pending_user = None pending_user = None
pending_factors = [] pending_factors = []
factors = settings.AUTHENTICATION_FACTORS.copy()
_current_factor = None _current_factor = None
# Allow only not authenticated users to login # Allow only not authenticated users to login
@ -34,29 +31,38 @@ class MultiFactorAuthenticator(UserPassesTestMixin, View):
return self.request.user.is_authenticated is False return self.request.user.is_authenticated is False
def handle_no_permission(self): def handle_no_permission(self):
# Function from UserPassesTestMixin
if 'next' in self.request.GET: if 'next' in self.request.GET:
return redirect(self.request.GET.get('next')) return redirect(self.request.GET.get('next'))
return redirect(reverse('passbook_core:overview')) return redirect(reverse('passbook_core:overview'))
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# Extract pending user from session (only remember uid) # 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( 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: else:
# No Pending user, redirect to login screen # No Pending user, redirect to login screen
return redirect(reverse('passbook_core:auth-login')) return redirect(reverse('passbook_core:auth-login'))
# Write pending factors to session # Write pending factors to session
if MultiFactorAuthenticator.SESSION_PENDING_FACTORS in request.session: if AuthenticationView.SESSION_PENDING_FACTORS in request.session:
self.pending_factors = request.session[MultiFactorAuthenticator.SESSION_PENDING_FACTORS] self.pending_factors = request.session[AuthenticationView.SESSION_PENDING_FACTORS]
else: 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 # Read and instantiate factor from session
factor_class = None 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] factor_class = self.pending_factors[0]
else: 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) factor = path_to_class(factor_class)
self._current_factor = factor(self) self._current_factor = factor(self)
self._current_factor.request = request self._current_factor.request = request
@ -81,11 +87,11 @@ class MultiFactorAuthenticator(UserPassesTestMixin, View):
next_factor = None next_factor = None
if self.pending_factors: if self.pending_factors:
next_factor = self.pending_factors.pop() next_factor = self.pending_factors.pop()
self.request.session[MultiFactorAuthenticator.SESSION_PENDING_FACTORS] = \ self.request.session[AuthenticationView.SESSION_PENDING_FACTORS] = \
self.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) 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 # User passed all factors
LOGGER.debug("User passed all factors, logging in") LOGGER.debug("User passed all factors, logging in")
return self._user_passed() return self._user_passed()
@ -94,12 +100,12 @@ class MultiFactorAuthenticator(UserPassesTestMixin, View):
"""Show error message, user cannot login. """Show error message, user cannot login.
This should only be shown if user authenticated successfully, but is disabled/locked/etc""" This should only be shown if user authenticated successfully, but is disabled/locked/etc"""
LOGGER.debug("User invalid") LOGGER.debug("User invalid")
return redirect(reverse('passbook_core:mfa-denied')) return redirect(reverse('passbook_core:auth-denied'))
def _user_passed(self): def _user_passed(self):
"""User Successfully passed all factors""" """User Successfully passed all factors"""
# user = authenticate(request=self.request, ) # 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) login(self.request, self.pending_user, backend=backend)
LOGGER.debug("Logged in user %s", self.pending_user) LOGGER.debug("Logged in user %s", self.pending_user)
# Cleanup # 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 return True
@reversion.register() @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): class Application(RuleModel):
"""Every Application which uses passbook for authentication/identification/authorization """Every Application which uses passbook for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to needs an Application record. Other authentication types can subclass this Model to
@ -73,7 +85,7 @@ class Application(RuleModel):
slug = models.SlugField() slug = models.SlugField()
launch_url = models.URLField(null=True, blank=True) launch_url = models.URLField(null=True, blank=True)
icon_url = models.TextField(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) default=None, on_delete=models.SET_DEFAULT)
skip_authorization = models.BooleanField(default=False) skip_authorization = models.BooleanField(default=False)

View File

@ -50,11 +50,6 @@ AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend', 'django.contrib.auth.backends.ModelBackend',
'passbook.oauth_client.backends.AuthorizedServiceBackend' '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 # Application definition

View File

@ -6,7 +6,7 @@ from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views.generic import RedirectView 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.core.views import authentication, overview, user
from passbook.lib.utils.reflection import get_apps 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/login/', authentication.LoginView.as_view(), name='auth-login'),
path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'), path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'),
path('auth/sign_up/', authentication.SignUpView.as_view(), name='auth-sign-up'), path('auth/sign_up/', authentication.SignUpView.as_view(), name='auth-sign-up'),
path('auth/mfa/', mfa.MultiFactorAuthenticator.as_view(), name='mfa'), path('auth/process/', view.AuthenticationView.as_view(), name='auth-process'),
path('auth/mfa/denied/', mfa.MFAPermissionDeniedView.as_view(), name='mfa-denied'), 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 # User views
path('user/', user.UserSettingsView.as_view(), name='user-settings'), path('user/', user.UserSettingsView.as_view(), name='user-settings'),
path('user/delete/', user.UserDeleteView.as_view(), name='user-delete'), 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 import View
from django.views.generic import FormView 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.forms.authentication import LoginForm, SignUpForm
from passbook.core.models import Invitation, Source, User from passbook.core.models import Invitation, Source, User
from passbook.core.signals import invitation_used, user_signed_up from passbook.core.signals import invitation_used, user_signed_up
@ -62,9 +62,9 @@ class LoginView(UserPassesTestMixin, FormView):
if not pre_user: if not pre_user:
# No user found # No user found
return self.invalid_login(self.request) return self.invalid_login(self.request)
if MultiFactorAuthenticator.SESSION_FACTOR in self.request.session: if AuthenticationView.SESSION_FACTOR in self.request.session:
del self.request.session[MultiFactorAuthenticator.SESSION_FACTOR] del self.request.session[AuthenticationView.SESSION_FACTOR]
self.request.session[MultiFactorAuthenticator.SESSION_PENDING_USER] = pre_user.pk self.request.session[AuthenticationView.SESSION_PENDING_USER] = pre_user.pk
return redirect(reverse('passbook_core:mfa')) return redirect(reverse('passbook_core:mfa'))
def invalid_login(self, request: HttpRequest, disabled_user: User = None) -> HttpResponse: def invalid_login(self, request: HttpRequest, disabled_user: User = None) -> HttpResponse:

View File

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