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...' %} + +
    + + + + + + + + + + + + {% for factor in object_list %} + + + + + + + + {% endfor %} + +
    {% trans 'Name' %}{% trans 'Type' %}{% trans 'Order' %}{% trans 'Enabled?' %}
    {{ 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 %} +
    +
    +{% 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