diff --git a/passbook/admin/templates/administration/base.html b/passbook/admin/templates/administration/base.html
index bab43ea32..4dac1922f 100644
--- a/passbook/admin/templates/administration/base.html
+++ b/passbook/admin/templates/administration/base.html
@@ -17,6 +17,9 @@
{% trans 'Providers' %}
+
+ {% trans 'Factors' %}
+
{% trans 'Rules' %}
diff --git a/passbook/admin/templates/administration/factor/list.html b/passbook/admin/templates/administration/factor/list.html
new file mode 100644
index 000000000..fe7ff61db
--- /dev/null
+++ b/passbook/admin/templates/administration/factor/list.html
@@ -0,0 +1,48 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load utils %}
+{% load admin_reflection %}
+
+{% block title %}
+{% title %}
+{% endblock %}
+
+{% block content %}
+
+
{% trans "Factors" %}
+
+ {% trans 'Create...' %}
+
+
+
+
+
+ {% trans 'Name' %} |
+ {% trans 'Type' %} |
+ {% trans 'Order' %} |
+ {% trans 'Enabled?' %} |
+ |
+
+
+
+ {% for factor in object_list %}
+
+ {{ factor.name }} ({{ factor.slug }}) |
+ {{ factor.type }} |
+ {{ factor.order }} |
+ {{ factor.enabled }} |
+
+ {% trans 'Edit' %}
+ {% trans 'Delete' %}
+ {% get_links factor as links %}
+ {% for name, href in links.items %}
+ {% trans name %}
+ {% endfor %}
+ |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py
index 69cb03335..941d99cf2 100644
--- a/passbook/admin/urls.py
+++ b/passbook/admin/urls.py
@@ -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//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//update/',
+ factors.FactorUpdateView.as_view(), name='factor-update'),
+ path('factors//delete/',
+ factors.FactorDeleteView.as_view(), name='factor-delete'),
# Invitations
path('invitations/', invitations.InvitationListView.as_view(), name='invitations'),
path('invitations/create/',
diff --git a/passbook/admin/views/factors.py b/passbook/admin/views/factors.py
new file mode 100644
index 000000000..6cae923af
--- /dev/null
+++ b/passbook/admin/views/factors.py
@@ -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')
diff --git a/passbook/captcha_factor/factor.py b/passbook/captcha_factor/factor.py
index a16222995..82e76f1a0 100644
--- a/passbook/captcha_factor/factor.py
+++ b/passbook/captcha_factor/factor.py
@@ -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"""
diff --git a/passbook/core/apps.py b/passbook/core/apps.py
index a44561714..8548b9da2 100644
--- a/passbook/core/apps.py
+++ b/passbook/core/apps.py
@@ -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)
diff --git a/passbook/core/auth/factor_manager.py b/passbook/core/auth/factor_manager.py
new file mode 100644
index 000000000..ecde60724
--- /dev/null
+++ b/passbook/core/auth/factor_manager.py
@@ -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()
diff --git a/passbook/core/auth/factors/__init__.py b/passbook/core/auth/factors/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/passbook/core/auth/backend_factor.py b/passbook/core/auth/factors/backend.py
similarity index 90%
rename from passbook/core/auth/backend_factor.py
rename to passbook/core/auth/factors/backend.py
index 9aaef62fd..1bff589cd 100644
--- a/passbook/core/auth/backend_factor.py
+++ b/passbook/core/auth/factors/backend.py
@@ -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")
diff --git a/passbook/core/auth/dummy.py b/passbook/core/auth/factors/dummy.py
similarity index 84%
rename from passbook/core/auth/dummy.py
rename to passbook/core/auth/factors/dummy.py
index 593916409..64c5f17f4 100644
--- a/passbook/core/auth/dummy.py
+++ b/passbook/core/auth/factors/dummy.py
@@ -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"""
diff --git a/passbook/core/auth/mfa.py b/passbook/core/auth/view.py
similarity index 72%
rename from passbook/core/auth/mfa.py
rename to passbook/core/auth/view.py
index 2c5a5df75..8ef594e9e 100644
--- a/passbook/core/auth/mfa.py
+++ b/passbook/core/auth/view.py
@@ -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
diff --git a/passbook/core/forms/factor.py b/passbook/core/forms/factor.py
new file mode 100644
index 000000000..326fe795f
--- /dev/null
+++ b/passbook/core/forms/factor.py
@@ -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(),
+ }
diff --git a/passbook/core/migrations/0003_factor.py b/passbook/core/migrations/0003_factor.py
new file mode 100644
index 000000000..b9da82781
--- /dev/null
+++ b/passbook/core/migrations/0003_factor.py
@@ -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',),
+ ),
+ ]
diff --git a/passbook/core/migrations/0004_auto_20190215_1534.py b/passbook/core/migrations/0004_auto_20190215_1534.py
new file mode 100644
index 000000000..d31c8de23
--- /dev/null
+++ b/passbook/core/migrations/0004_auto_20190215_1534.py
@@ -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'),
+ ),
+ ]
diff --git a/passbook/core/models.py b/passbook/core/models.py
index c2a0a103b..a6aee769f 100644
--- a/passbook/core/models.py
+++ b/passbook/core/models.py
@@ -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)
diff --git a/passbook/core/settings.py b/passbook/core/settings.py
index b41406e4d..6e794ee6b 100644
--- a/passbook/core/settings.py
+++ b/passbook/core/settings.py
@@ -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
diff --git a/passbook/core/urls.py b/passbook/core/urls.py
index 632a380b1..1c1fcd75a 100644
--- a/passbook/core/urls.py
+++ b/passbook/core/urls.py
@@ -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//', 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'),
diff --git a/passbook/core/views/authentication.py b/passbook/core/views/authentication.py
index e67986f04..14c2bbd21 100644
--- a/passbook/core/views/authentication.py
+++ b/passbook/core/views/authentication.py
@@ -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:
diff --git a/passbook/lib/default.yml b/passbook/lib/default.yml
index f6900abaa..ba2a85cf1 100644
--- a/passbook/lib/default.yml
+++ b/passbook/lib/default.yml
@@ -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