factors: -> stage

This commit is contained in:
Jens Langhammer 2020-05-08 19:46:39 +02:00
parent 08c0eb2ec6
commit 212e966dd4
99 changed files with 745 additions and 958 deletions

View File

@ -6,7 +6,6 @@ from passbook.admin.views import (
audit, audit,
certificate_key_pair, certificate_key_pair,
debug, debug,
factors,
flows, flows,
groups, groups,
invitations, invitations,
@ -15,6 +14,7 @@ from passbook.admin.views import (
property_mapping, property_mapping,
providers, providers,
sources, sources,
stages,
users, users,
) )
@ -85,18 +85,18 @@ urlpatterns = [
providers.ProviderDeleteView.as_view(), providers.ProviderDeleteView.as_view(),
name="provider-delete", name="provider-delete",
), ),
# Factors # Stages
path("factors/", factors.FactorListView.as_view(), name="factors"), path("stages/", stages.StageListView.as_view(), name="stages"),
path("factors/create/", factors.FactorCreateView.as_view(), name="factor-create"), path("stages/create/", stages.StageCreateView.as_view(), name="stage-create"),
path( path(
"factors/<uuid:pk>/update/", "stages/<uuid:pk>/update/",
factors.FactorUpdateView.as_view(), stages.StageUpdateView.as_view(),
name="factor-update", name="stage-update",
), ),
path( path(
"factors/<uuid:pk>/delete/", "stages/<uuid:pk>/delete/",
factors.FactorDeleteView.as_view(), stages.StageDeleteView.as_view(),
name="factor-delete", name="stage-delete",
), ),
# Flows # Flows
path("flows/", flows.FlowListView.as_view(), name="flows"), path("flows/", flows.FlowListView.as_view(), name="flows"),
@ -107,7 +107,7 @@ urlpatterns = [
path( path(
"flows/<uuid:pk>/delete/", flows.FlowDeleteView.as_view(), name="flow-delete", "flows/<uuid:pk>/delete/", flows.FlowDeleteView.as_view(), name="flow-delete",
), ),
# Factors # Property Mappings
path( path(
"property-mappings/", "property-mappings/",
property_mapping.PropertyMappingListView.as_view(), property_mapping.PropertyMappingListView.as_view(),

View File

@ -5,15 +5,8 @@ from django.views.generic import TemplateView
from passbook import __version__ from passbook import __version__
from passbook.admin.mixins import AdminRequiredMixin from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.models import ( from passbook.core.models import Application, Invitation, Policy, Provider, Source, User
Application, from passbook.flows.models import Flow, Stage
Factor,
Invitation,
Policy,
Provider,
Source,
User,
)
from passbook.root.celery import CELERY_APP from passbook.root.celery import CELERY_APP
@ -35,7 +28,8 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
kwargs["user_count"] = len(User.objects.all()) kwargs["user_count"] = len(User.objects.all())
kwargs["provider_count"] = len(Provider.objects.all()) kwargs["provider_count"] = len(Provider.objects.all())
kwargs["source_count"] = len(Source.objects.all()) kwargs["source_count"] = len(Source.objects.all())
kwargs["factor_count"] = len(Factor.objects.all()) kwargs["stage_count"] = len(Stage.objects.all())
kwargs["flow_count"] = len(Flow.objects.all())
kwargs["invitation_count"] = len(Invitation.objects.all()) kwargs["invitation_count"] = len(Invitation.objects.all())
kwargs["version"] = __version__ kwargs["version"] = __version__
kwargs["worker_count"] = len(CELERY_APP.control.ping(timeout=0.5)) kwargs["worker_count"] = len(CELERY_APP.control.ping(timeout=0.5))

View File

@ -1,4 +1,4 @@
"""passbook Factor administration""" """passbook Stage administration"""
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import (
@ -11,7 +11,7 @@ from django.utils.translation import ugettext as _
from django.views.generic import DeleteView, ListView, UpdateView from django.views.generic import DeleteView, ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.core.models import Factor from passbook.flows.models import Stage
from passbook.lib.utils.reflection import path_to_class from passbook.lib.utils.reflection import path_to_class
from passbook.lib.views import CreateAssignPermView from passbook.lib.views import CreateAssignPermView
@ -23,18 +23,18 @@ def all_subclasses(cls):
) )
class FactorListView(LoginRequiredMixin, PermissionListMixin, ListView): class StageListView(LoginRequiredMixin, PermissionListMixin, ListView):
"""Show list of all factors""" """Show list of all flows"""
model = Factor model = Stage
template_name = "administration/factor/list.html" template_name = "administration/flow/list.html"
permission_required = "passbook_core.view_factor" permission_required = "passbook_core.view_flow"
ordering = "order" ordering = "order"
paginate_by = 40 paginate_by = 40
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs["types"] = { kwargs["types"] = {
x.__name__: x._meta.verbose_name for x in all_subclasses(Factor) x.__name__: x._meta.verbose_name for x in all_subclasses(Stage)
} }
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
@ -42,46 +42,46 @@ class FactorListView(LoginRequiredMixin, PermissionListMixin, ListView):
return super().get_queryset().select_subclasses() return super().get_queryset().select_subclasses()
class FactorCreateView( class StageCreateView(
SuccessMessageMixin, SuccessMessageMixin,
LoginRequiredMixin, LoginRequiredMixin,
DjangoPermissionRequiredMixin, DjangoPermissionRequiredMixin,
CreateAssignPermView, CreateAssignPermView,
): ):
"""Create new Factor""" """Create new Stage"""
model = Factor model = Stage
template_name = "generic/create.html" template_name = "generic/create.html"
permission_required = "passbook_core.add_factor" permission_required = "passbook_core.add_flow"
success_url = reverse_lazy("passbook_admin:factors") success_url = reverse_lazy("passbook_admin:flows")
success_message = _("Successfully created Factor") success_message = _("Successfully created Stage")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
factor_type = self.request.GET.get("type") flow_type = self.request.GET.get("type")
model = next(x for x in all_subclasses(Factor) if x.__name__ == factor_type) model = next(x for x in all_subclasses(Stage) if x.__name__ == flow_type)
kwargs["type"] = model._meta.verbose_name kwargs["type"] = model._meta.verbose_name
return kwargs return kwargs
def get_form_class(self): def get_form_class(self):
factor_type = self.request.GET.get("type") flow_type = self.request.GET.get("type")
model = next(x for x in all_subclasses(Factor) if x.__name__ == factor_type) model = next(x for x in all_subclasses(Stage) if x.__name__ == flow_type)
if not model: if not model:
raise Http404 raise Http404
return path_to_class(model.form) return path_to_class(model.form)
class FactorUpdateView( class StageUpdateView(
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
): ):
"""Update factor""" """Update flow"""
model = Factor model = Stage
permission_required = "passbook_core.update_application" permission_required = "passbook_core.update_application"
template_name = "generic/update.html" template_name = "generic/update.html"
success_url = reverse_lazy("passbook_admin:factors") success_url = reverse_lazy("passbook_admin:flows")
success_message = _("Successfully updated Factor") success_message = _("Successfully updated Stage")
def get_form_class(self): def get_form_class(self):
form_class_path = self.get_object().form form_class_path = self.get_object().form
@ -90,24 +90,24 @@ class FactorUpdateView(
def get_object(self, queryset=None): def get_object(self, queryset=None):
return ( return (
Factor.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first() Stage.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
) )
class FactorDeleteView( class StageDeleteView(
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, DeleteView SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, DeleteView
): ):
"""Delete factor""" """Delete flow"""
model = Factor model = Stage
template_name = "generic/delete.html" template_name = "generic/delete.html"
permission_required = "passbook_core.delete_factor" permission_required = "passbook_core.delete_flow"
success_url = reverse_lazy("passbook_admin:factors") success_url = reverse_lazy("passbook_admin:flows")
success_message = _("Successfully deleted Factor") success_message = _("Successfully deleted Stage")
def get_object(self, queryset=None): def get_object(self, queryset=None):
return ( return (
Factor.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first() Stage.objects.filter(pk=self.kwargs.get("pk")).select_subclasses().first()
) )
def delete(self, request, *args, **kwargs): def delete(self, request, *args, **kwargs):

View File

@ -9,7 +9,6 @@ from structlog import get_logger
from passbook.api.permissions import CustomObjectPermissions from passbook.api.permissions import CustomObjectPermissions
from passbook.audit.api import EventViewSet from passbook.audit.api import EventViewSet
from passbook.core.api.applications import ApplicationViewSet from passbook.core.api.applications import ApplicationViewSet
from passbook.core.api.factors import FactorViewSet
from passbook.core.api.groups import GroupViewSet from passbook.core.api.groups import GroupViewSet
from passbook.core.api.invitations import InvitationViewSet from passbook.core.api.invitations import InvitationViewSet
from passbook.core.api.policies import PolicyViewSet from passbook.core.api.policies import PolicyViewSet
@ -17,12 +16,7 @@ from passbook.core.api.propertymappings import PropertyMappingViewSet
from passbook.core.api.providers import ProviderViewSet from passbook.core.api.providers import ProviderViewSet
from passbook.core.api.sources import SourceViewSet from passbook.core.api.sources import SourceViewSet
from passbook.core.api.users import UserViewSet from passbook.core.api.users import UserViewSet
from passbook.factors.captcha.api import CaptchaFactorViewSet from passbook.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet
from passbook.factors.dummy.api import DummyFactorViewSet
from passbook.factors.email.api import EmailFactorViewSet
from passbook.factors.otp.api import OTPFactorViewSet
from passbook.factors.password.api import PasswordFactorViewSet
from passbook.flows.api import FlowFactorBindingViewSet, FlowViewSet
from passbook.lib.utils.reflection import get_apps from passbook.lib.utils.reflection import get_apps
from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet
from passbook.policies.expression.api import ExpressionPolicyViewSet from passbook.policies.expression.api import ExpressionPolicyViewSet
@ -36,6 +30,11 @@ from passbook.providers.oidc.api import OpenIDProviderViewSet
from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet
from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
from passbook.sources.oauth.api import OAuthSourceViewSet from passbook.sources.oauth.api import OAuthSourceViewSet
from passbook.stages.captcha.api import CaptchaStageViewSet
from passbook.stages.dummy.api import DummyStageViewSet
from passbook.stages.email.api import EmailStageViewSet
from passbook.stages.otp.api import OTPStageViewSet
from passbook.stages.password.api import PasswordStageViewSet
LOGGER = get_logger() LOGGER = get_logger()
router = routers.DefaultRouter() router = routers.DefaultRouter()
@ -69,14 +68,14 @@ router.register("providers/saml", SAMLProviderViewSet)
router.register("propertymappings/all", PropertyMappingViewSet) router.register("propertymappings/all", PropertyMappingViewSet)
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
router.register("propertymappings/saml", SAMLPropertyMappingViewSet) router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
router.register("factors/all", FactorViewSet) router.register("stages/all", StageViewSet)
router.register("factors/captcha", CaptchaFactorViewSet) router.register("stages/captcha", CaptchaStageViewSet)
router.register("factors/dummy", DummyFactorViewSet) router.register("stages/dummy", DummyStageViewSet)
router.register("factors/email", EmailFactorViewSet) router.register("stages/email", EmailStageViewSet)
router.register("factors/otp", OTPFactorViewSet) router.register("stages/otp", OTPStageViewSet)
router.register("factors/password", PasswordFactorViewSet) router.register("stages/password", PasswordStageViewSet)
router.register("flows", FlowViewSet) router.register("flows", FlowViewSet)
router.register("flows/bindings", FlowFactorBindingViewSet) router.register("flows/bindings", FlowStageBindingViewSet)
info = openapi.Info( info = openapi.Info(
title="passbook API", title="passbook API",

View File

@ -1,30 +0,0 @@
"""Factor API Views"""
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import ReadOnlyModelViewSet
from passbook.core.models import Factor
class FactorSerializer(ModelSerializer):
"""Factor Serializer"""
__type__ = SerializerMethodField(method_name="get_type")
def get_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object"""
return obj._meta.object_name.lower().replace("factor", "")
class Meta:
model = Factor
fields = ["pk", "name", "slug", "order", "enabled", "__type__"]
class FactorViewSet(ReadOnlyModelViewSet):
"""Factor Viewset"""
queryset = Factor.objects.all()
serializer_class = FactorSerializer
def get_queryset(self):
return Factor.objects.select_subclasses()

View File

@ -0,0 +1,14 @@
# Generated by Django 3.0.3 on 2020-05-08 17:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0011_auto_20200222_1822"),
]
operations = [
migrations.DeleteModel(name="Factor",),
]

View File

@ -103,30 +103,6 @@ class PolicyModel(UUIDModel, CreatedUpdatedModel):
policies = models.ManyToManyField("Policy", blank=True) policies = models.ManyToManyField("Policy", blank=True)
class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
"""Authentication factor, multiple instances of the same Factor can be used"""
name = models.TextField(help_text=_("Factor's display Name."))
slug = models.SlugField(
unique=True, help_text=_("Internal factor name, used in URLs.")
)
order = models.IntegerField()
enabled = models.BooleanField(default=True)
objects = InheritanceManager()
type = ""
form = ""
@property
def ui_user_settings(self) -> Optional[UIUserSettings]:
"""Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or an instanace of UIUserSettings."""
return None
def __str__(self):
return f"Factor {self.slug}"
class Application(ExportModelOperationsMixin("application"), PolicyModel): class Application(ExportModelOperationsMixin("application"), PolicyModel):
"""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

View File

@ -18,16 +18,16 @@
</li> </li>
</ul> </ul>
</section> </section>
{% user_factors as user_factors_loc %} {% user_stages as user_stages_loc %}
{% if user_factors_loc %} {% if user_stages_loc %}
<section class="pf-c-nav__section"> <section class="pf-c-nav__section">
<h2 class="pf-c-nav__section-title">{% trans 'Factors' %}</h2> <h2 class="pf-c-nav__section-title">{% trans 'Factors' %}</h2>
<ul class="pf-c-nav__list"> <ul class="pf-c-nav__list">
{% for factor in user_factors_loc %} {% for stage in user_stages_loc %}
<li class="pf-c-nav__item"> <li class="pf-c-nav__item">
<a href="{% url factor.view_name %}" class="pf-c-nav__link {% is_active factor.view_name %}"> <a href="{% url stage.view_name %}" class="pf-c-nav__link {% is_active stage.view_name %}">
<i class="{{ factor.icon }}"></i> <i class="{{ stage.icon }}"></i>
{{ factor.name }} {{ stage.name }}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}

View File

@ -4,7 +4,7 @@ from typing import Iterable, List
from django import template from django import template
from django.template.context import RequestContext from django.template.context import RequestContext
from passbook.core.models import Factor, Source from passbook.core.models import Source
from passbook.core.types import UIUserSettings from passbook.core.types import UIUserSettings
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
@ -12,24 +12,24 @@ register = template.Library()
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def user_factors(context: RequestContext) -> List[UIUserSettings]: # pylint: disable=unused-argument
"""Return list of all factors which apply to user""" def user_stages(context: RequestContext) -> List[UIUserSettings]:
user = context.get("request").user """Return list of all stages which apply to user"""
_all_factors: Iterable[Factor] = ( # TODO: Rewrite this based on flows
Factor.objects.filter(enabled=True).order_by("order").select_subclasses() # user = context.get("request").user
) # _all_stages: Iterable[Stage] = (Stage.objects.all().select_subclasses())
matching_factors: List[UIUserSettings] = [] matching_stages: List[UIUserSettings] = []
for factor in _all_factors: # for stage in _all_stages:
user_settings = factor.ui_user_settings # user_settings = stage.ui_user_settings
if not user_settings: # if not user_settings:
continue # continue
policy_engine = PolicyEngine( # policy_engine = PolicyEngine(
factor.policies.all(), user, context.get("request") # stage.policies.all(), user, context.get("request")
) # )
policy_engine.build() # policy_engine.build()
if policy_engine.passing: # if policy_engine.passing:
matching_factors.append(user_settings) # matching_stages.append(user_settings)
return matching_factors return matching_stages
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
@ -40,12 +40,12 @@ def user_sources(context: RequestContext) -> List[UIUserSettings]:
Source.objects.filter(enabled=True).select_subclasses() Source.objects.filter(enabled=True).select_subclasses()
) )
matching_sources: List[UIUserSettings] = [] matching_sources: List[UIUserSettings] = []
for factor in _all_sources: for source in _all_sources:
user_settings = factor.ui_user_settings user_settings = source.ui_user_settings
if not user_settings: if not user_settings:
continue continue
policy_engine = PolicyEngine( policy_engine = PolicyEngine(
factor.policies.all(), user, context.get("request") source.policies.all(), user, context.get("request")
) )
policy_engine.build() policy_engine.build()
if policy_engine.passing: if policy_engine.passing:

View File

@ -5,7 +5,7 @@ from typing import Optional
@dataclass @dataclass
class UIUserSettings: class UIUserSettings:
"""Dataclass for Factor and Source's user_settings""" """Dataclass for Stage and Source's user_settings"""
name: str name: str
icon: str icon: str

View File

@ -15,12 +15,12 @@ from structlog import get_logger
from passbook.core.forms.authentication import LoginForm, SignUpForm from passbook.core.forms.authentication import LoginForm, SignUpForm
from passbook.core.models import Invitation, Nonce, Source, User from passbook.core.models import Invitation, Nonce, Source, User
from passbook.core.signals import invitation_used, user_signed_up from passbook.core.signals import invitation_used, user_signed_up
from passbook.factors.password.exceptions import PasswordPolicyInvalid
from passbook.flows.models import Flow, FlowDesignation from passbook.flows.models import Flow, FlowDesignation
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from passbook.flows.views import SESSION_KEY_PLAN from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
from passbook.lib.utils.urls import redirect_with_qs from passbook.lib.utils.urls import redirect_with_qs
from passbook.stages.password.exceptions import PasswordPolicyInvalid
LOGGER = get_logger() LOGGER = get_logger()

View File

@ -10,8 +10,8 @@ from django.utils.translation import gettext as _
from django.views.generic import DeleteView, FormView, UpdateView from django.views.generic import DeleteView, FormView, UpdateView
from passbook.core.forms.users import PasswordChangeForm, UserDetailForm from passbook.core.forms.users import PasswordChangeForm, UserDetailForm
from passbook.factors.password.exceptions import PasswordPolicyInvalid
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
from passbook.stages.password.exceptions import PasswordPolicyInvalid
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView): class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):

View File

@ -1,21 +0,0 @@
"""CaptchaFactor API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.factors.captcha.models import CaptchaFactor
class CaptchaFactorSerializer(ModelSerializer):
"""CaptchaFactor Serializer"""
class Meta:
model = CaptchaFactor
fields = ["pk", "name", "slug", "order", "enabled", "public_key", "private_key"]
class CaptchaFactorViewSet(ModelViewSet):
"""CaptchaFactor Viewset"""
queryset = CaptchaFactor.objects.all()
serializer_class = CaptchaFactorSerializer

View File

@ -1,10 +0,0 @@
"""passbook captcha app"""
from django.apps import AppConfig
class PassbookFactorCaptchaConfig(AppConfig):
"""passbook captcha app"""
name = "passbook.factors.captcha"
label = "passbook_factors_captcha"
verbose_name = "passbook Factors.Captcha"

View File

@ -1,35 +0,0 @@
"""passbook captcha factor forms"""
from captcha.fields import ReCaptchaField
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
from passbook.factors.captcha.models import CaptchaFactor
from passbook.flows.forms import GENERAL_FIELDS
class CaptchaForm(forms.Form):
"""passbook captcha factor form"""
captcha = ReCaptchaField()
class CaptchaFactorForm(forms.ModelForm):
"""Form to edit CaptchaFactor Instance"""
class Meta:
model = CaptchaFactor
fields = GENERAL_FIELDS + ["public_key", "private_key"]
widgets = {
"name": forms.TextInput(),
"order": forms.NumberInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
"public_key": forms.TextInput(),
"private_key": forms.TextInput(),
}
help_texts = {
"policies": _(
"Policies which determine if this factor applies to the current user."
)
}

View File

@ -1,39 +0,0 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="CaptchaFactor",
fields=[
(
"factor_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_core.Factor",
),
),
("public_key", models.TextField()),
("private_key", models.TextField()),
],
options={
"verbose_name": "Captcha Factor",
"verbose_name_plural": "Captcha Factors",
},
bases=("passbook_core.factor",),
),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 3.0.3 on 2020-02-21 14:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_factors_captcha", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="captchafactor",
name="private_key",
field=models.TextField(
help_text="Private key, acquired from https://www.google.com/recaptcha/intro/v3.html"
),
),
migrations.AlterField(
model_name="captchafactor",
name="public_key",
field=models.TextField(
help_text="Public key, acquired from https://www.google.com/recaptcha/intro/v3.html"
),
),
]

View File

@ -1,21 +0,0 @@
"""DummyFactor API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.factors.dummy.models import DummyFactor
class DummyFactorSerializer(ModelSerializer):
"""DummyFactor Serializer"""
class Meta:
model = DummyFactor
fields = ["pk", "name", "slug", "order", "enabled"]
class DummyFactorViewSet(ModelViewSet):
"""DummyFactor Viewset"""
queryset = DummyFactor.objects.all()
serializer_class = DummyFactorSerializer

View File

@ -1,11 +0,0 @@
"""passbook dummy factor config"""
from django.apps import AppConfig
class PassbookFactorDummyConfig(AppConfig):
"""passbook dummy factor config"""
name = "passbook.factors.dummy"
label = "passbook_factors_dummy"
verbose_name = "passbook Factors.Dummy"

View File

@ -1,12 +0,0 @@
"""passbook multi-factor authentication engine"""
from django.http import HttpRequest
from passbook.flows.factor_base import AuthenticationFactor
class DummyFactor(AuthenticationFactor):
"""Dummy factor for testing with multiple factors"""
def post(self, request: HttpRequest):
"""Just redirect to next factor"""
return self.executor.factor_ok()

View File

@ -1,21 +0,0 @@
"""passbook administration forms"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.factors.dummy.models import DummyFactor
from passbook.flows.forms import GENERAL_FIELDS
class DummyFactorForm(forms.ModelForm):
"""Form to create/edit Dummy Factor"""
class Meta:
model = DummyFactor
fields = GENERAL_FIELDS
widgets = {
"name": forms.TextInput(),
"order": forms.NumberInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
}

View File

@ -1,19 +0,0 @@
"""dummy factor models"""
from django.utils.translation import gettext as _
from passbook.core.models import Factor
class DummyFactor(Factor):
"""Dummy factor, mostly used to debug"""
type = "passbook.factors.dummy.factor.DummyFactor"
form = "passbook.factors.dummy.forms.DummyFactorForm"
def __str__(self):
return f"Dummy Factor {self.slug}"
class Meta:
verbose_name = _("Dummy Factor")
verbose_name_plural = _("Dummy Factors")

View File

@ -1,15 +0,0 @@
"""passbook email factor config"""
from importlib import import_module
from django.apps import AppConfig
class PassbookFactorEmailConfig(AppConfig):
"""passbook email factor config"""
name = "passbook.factors.email"
label = "passbook_factors_email"
verbose_name = "passbook Factors.Email"
def ready(self):
import_module("passbook.factors.email.tasks")

View File

@ -1,18 +0,0 @@
# Generated by Django 2.2.6 on 2019-10-11 12:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_factors_email", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="emailfactor",
name="timeout",
field=models.IntegerField(default=10),
),
]

View File

@ -1,21 +0,0 @@
"""OTPFactor API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.factors.otp.models import OTPFactor
class OTPFactorSerializer(ModelSerializer):
"""OTPFactor Serializer"""
class Meta:
model = OTPFactor
fields = ["pk", "name", "slug", "order", "enabled", "enforced"]
class OTPFactorViewSet(ModelViewSet):
"""OTPFactor Viewset"""
queryset = OTPFactor.objects.all()
serializer_class = OTPFactorSerializer

View File

@ -1,12 +0,0 @@
"""passbook OTP AppConfig"""
from django.apps.config import AppConfig
class PassbookFactorOTPConfig(AppConfig):
"""passbook OTP AppConfig"""
name = "passbook.factors.otp"
label = "passbook_factors_otp"
verbose_name = "passbook Factors.OTP"
mountpoint = "user/otp/"

View File

@ -1,34 +0,0 @@
"""OTP Factor"""
from django.db import models
from django.utils.translation import gettext as _
from passbook.core.models import Factor
from passbook.core.types import UIUserSettings
class OTPFactor(Factor):
"""OTP Factor"""
enforced = models.BooleanField(
default=False,
help_text=("Enforce enabled OTP for Users " "this factor applies to."),
)
type = "passbook.factors.otp.factors.OTPFactor"
form = "passbook.factors.otp.forms.OTPFactorForm"
@property
def ui_user_settings(self) -> UIUserSettings:
return UIUserSettings(
name="OTP",
icon="pficon-locked",
view_name="passbook_factors_otp:otp-user-settings",
)
def __str__(self):
return f"OTP Factor {self.slug}"
class Meta:
verbose_name = _("OTP Factor")
verbose_name_plural = _("OTP Factors")

View File

@ -1,30 +0,0 @@
"""PasswordFactor API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.factors.password.models import PasswordFactor
class PasswordFactorSerializer(ModelSerializer):
"""PasswordFactor Serializer"""
class Meta:
model = PasswordFactor
fields = [
"pk",
"name",
"slug",
"order",
"enabled",
"backends",
"password_policies",
"reset_factors",
]
class PasswordFactorViewSet(ModelViewSet):
"""PasswordFactor Viewset"""
queryset = PasswordFactor.objects.all()
serializer_class = PasswordFactorSerializer

View File

@ -1,15 +0,0 @@
"""passbook core app config"""
from importlib import import_module
from django.apps import AppConfig
class PassbookFactorPasswordConfig(AppConfig):
"""passbook password factor config"""
name = "passbook.factors.password"
label = "passbook_factors_password"
verbose_name = "passbook Factors.Password"
def ready(self):
import_module("passbook.factors.password.signals")

View File

@ -1,24 +0,0 @@
# Generated by Django 2.2.6 on 2019-10-07 14:11
from django.db import migrations
def create_initial_factor(apps, schema_editor):
"""Create initial PasswordFactor if none exists"""
PasswordFactor = apps.get_model("passbook_factors_password", "PasswordFactor")
if not PasswordFactor.objects.exists():
PasswordFactor.objects.create(
name="password",
slug="password",
order=0,
backends=["django.contrib.auth.backends.ModelBackend"],
)
class Migration(migrations.Migration):
dependencies = [
("passbook_factors_password", "0001_initial"),
]
operations = [migrations.RunPython(create_initial_factor)]

View File

@ -1,21 +0,0 @@
# Generated by Django 2.2.6 on 2019-10-08 09:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0001_initial"),
("passbook_factors_password", "0002_auto_20191007_1411"),
]
operations = [
migrations.AddField(
model_name="passwordfactor",
name="reset_factors",
field=models.ManyToManyField(
blank=True, related_name="reset_factors", to="passbook_core.Factor"
),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.0.3 on 2020-02-21 14:10
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_factors_password", "0003_passwordfactor_reset_factors"),
]
operations = [
migrations.AlterField(
model_name="passwordfactor",
name="backends",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(),
help_text="Selection of backends to test the password against.",
size=None,
),
),
]

View File

@ -1,23 +0,0 @@
"""passbook password factor signals"""
from django.dispatch import receiver
from passbook.core.signals import password_changed
from passbook.factors.password.exceptions import PasswordPolicyInvalid
@receiver(password_changed)
def password_policy_checker(sender, password, **_):
"""Run password through all password policies which are applied to the user"""
from passbook.factors.password.models import PasswordFactor
from passbook.policies.engine import PolicyEngine
setattr(sender, "__password__", password)
_all_factors = PasswordFactor.objects.filter(enabled=True).order_by("order")
for factor in _all_factors:
policy_engine = PolicyEngine(
factor.password_policies.all().select_subclasses(), sender
)
policy_engine.build()
passing, messages = policy_engine.result
if not passing:
raise PasswordPolicyInvalid(*messages)

View File

@ -1,8 +1,8 @@
"""Flow API Views""" """Flow API Views"""
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from passbook.flows.models import Flow, FlowFactorBinding from passbook.flows.models import Flow, FlowStageBinding, Stage
class FlowSerializer(ModelSerializer): class FlowSerializer(ModelSerializer):
@ -11,7 +11,7 @@ class FlowSerializer(ModelSerializer):
class Meta: class Meta:
model = Flow model = Flow
fields = ["pk", "name", "slug", "designation", "factors", "policies"] fields = ["pk", "name", "slug", "designation", "stages", "policies"]
class FlowViewSet(ModelViewSet): class FlowViewSet(ModelViewSet):
@ -21,17 +21,42 @@ class FlowViewSet(ModelViewSet):
serializer_class = FlowSerializer serializer_class = FlowSerializer
class FlowFactorBindingSerializer(ModelSerializer): class FlowStageBindingSerializer(ModelSerializer):
"""FlowFactorBinding Serializer""" """FlowStageBinding Serializer"""
class Meta: class Meta:
model = FlowFactorBinding model = FlowStageBinding
fields = ["pk", "flow", "factor", "re_evaluate_policies", "order", "policies"] fields = ["pk", "flow", "stage", "re_evaluate_policies", "order", "policies"]
class FlowFactorBindingViewSet(ModelViewSet): class FlowStageBindingViewSet(ModelViewSet):
"""FlowFactorBinding Viewset""" """FlowStageBinding Viewset"""
queryset = FlowFactorBinding.objects.all() queryset = FlowStageBinding.objects.all()
serializer_class = FlowFactorBindingSerializer serializer_class = FlowStageBindingSerializer
class StageSerializer(ModelSerializer):
"""Stage Serializer"""
__type__ = SerializerMethodField(method_name="get_type")
def get_type(self, obj):
"""Get object type so that we know which API Endpoint to use to get the full object"""
return obj._meta.object_name.lower().replace("stage", "")
class Meta:
model = Stage
fields = ["pk", "name", "__type__"]
class StageViewSet(ReadOnlyModelViewSet):
"""Stage Viewset"""
queryset = Stage.objects.all()
serializer_class = StageSerializer
def get_queryset(self):
return Stage.objects.select_subclasses()

View File

@ -1,12 +1,10 @@
"""factor forms""" """Flow and Stage forms"""
from django import forms from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.flows.models import Flow, FlowFactorBinding from passbook.flows.models import Flow, FlowStageBinding
GENERAL_FIELDS = ["name", "slug", "order", "policies", "enabled"]
class FlowForm(forms.ModelForm): class FlowForm(forms.ModelForm):
@ -19,29 +17,30 @@ class FlowForm(forms.ModelForm):
"name", "name",
"slug", "slug",
"designation", "designation",
"factors", "stages",
"policies", "policies",
] ]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"factors": FilteredSelectMultiple(_("policies"), False), "stages": FilteredSelectMultiple(_("stages"), False),
"policies": FilteredSelectMultiple(_("policies"), False),
} }
class FlowFactorBindingForm(forms.ModelForm): class FlowStageBindingForm(forms.ModelForm):
"""FlowFactorBinding Form""" """FlowStageBinding Form"""
class Meta: class Meta:
model = FlowFactorBinding model = FlowStageBinding
fields = [ fields = [
"flow", "flow",
"factor", "stage",
"re_evaluate_policies", "re_evaluate_policies",
"order", "order",
"policies", "policies",
] ]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"factors": FilteredSelectMultiple(_("policies"), False), "policies": FilteredSelectMultiple(_("policies"), False),
} }

View File

@ -1,4 +1,4 @@
# Generated by Django 3.0.3 on 2020-05-07 18:35 # Generated by Django 3.0.3 on 2020-05-08 18:27
import uuid import uuid
@ -11,8 +11,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("passbook_policies", "0001_initial"), ("passbook_policies", "0003_auto_20200508_1642"),
("passbook_core", "0011_auto_20200222_1822"),
] ]
operations = [ operations = [
@ -37,6 +36,7 @@ class Migration(migrations.Migration):
("AUTHENTICATION", "authentication"), ("AUTHENTICATION", "authentication"),
("ENROLLMENT", "enrollment"), ("ENROLLMENT", "enrollment"),
("RECOVERY", "recovery"), ("RECOVERY", "recovery"),
("PASSWORD_CHANGE", "password_change"),
], ],
max_length=100, max_length=100,
), ),
@ -55,7 +55,23 @@ class Migration(migrations.Migration):
bases=("passbook_policies.policybindingmodel", models.Model), bases=("passbook_policies.policybindingmodel", models.Model),
), ),
migrations.CreateModel( migrations.CreateModel(
name="FlowFactorBinding", name="Stage",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("name", models.TextField()),
],
options={"abstract": False,},
),
migrations.CreateModel(
name="FlowStageBinding",
fields=[ fields=[
( (
"policybindingmodel_ptr", "policybindingmodel_ptr",
@ -75,14 +91,14 @@ class Migration(migrations.Migration):
serialize=False, serialize=False,
), ),
), ),
("order", models.IntegerField()),
( (
"factor", "re_evaluate_policies",
models.ForeignKey( models.BooleanField(
on_delete=django.db.models.deletion.CASCADE, default=False,
to="passbook_core.Factor", help_text="When this option is enabled, the planner will re-evaluate policies bound to this.",
), ),
), ),
("order", models.IntegerField()),
( (
"flow", "flow",
models.ForeignKey( models.ForeignKey(
@ -90,19 +106,29 @@ class Migration(migrations.Migration):
to="passbook_flows.Flow", to="passbook_flows.Flow",
), ),
), ),
(
"stage",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="passbook_flows.Stage",
),
),
], ],
options={ options={
"verbose_name": "Flow Factor Binding", "verbose_name": "Flow Stage Binding",
"verbose_name_plural": "Flow Factor Bindings", "verbose_name_plural": "Flow Stage Bindings",
"unique_together": {("flow", "factor", "order")}, "ordering": ["order", "flow"],
"unique_together": {("flow", "stage", "order")},
}, },
bases=("passbook_policies.policybindingmodel", models.Model), bases=("passbook_policies.policybindingmodel", models.Model),
), ),
migrations.AddField( migrations.AddField(
model_name="flow", model_name="flow",
name="factors", name="stages",
field=models.ManyToManyField( field=models.ManyToManyField(
through="passbook_flows.FlowFactorBinding", to="passbook_core.Factor" blank=True,
through="passbook_flows.FlowStageBinding",
to="passbook_flows.Stage",
), ),
), ),
] ]

View File

@ -9,29 +9,35 @@ from passbook.flows.models import FlowDesignation
def create_default_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def create_default_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Flow = apps.get_model("passbook_flows", "Flow") Flow = apps.get_model("passbook_flows", "Flow")
FlowFactorBinding = apps.get_model("passbook_flows", "FlowFactorBinding") FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
PasswordFactor = apps.get_model("passbook_factors_password", "PasswordFactor") PasswordStage = apps.get_model("passbook_stages_password", "PasswordStage")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
if Flow.objects.using(db_alias).all().exists(): if Flow.objects.using(db_alias).all().exists():
# Only create default flow when none exist # Only create default flow when none exist
return return
pw_factor = PasswordFactor.objects.using(db_alias).first() if not PasswordStage.objects.using(db_alias).exists():
PasswordStage.objects.using(db_alias).create(
name="password", backends=["django.contrib.auth.backends.ModelBackend"],
)
pw_stage = PasswordStage.objects.using(db_alias).first()
flow = Flow.objects.using(db_alias).create( flow = Flow.objects.using(db_alias).create(
name="default-authentication-flow", name="default-authentication-flow",
slug="default-authentication-flow", slug="default-authentication-flow",
designation=FlowDesignation.AUTHENTICATION, designation=FlowDesignation.AUTHENTICATION,
) )
FlowFactorBinding.objects.using(db_alias).create( FlowStageBinding.objects.using(db_alias).create(
flow=flow, factor=pw_factor, order=0, flow=flow, stage=pw_stage, order=0,
) )
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("passbook_flows", "0003_auto_20200508_1230"), ("passbook_flows", "0001_initial"),
("passbook_stages_password", "0001_initial"),
] ]
operations = [migrations.RunPython(create_default_flow)] operations = [migrations.RunPython(create_default_flow)]

View File

@ -1,21 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-07 19:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="flowfactorbinding",
name="re_evaluate_policies",
field=models.BooleanField(
default=False,
help_text="When this option is enabled, the planner will re-evaluate policies bound to this.",
),
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-08 12:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0002_flowfactorbinding_re_evaluate_policies"),
]
operations = [
migrations.AlterModelOptions(
name="flowfactorbinding",
options={
"ordering": ["order", "flow"],
"verbose_name": "Flow Factor Binding",
"verbose_name_plural": "Flow Factor Bindings",
},
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-08 16:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0011_auto_20200222_1822"),
("passbook_flows", "0004_default_flows"),
]
operations = [
migrations.AlterField(
model_name="flow",
name="factors",
field=models.ManyToManyField(
blank=True,
through="passbook_flows.FlowFactorBinding",
to="passbook_core.Factor",
),
),
]

View File

@ -1,11 +1,12 @@
"""Flow models""" """Flow models"""
from enum import Enum from enum import Enum
from typing import Tuple from typing import Optional, Tuple
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from model_utils.managers import InheritanceManager
from passbook.core.models import Factor from passbook.core.types import UIUserSettings
from passbook.lib.models import UUIDModel from passbook.lib.models import UUIDModel
from passbook.policies.models import PolicyBindingModel from passbook.policies.models import PolicyBindingModel
@ -17,6 +18,7 @@ class FlowDesignation(Enum):
AUTHENTICATION = "authentication" AUTHENTICATION = "authentication"
ENROLLMENT = "enrollment" ENROLLMENT = "enrollment"
RECOVERY = "recovery" RECOVERY = "recovery"
PASSWORD_CHANGE = "password_change" # nosec # noqa
@staticmethod @staticmethod
def as_choices() -> Tuple[Tuple[str, str]]: def as_choices() -> Tuple[Tuple[str, str]]:
@ -26,8 +28,28 @@ class FlowDesignation(Enum):
) )
class Stage(UUIDModel):
"""Stage is an instance of a component used in a flow. This can verify the user,
enroll the user or offer a way of recovery"""
name = models.TextField()
objects = InheritanceManager()
type = ""
form = ""
@property
def ui_user_settings(self) -> Optional[UIUserSettings]:
"""Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or an instanace of UIUserSettings."""
return None
def __str__(self):
return f"Stage {self.name}"
class Flow(PolicyBindingModel, UUIDModel): class Flow(PolicyBindingModel, UUIDModel):
"""Flow describes how a series of Factors should be executed to authenticate/enroll/recover """Flow describes how a series of Stages should be executed to authenticate/enroll/recover
a user. Additionally, policies can be applied, to specify which users a user. Additionally, policies can be applied, to specify which users
have access to this flow.""" have access to this flow."""
@ -36,7 +58,7 @@ class Flow(PolicyBindingModel, UUIDModel):
designation = models.CharField(max_length=100, choices=FlowDesignation.as_choices()) designation = models.CharField(max_length=100, choices=FlowDesignation.as_choices())
factors = models.ManyToManyField(Factor, through="FlowFactorBinding", blank=True) stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True)
pbm = models.OneToOneField( pbm = models.OneToOneField(
PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+" PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+"
@ -51,13 +73,13 @@ class Flow(PolicyBindingModel, UUIDModel):
verbose_name_plural = _("Flows") verbose_name_plural = _("Flows")
class FlowFactorBinding(PolicyBindingModel, UUIDModel): class FlowStageBinding(PolicyBindingModel, UUIDModel):
"""Relationship between Flow and Factor. Order is required and unique for """Relationship between Flow and Stage. Order is required and unique for
each flow-factor Binding. Additionally, policies can be specified, which determine if each flow-stage Binding. Additionally, policies can be specified, which determine if
this Binding applies to the current user""" this Binding applies to the current user"""
flow = models.ForeignKey("Flow", on_delete=models.CASCADE) flow = models.ForeignKey("Flow", on_delete=models.CASCADE)
factor = models.ForeignKey(Factor, on_delete=models.CASCADE) stage = models.ForeignKey(Stage, on_delete=models.CASCADE)
re_evaluate_policies = models.BooleanField( re_evaluate_policies = models.BooleanField(
default=False, default=False,
@ -69,12 +91,12 @@ class FlowFactorBinding(PolicyBindingModel, UUIDModel):
order = models.IntegerField() order = models.IntegerField()
def __str__(self) -> str: def __str__(self) -> str:
return f"Flow Factor Binding #{self.order} {self.flow} -> {self.factor}" return f"Flow Stage Binding #{self.order} {self.flow} -> {self.stage}"
class Meta: class Meta:
ordering = ["order", "flow"] ordering = ["order", "flow"]
verbose_name = _("Flow Factor Binding") verbose_name = _("Flow Stage Binding")
verbose_name_plural = _("Flow Factor Bindings") verbose_name_plural = _("Flow Stage Bindings")
unique_together = (("flow", "factor", "order"),) unique_together = (("flow", "stage", "order"),)

View File

@ -7,7 +7,7 @@ from django.http import HttpRequest
from structlog import get_logger from structlog import get_logger
from passbook.flows.exceptions import FlowNonApplicableError from passbook.flows.exceptions import FlowNonApplicableError
from passbook.flows.models import Factor, Flow from passbook.flows.models import Flow, Stage
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
LOGGER = get_logger() LOGGER = get_logger()
@ -19,19 +19,19 @@ PLAN_CONTEXT_SSO = "is_sso"
@dataclass @dataclass
class FlowPlan: class FlowPlan:
"""This data-class is the output of a FlowPlanner. It holds a flat list """This data-class is the output of a FlowPlanner. It holds a flat list
of all Factors that should be run.""" of all Stages that should be run."""
factors: List[Factor] = field(default_factory=list) stages: List[Stage] = field(default_factory=list)
context: Dict[str, Any] = field(default_factory=dict) context: Dict[str, Any] = field(default_factory=dict)
def next(self) -> Factor: def next(self) -> Stage:
"""Return next pending factor from the bottom of the list""" """Return next pending stage from the bottom of the list"""
factor_cls = self.factors.pop(0) stage_cls = self.stages.pop(0)
return factor_cls return stage_cls
class FlowPlanner: class FlowPlanner:
"""Execute all policies to plan out a flat list of all Factors """Execute all policies to plan out a flat list of all Stages
that should be applied.""" that should be applied."""
flow: Flow flow: Flow
@ -45,7 +45,7 @@ class FlowPlanner:
return engine.result return engine.result
def plan(self, request: HttpRequest) -> FlowPlan: def plan(self, request: HttpRequest) -> FlowPlan:
"""Check each of the flows' policies, check policies for each factor with PolicyBinding """Check each of the flows' policies, check policies for each stage with PolicyBinding
and return ordered list""" and return ordered list"""
LOGGER.debug("Starting planning process", flow=self.flow) LOGGER.debug("Starting planning process", flow=self.flow)
start_time = time() start_time = time()
@ -56,13 +56,18 @@ class FlowPlanner:
if not root_passing: if not root_passing:
raise FlowNonApplicableError(root_passing_messages) raise FlowNonApplicableError(root_passing_messages)
# Check Flow policies # Check Flow policies
for factor in self.flow.factors.order_by("order").select_subclasses(): for stage in (
engine = PolicyEngine(factor.policies.all(), request.user, request) self.flow.stages.order_by("flowstagebinding__order")
.select_subclasses()
.select_related()
):
binding = stage.flowstagebinding_set.get(flow__pk=self.flow.pk)
engine = PolicyEngine(binding.policies.all(), request.user, request)
engine.build() engine.build()
passing, _ = engine.result passing, _ = engine.result
if passing: if passing:
LOGGER.debug("Factor passing", factor=factor) LOGGER.debug("Stage passing", stage=stage)
plan.factors.append(factor) plan.stages.append(stage)
end_time = time() end_time = time()
LOGGER.debug( LOGGER.debug(
"Finished planning", flow=self.flow, duration_s=end_time - start_time "Finished planning", flow=self.flow, duration_s=end_time - start_time

View File

@ -1,4 +1,4 @@
"""passbook multi-factor authentication engine""" """passbook stage Base view"""
from typing import Any, Dict from typing import Any, Dict
from django.forms import ModelForm from django.forms import ModelForm
@ -11,8 +11,8 @@ from passbook.flows.views import FlowExecutorView
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
class AuthenticationFactor(TemplateView): class AuthenticationStage(TemplateView):
"""Abstract Authentication factor, inherits TemplateView but can be combined with FormView""" """Abstract Authentication stage, inherits TemplateView but can be combined with FormView"""
form: ModelForm = None form: ModelForm = None

View File

@ -1,4 +1,4 @@
"""passbook multi-factor authentication engine""" """passbook multi-stage authentication engine"""
from typing import Optional from typing import Optional
from django.contrib.auth import login from django.contrib.auth import login
@ -7,10 +7,9 @@ from django.shortcuts import get_object_or_404, redirect
from django.views.generic import View from django.views.generic import View
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Factor
from passbook.core.views.utils import PermissionDeniedView from passbook.core.views.utils import PermissionDeniedView
from passbook.flows.exceptions import FlowNonApplicableError from passbook.flows.exceptions import FlowNonApplicableError
from passbook.flows.models import Flow from passbook.flows.models import Flow, Stage
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan, FlowPlanner from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan, FlowPlanner
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
from passbook.lib.utils.reflection import class_to_path, path_to_class from passbook.lib.utils.reflection import class_to_path, path_to_class
@ -24,13 +23,13 @@ SESSION_KEY_PLAN = "passbook_flows_plan"
class FlowExecutorView(View): class FlowExecutorView(View):
"""Stage 1 Flow executor, passing requests to Factor Views""" """Stage 1 Flow executor, passing requests to Stage Views"""
flow: Flow flow: Flow
plan: FlowPlan plan: FlowPlan
current_factor: Factor current_stage: Stage
current_factor_view: View current_stage_view: View
def setup(self, request: HttpRequest, flow_slug: str): def setup(self, request: HttpRequest, flow_slug: str):
super().setup(request, flow_slug=flow_slug) super().setup(request, flow_slug=flow_slug)
@ -77,36 +76,34 @@ class FlowExecutorView(View):
else: else:
LOGGER.debug("Continuing existing plan", flow_slug=flow_slug) LOGGER.debug("Continuing existing plan", flow_slug=flow_slug)
self.plan = self.request.session[SESSION_KEY_PLAN] self.plan = self.request.session[SESSION_KEY_PLAN]
# We don't save the Plan after getting the next factor # We don't save the Plan after getting the next stage
# as it hasn't been successfully passed yet # as it hasn't been successfully passed yet
self.current_factor = self.plan.next() self.current_stage = self.plan.next()
LOGGER.debug( LOGGER.debug(
"Current factor", "Current stage", current_stage=self.current_stage, flow_slug=self.flow.slug,
current_factor=self.current_factor,
flow_slug=self.flow.slug,
) )
factor_cls = path_to_class(self.current_factor.type) stage_cls = path_to_class(self.current_stage.type)
self.current_factor_view = factor_cls(self) self.current_stage_view = stage_cls(self)
self.current_factor_view.request = request self.current_stage_view.request = request
return super().dispatch(request) return super().dispatch(request)
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass get request to current factor""" """pass get request to current stage"""
LOGGER.debug( LOGGER.debug(
"Passing GET", "Passing GET",
view_class=class_to_path(self.current_factor_view.__class__), view_class=class_to_path(self.current_stage_view.__class__),
flow_slug=self.flow.slug, flow_slug=self.flow.slug,
) )
return self.current_factor_view.get(request, *args, **kwargs) return self.current_stage_view.get(request, *args, **kwargs)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass post request to current factor""" """pass post request to current stage"""
LOGGER.debug( LOGGER.debug(
"Passing POST", "Passing POST",
view_class=class_to_path(self.current_factor_view.__class__), view_class=class_to_path(self.current_stage_view.__class__),
flow_slug=self.flow.slug, flow_slug=self.flow.slug,
) )
return self.current_factor_view.post(request, *args, **kwargs) return self.current_stage_view.post(request, *args, **kwargs)
def _initiate_plan(self) -> FlowPlan: def _initiate_plan(self) -> FlowPlan:
planner = FlowPlanner(self.flow) planner = FlowPlanner(self.flow)
@ -115,7 +112,7 @@ class FlowExecutorView(View):
return plan return plan
def _flow_done(self) -> HttpResponse: def _flow_done(self) -> HttpResponse:
"""User Successfully passed all factors""" """User Successfully passed all stages"""
backend = self.plan.context[PLAN_CONTEXT_PENDING_USER].backend backend = self.plan.context[PLAN_CONTEXT_PENDING_USER].backend
login( login(
self.request, self.plan.context[PLAN_CONTEXT_PENDING_USER], backend=backend self.request, self.plan.context[PLAN_CONTEXT_PENDING_USER], backend=backend
@ -131,34 +128,34 @@ class FlowExecutorView(View):
return redirect(next_param) return redirect(next_param)
return redirect_with_qs("passbook_core:overview") return redirect_with_qs("passbook_core:overview")
def factor_ok(self) -> HttpResponse: def stage_ok(self) -> HttpResponse:
"""Callback called by factors upon successful completion. """Callback called by stages upon successful completion.
Persists updated plan and context to session.""" Persists updated plan and context to session."""
LOGGER.debug( LOGGER.debug(
"Factor ok", "Stage ok",
factor_class=class_to_path(self.current_factor_view.__class__), stage_class=class_to_path(self.current_stage_view.__class__),
flow_slug=self.flow.slug, flow_slug=self.flow.slug,
) )
self.request.session[SESSION_KEY_PLAN] = self.plan self.request.session[SESSION_KEY_PLAN] = self.plan
if self.plan.factors: if self.plan.stages:
LOGGER.debug( LOGGER.debug(
"Continuing with next factor", "Continuing with next stage",
reamining=len(self.plan.factors), reamining=len(self.plan.stages),
flow_slug=self.flow.slug, flow_slug=self.flow.slug,
) )
return redirect_with_qs( return redirect_with_qs(
"passbook_flows:flow-executor", self.request.GET, **self.kwargs "passbook_flows:flow-executor", self.request.GET, **self.kwargs
) )
# User passed all factors # User passed all stages
LOGGER.debug( LOGGER.debug(
"User passed all factors", "User passed all stages",
user=self.plan.context[PLAN_CONTEXT_PENDING_USER], user=self.plan.context[PLAN_CONTEXT_PENDING_USER],
flow_slug=self.flow.slug, flow_slug=self.flow.slug,
) )
return self._flow_done() return self._flow_done()
def factor_invalid(self) -> HttpResponse: def stage_invalid(self) -> HttpResponse:
"""Callback used factor when data is correct but a policy denies access """Callback used stage when data is correct but a policy denies access
or the user account is disabled.""" or the user account is disabled."""
LOGGER.debug("User invalid", flow_slug=self.flow.slug) LOGGER.debug("User invalid", flow_slug=self.flow.slug)
self.cancel() self.cancel()

View File

@ -96,11 +96,11 @@ INSTALLED_APPS = [
"passbook.providers.oidc.apps.PassbookProviderOIDCConfig", "passbook.providers.oidc.apps.PassbookProviderOIDCConfig",
"passbook.providers.saml.apps.PassbookProviderSAMLConfig", "passbook.providers.saml.apps.PassbookProviderSAMLConfig",
"passbook.providers.samlv2.apps.PassbookProviderSAMLv2Config", "passbook.providers.samlv2.apps.PassbookProviderSAMLv2Config",
"passbook.factors.otp.apps.PassbookFactorOTPConfig", "passbook.stages.otp.apps.PassbookStageOTPConfig",
"passbook.factors.captcha.apps.PassbookFactorCaptchaConfig", "passbook.stages.captcha.apps.PassbookStageCaptchaConfig",
"passbook.factors.password.apps.PassbookFactorPasswordConfig", "passbook.stages.password.apps.PassbookStagePasswordConfig",
"passbook.factors.dummy.apps.PassbookFactorDummyConfig", "passbook.stages.dummy.apps.PassbookStageDummyConfig",
"passbook.factors.email.apps.PassbookFactorEmailConfig", "passbook.stages.email.apps.PassbookStageEmailConfig",
"passbook.policies.expiry.apps.PassbookPolicyExpiryConfig", "passbook.policies.expiry.apps.PassbookPolicyExpiryConfig",
"passbook.policies.reputation.apps.PassbookPolicyReputationConfig", "passbook.policies.reputation.apps.PassbookPolicyReputationConfig",
"passbook.policies.hibp.apps.PassbookPolicyHIBPConfig", "passbook.policies.hibp.apps.PassbookPolicyHIBPConfig",

View File

@ -13,7 +13,6 @@ from django.views.generic import RedirectView, View
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event, EventAction from passbook.audit.models import Event, EventAction
from passbook.factors.password.factor import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from passbook.flows.models import Flow, FlowDesignation from passbook.flows.models import Flow, FlowDesignation
from passbook.flows.planner import ( from passbook.flows.planner import (
PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_PENDING_USER,
@ -24,6 +23,7 @@ from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.utils.urls import redirect_with_qs from passbook.lib.utils.urls import redirect_with_qs
from passbook.sources.oauth.clients import get_client from passbook.sources.oauth.clients import get_client
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
LOGGER = get_logger() LOGGER = get_logger()
@ -169,7 +169,7 @@ class OAuthCallback(OAuthClientMixin, View):
return None return None
def handle_login(self, user, source, access): def handle_login(self, user, source, access):
"""Prepare AuthenticationView, redirect users to remaining Factors""" """Prepare Authentication Plan, redirect user FlowExecutor"""
user = authenticate( user = authenticate(
source=access.source, identifier=access.identifier, request=self.request source=access.source, identifier=access.identifier, request=self.request
) )

View File

@ -0,0 +1,21 @@
"""CaptchaStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.stages.captcha.models import CaptchaStage
class CaptchaStageSerializer(ModelSerializer):
"""CaptchaStage Serializer"""
class Meta:
model = CaptchaStage
fields = ["pk", "name", "public_key", "private_key"]
class CaptchaStageViewSet(ModelViewSet):
"""CaptchaStage Viewset"""
queryset = CaptchaStage.objects.all()
serializer_class = CaptchaStageSerializer

View File

@ -0,0 +1,10 @@
"""passbook captcha app"""
from django.apps import AppConfig
class PassbookStageCaptchaConfig(AppConfig):
"""passbook captcha app"""
name = "passbook.stages.captcha"
label = "passbook_stages_captcha"
verbose_name = "passbook Stages.Captcha"

View File

@ -0,0 +1,25 @@
"""passbook captcha stage forms"""
from captcha.fields import ReCaptchaField
from django import forms
from passbook.stages.captcha.models import CaptchaStage
class CaptchaForm(forms.Form):
"""passbook captcha stage form"""
captcha = ReCaptchaField()
class CaptchaStageForm(forms.ModelForm):
"""Form to edit CaptchaStage Instance"""
class Meta:
model = CaptchaStage
fields = ["name", "public_key", "private_key"]
widgets = {
"name": forms.TextInput(),
"public_key": forms.TextInput(),
"private_key": forms.TextInput(),
}

View File

@ -0,0 +1,49 @@
# Generated by Django 3.0.3 on 2020-05-08 17:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_flows", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="CaptchaStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_flows.Stage",
),
),
(
"public_key",
models.TextField(
help_text="Public key, acquired from https://www.google.com/recaptcha/intro/v3.html"
),
),
(
"private_key",
models.TextField(
help_text="Private key, acquired from https://www.google.com/recaptcha/intro/v3.html"
),
),
],
options={
"verbose_name": "Captcha Stage",
"verbose_name_plural": "Captcha Stages",
},
bases=("passbook_flows.stage",),
),
]

View File

@ -1,12 +1,12 @@
"""passbook captcha factor""" """passbook captcha stage"""
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.core.models import Factor from passbook.flows.models import Stage
class CaptchaFactor(Factor): class CaptchaStage(Stage):
"""Captcha Factor instance""" """Captcha Stage instance"""
public_key = models.TextField( public_key = models.TextField(
help_text=_( help_text=_(
@ -19,13 +19,13 @@ class CaptchaFactor(Factor):
) )
) )
type = "passbook.factors.captcha.factor.CaptchaFactor" type = "passbook.stages.captcha.stage.CaptchaStage"
form = "passbook.factors.captcha.forms.CaptchaFactorForm" form = "passbook.stages.captcha.forms.CaptchaStageForm"
def __str__(self): def __str__(self):
return f"Captcha Factor {self.slug}" return f"Captcha Stage {self.name}"
class Meta: class Meta:
verbose_name = _("Captcha Factor") verbose_name = _("Captcha Stage")
verbose_name_plural = _("Captcha Factors") verbose_name_plural = _("Captcha Stages")

View File

@ -1,4 +1,4 @@
"""passbook captcha_factor settings""" """passbook captcha stage settings"""
# https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do # https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do
RECAPTCHA_PUBLIC_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" RECAPTCHA_PUBLIC_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe"

View File

@ -1,23 +1,23 @@
"""passbook captcha factor""" """passbook captcha stage"""
from django.views.generic import FormView from django.views.generic import FormView
from passbook.factors.captcha.forms import CaptchaForm from passbook.flows.stage import AuthenticationStage
from passbook.flows.factor_base import AuthenticationFactor from passbook.stages.captcha.forms import CaptchaForm
class CaptchaFactor(FormView, AuthenticationFactor): class CaptchaStage(FormView, AuthenticationStage):
"""Simple captcha checker, logic is handeled in django-captcha module""" """Simple captcha checker, logic is handeled in django-captcha module"""
form_class = CaptchaForm form_class = CaptchaForm
def form_valid(self, form): def form_valid(self, form):
return self.executor.factor_ok() return self.executor.stage_ok()
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = CaptchaForm(**self.get_form_kwargs()) form = CaptchaForm(**self.get_form_kwargs())
form.fields["captcha"].public_key = self.executor.current_factor.public_key form.fields["captcha"].public_key = self.executor.current_stage.public_key
form.fields["captcha"].private_key = self.executor.current_factor.private_key form.fields["captcha"].private_key = self.executor.current_stage.private_key
form.fields["captcha"].widget.attrs["data-sitekey"] = form.fields[ form.fields["captcha"].widget.attrs["data-sitekey"] = form.fields[
"captcha" "captcha"
].public_key ].public_key

View File

@ -0,0 +1,21 @@
"""DummyStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.stages.dummy.models import DummyStage
class DummyStageSerializer(ModelSerializer):
"""DummyStage Serializer"""
class Meta:
model = DummyStage
fields = ["pk", "name"]
class DummyStageViewSet(ModelViewSet):
"""DummyStage Viewset"""
queryset = DummyStage.objects.all()
serializer_class = DummyStageSerializer

View File

@ -0,0 +1,11 @@
"""passbook dummy stage config"""
from django.apps import AppConfig
class PassbookStageDummyConfig(AppConfig):
"""passbook dummy stage config"""
name = "passbook.stages.dummy"
label = "passbook_stages_dummy"
verbose_name = "passbook Stages.Dummy"

View File

@ -0,0 +1,16 @@
"""passbook administration forms"""
from django import forms
from passbook.stages.dummy.models import DummyStage
class DummyStageForm(forms.ModelForm):
"""Form to create/edit Dummy Stage"""
class Meta:
model = DummyStage
fields = ["name"]
widgets = {
"name": forms.TextInput(),
}

View File

@ -1,4 +1,4 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07 # Generated by Django 3.0.3 on 2020-05-08 17:58
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -9,29 +9,29 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("passbook_core", "0001_initial"), ("passbook_flows", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="DummyFactor", name="DummyStage",
fields=[ fields=[
( (
"factor_ptr", "stage_ptr",
models.OneToOneField( models.OneToOneField(
auto_created=True, auto_created=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
parent_link=True, parent_link=True,
primary_key=True, primary_key=True,
serialize=False, serialize=False,
to="passbook_core.Factor", to="passbook_flows.Stage",
), ),
), ),
], ],
options={ options={
"verbose_name": "Dummy Factor", "verbose_name": "Dummy Stage",
"verbose_name_plural": "Dummy Factors", "verbose_name_plural": "Dummy Stages",
}, },
bases=("passbook_core.factor",), bases=("passbook_flows.stage",),
), ),
] ]

View File

@ -0,0 +1,19 @@
"""dummy stage models"""
from django.utils.translation import gettext as _
from passbook.flows.models import Stage
class DummyStage(Stage):
"""Dummy stage, mostly used to debug"""
type = "passbook.stages.dummy.stage.DummyStage"
form = "passbook.stages.dummy.forms.DummyStageForm"
def __str__(self):
return f"Dummy Stage {self.name}"
class Meta:
verbose_name = _("Dummy Stage")
verbose_name_plural = _("Dummy Stages")

View File

@ -0,0 +1,12 @@
"""passbook multi-stage authentication engine"""
from django.http import HttpRequest
from passbook.flows.stage import AuthenticationStage
class DummyStage(AuthenticationStage):
"""Dummy stage for testing with multiple stages"""
def post(self, request: HttpRequest):
"""Just redirect to next stage"""
return self.executor.stage_ok()

View File

@ -1,22 +1,19 @@
"""EmailFactor API Views""" """EmailStage API Views"""
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from passbook.factors.email.models import EmailFactor from passbook.stages.email.models import EmailStage
class EmailFactorSerializer(ModelSerializer): class EmailStageSerializer(ModelSerializer):
"""EmailFactor Serializer""" """EmailStage Serializer"""
class Meta: class Meta:
model = EmailFactor model = EmailStage
fields = [ fields = [
"pk", "pk",
"name", "name",
"slug",
"order",
"enabled",
"host", "host",
"port", "port",
"username", "username",
@ -31,8 +28,8 @@ class EmailFactorSerializer(ModelSerializer):
extra_kwargs = {"password": {"write_only": True}} extra_kwargs = {"password": {"write_only": True}}
class EmailFactorViewSet(ModelViewSet): class EmailStageViewSet(ModelViewSet):
"""EmailFactor Viewset""" """EmailStage Viewset"""
queryset = EmailFactor.objects.all() queryset = EmailStage.objects.all()
serializer_class = EmailFactorSerializer serializer_class = EmailStageSerializer

View File

@ -0,0 +1,15 @@
"""passbook email stage config"""
from importlib import import_module
from django.apps import AppConfig
class PassbookStageEmailConfig(AppConfig):
"""passbook email stage config"""
name = "passbook.stages.email"
label = "passbook_stages_email"
verbose_name = "passbook Stages.Email"
def ready(self):
import_module("passbook.stages.email.tasks")

View File

@ -1,19 +1,18 @@
"""passbook administration forms""" """passbook administration forms"""
from django import forms from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.factors.email.models import EmailFactor from passbook.stages.email.models import EmailStage
from passbook.flows.forms import GENERAL_FIELDS
class EmailFactorForm(forms.ModelForm): class EmailStageForm(forms.ModelForm):
"""Form to create/edit Dummy Factor""" """Form to create/edit Dummy Stage"""
class Meta: class Meta:
model = EmailFactor model = EmailStage
fields = GENERAL_FIELDS + [ fields = [
"name",
"host", "host",
"port", "port",
"username", "username",
@ -27,8 +26,6 @@ class EmailFactorForm(forms.ModelForm):
] ]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"order": forms.NumberInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
"host": forms.TextInput(), "host": forms.TextInput(),
"username": forms.TextInput(), "username": forms.TextInput(),
"password": forms.TextInput(), "password": forms.TextInput(),
@ -41,8 +38,3 @@ class EmailFactorForm(forms.ModelForm):
"ssl_keyfile": _("SSL Keyfile (optional)"), "ssl_keyfile": _("SSL Keyfile (optional)"),
"ssl_certfile": _("SSL Certfile (optional)"), "ssl_certfile": _("SSL Certfile (optional)"),
} }
help_texts = {
"policies": _(
"Policies which determine if this factor applies to the current user."
)
}

View File

@ -1,4 +1,4 @@
# Generated by Django 2.2.6 on 2019-10-08 12:23 # Generated by Django 3.0.3 on 2020-05-08 17:59
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -9,22 +9,22 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("passbook_core", "0001_initial"), ("passbook_flows", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="EmailFactor", name="EmailStage",
fields=[ fields=[
( (
"factor_ptr", "stage_ptr",
models.OneToOneField( models.OneToOneField(
auto_created=True, auto_created=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
parent_link=True, parent_link=True,
primary_key=True, primary_key=True,
serialize=False, serialize=False,
to="passbook_core.Factor", to="passbook_flows.Stage",
), ),
), ),
("host", models.TextField(default="localhost")), ("host", models.TextField(default="localhost")),
@ -33,7 +33,7 @@ class Migration(migrations.Migration):
("password", models.TextField(blank=True, default="")), ("password", models.TextField(blank=True, default="")),
("use_tls", models.BooleanField(default=False)), ("use_tls", models.BooleanField(default=False)),
("use_ssl", models.BooleanField(default=False)), ("use_ssl", models.BooleanField(default=False)),
("timeout", models.IntegerField(default=0)), ("timeout", models.IntegerField(default=10)),
("ssl_keyfile", models.TextField(blank=True, default=None, null=True)), ("ssl_keyfile", models.TextField(blank=True, default=None, null=True)),
("ssl_certfile", models.TextField(blank=True, default=None, null=True)), ("ssl_certfile", models.TextField(blank=True, default=None, null=True)),
( (
@ -42,9 +42,9 @@ class Migration(migrations.Migration):
), ),
], ],
options={ options={
"verbose_name": "Email Factor", "verbose_name": "Email Stage",
"verbose_name_plural": "Email Factors", "verbose_name_plural": "Email Stages",
}, },
bases=("passbook_core.factor",), bases=("passbook_flows.stage",),
), ),
] ]

View File

@ -1,13 +1,13 @@
"""email factor models""" """email stage models"""
from django.core.mail.backends.smtp import EmailBackend from django.core.mail.backends.smtp import EmailBackend
from django.db import models from django.db import models
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from passbook.core.models import Factor from passbook.flows.models import Stage
class EmailFactor(Factor): class EmailStage(Stage):
"""email factor""" """email stage"""
host = models.TextField(default="localhost") host = models.TextField(default="localhost")
port = models.IntegerField(default=25) port = models.IntegerField(default=25)
@ -22,8 +22,8 @@ class EmailFactor(Factor):
from_address = models.EmailField(default="system@passbook.local") from_address = models.EmailField(default="system@passbook.local")
type = "passbook.factors.email.factor.EmailFactorView" type = "passbook.stages.email.stage.EmailStageView"
form = "passbook.factors.email.forms.EmailFactorForm" form = "passbook.stages.email.forms.EmailStageForm"
@property @property
def backend(self) -> EmailBackend: def backend(self) -> EmailBackend:
@ -41,9 +41,9 @@ class EmailFactor(Factor):
) )
def __str__(self): def __str__(self):
return f"Email Factor {self.slug}" return f"Email Stage {self.name}"
class Meta: class Meta:
verbose_name = _("Email Factor") verbose_name = _("Email Stage")
verbose_name_plural = _("Email Factors") verbose_name_plural = _("Email Stages")

View File

@ -1,4 +1,4 @@
"""passbook multi-factor authentication engine""" """passbook multi-stage authentication engine"""
from django.contrib import messages from django.contrib import messages
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import reverse from django.shortcuts import reverse
@ -6,17 +6,17 @@ from django.utils.translation import gettext as _
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Nonce from passbook.core.models import Nonce
from passbook.factors.email.tasks import send_mails
from passbook.factors.email.utils import TemplateEmailMessage
from passbook.flows.factor_base import AuthenticationFactor
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import AuthenticationStage
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
from passbook.stages.email.tasks import send_mails
from passbook.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
class EmailFactorView(AuthenticationFactor): class EmailStageView(AuthenticationStage):
"""Dummy factor for testing with multiple factors""" """E-Mail stage which sends E-Mail for verification"""
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs["show_password_forget_notice"] = CONFIG.y( kwargs["show_password_forget_notice"] = CONFIG.y(
@ -41,10 +41,10 @@ class EmailFactorView(AuthenticationFactor):
) )
}, },
) )
send_mails(self.executor.current_factor, message) send_mails(self.executor.current_stage, message)
messages.success(request, _("Check your E-Mails for a password reset link.")) messages.success(request, _("Check your E-Mails for a password reset link."))
return self.executor.cancel() return self.executor.cancel()
def post(self, request: HttpRequest): def post(self, request: HttpRequest):
"""Just redirect to next factor""" """Just redirect to next stage"""
return self.executor.factor_ok() return self.executor.stage_ok()

View File

@ -1,4 +1,4 @@
"""email factor tasks""" """email stage tasks"""
from smtplib import SMTPException from smtplib import SMTPException
from typing import Any, Dict, List from typing import Any, Dict, List
@ -6,38 +6,38 @@ from celery import group
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from structlog import get_logger from structlog import get_logger
from passbook.factors.email.models import EmailFactor
from passbook.root.celery import CELERY_APP from passbook.root.celery import CELERY_APP
from passbook.stages.email.models import EmailStage
LOGGER = get_logger() LOGGER = get_logger()
def send_mails(factor: EmailFactor, *messages: List[EmailMessage]): def send_mails(stage: EmailStage, *messages: List[EmailMessage]):
"""Wrapper to convert EmailMessage to dict and send it from worker""" """Wrapper to convert EmailMessage to dict and send it from worker"""
tasks = [] tasks = []
for message in messages: for message in messages:
tasks.append(_send_mail_task.s(factor.pk, message.__dict__)) tasks.append(_send_mail_task.s(stage.pk, message.__dict__))
lazy_group = group(*tasks) lazy_group = group(*tasks)
promise = lazy_group() promise = lazy_group()
return promise return promise
@CELERY_APP.task(bind=True) @CELERY_APP.task(bind=True)
def _send_mail_task(self, email_factor_pk: int, message: Dict[Any, Any]): def _send_mail_task(self, email_stage_pk: int, message: Dict[Any, Any]):
"""Send E-Mail according to EmailFactor parameters from background worker. """Send E-Mail according to EmailStage parameters from background worker.
Automatically retries if message couldn't be sent.""" Automatically retries if message couldn't be sent."""
factor: EmailFactor = EmailFactor.objects.get(pk=email_factor_pk) stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
backend = factor.backend backend = stage.backend
backend.open() backend.open()
# Since django's EmailMessage objects are not JSON serialisable, # Since django's EmailMessage objects are not JSON serialisable,
# we need to rebuild them from a dict # we need to rebuild them from a dict
message_object = EmailMessage() message_object = EmailMessage()
for key, value in message.items(): for key, value in message.items():
setattr(message_object, key, value) setattr(message_object, key, value)
message_object.from_email = factor.from_address message_object.from_email = stage.from_address
LOGGER.debug("Sending mail", to=message_object.to) LOGGER.debug("Sending mail", to=message_object.to)
try: try:
num_sent = factor.backend.send_messages([message_object]) num_sent = stage.backend.send_messages([message_object])
except SMTPException as exc: except SMTPException as exc:
raise self.retry(exc=exc) raise self.retry(exc=exc)
if num_sent != 1: if num_sent != 1:

View File

@ -0,0 +1,21 @@
"""OTPStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.stages.otp.models import OTPStage
class OTPStageSerializer(ModelSerializer):
"""OTPStage Serializer"""
class Meta:
model = OTPStage
fields = ["pk", "name", "enforced"]
class OTPStageViewSet(ModelViewSet):
"""OTPStage Viewset"""
queryset = OTPStage.objects.all()
serializer_class = OTPStageSerializer

View File

@ -0,0 +1,12 @@
"""passbook OTP AppConfig"""
from django.apps.config import AppConfig
class PassbookStageOTPConfig(AppConfig):
"""passbook OTP AppConfig"""
name = "passbook.stages.otp"
label = "passbook_stages_otp"
verbose_name = "passbook Stages.OTP"
mountpoint = "user/otp/"

View File

@ -1,14 +1,12 @@
"""passbook OTP Forms""" """passbook OTP Forms"""
from django import forms from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_otp.models import Device from django_otp.models import Device
from passbook.factors.otp.models import OTPFactor from passbook.stages.otp.models import OTPStage
from passbook.flows.forms import GENERAL_FIELDS
OTP_CODE_VALIDATOR = RegexValidator( OTP_CODE_VALIDATOR = RegexValidator(
r"^[0-9a-z]{6,8}$", _("Only alpha-numeric characters are allowed.") r"^[0-9a-z]{6,8}$", _("Only alpha-numeric characters are allowed.")
@ -68,20 +66,13 @@ class OTPSetupForm(forms.Form):
return self.cleaned_data.get("code") return self.cleaned_data.get("code")
class OTPFactorForm(forms.ModelForm): class OTPStageForm(forms.ModelForm):
"""Form to edit OTPFactor instances""" """Form to edit OTPStage instances"""
class Meta: class Meta:
model = OTPFactor model = OTPStage
fields = GENERAL_FIELDS + ["enforced"] fields = ["name", "enforced"]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"order": forms.NumberInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
}
help_texts = {
"policies": _(
"Policies which determine if this factor applies to the current user."
)
} }

View File

@ -1,4 +1,4 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07 # Generated by Django 3.0.3 on 2020-05-08 17:59
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -9,36 +9,33 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("passbook_core", "0001_initial"), ("passbook_flows", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="OTPFactor", name="OTPStage",
fields=[ fields=[
( (
"factor_ptr", "stage_ptr",
models.OneToOneField( models.OneToOneField(
auto_created=True, auto_created=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
parent_link=True, parent_link=True,
primary_key=True, primary_key=True,
serialize=False, serialize=False,
to="passbook_core.Factor", to="passbook_flows.Stage",
), ),
), ),
( (
"enforced", "enforced",
models.BooleanField( models.BooleanField(
default=False, default=False,
help_text="Enforce enabled OTP for Users this factor applies to.", help_text="Enforce enabled OTP for Users this stage applies to.",
), ),
), ),
], ],
options={ options={"verbose_name": "OTP Stage", "verbose_name_plural": "OTP Stages",},
"verbose_name": "OTP Factor", bases=("passbook_flows.stage",),
"verbose_name_plural": "OTP Factors",
},
bases=("passbook_core.factor",),
), ),
] ]

View File

@ -0,0 +1,34 @@
"""OTP Stage"""
from django.db import models
from django.utils.translation import gettext as _
from passbook.core.types import UIUserSettings
from passbook.flows.models import Stage
class OTPStage(Stage):
"""OTP Stage"""
enforced = models.BooleanField(
default=False,
help_text=("Enforce enabled OTP for Users " "this stage applies to."),
)
type = "passbook.stages.otp.stages.OTPStage"
form = "passbook.stages.otp.forms.OTPStageForm"
@property
def ui_user_settings(self) -> UIUserSettings:
return UIUserSettings(
name="OTP",
icon="pficon-locked",
view_name="passbook_stages_otp:otp-user-settings",
)
def __str__(self):
return f"OTP Stage {self.name}"
class Meta:
verbose_name = _("OTP Stage")
verbose_name_plural = _("OTP Stages")

View File

@ -1,22 +1,22 @@
"""OTP Factor logic""" """OTP Stage logic"""
from django.contrib import messages from django.contrib import messages
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import FormView from django.views.generic import FormView
from django_otp import match_token, user_has_device from django_otp import match_token, user_has_device
from structlog import get_logger from structlog import get_logger
from passbook.factors.otp.forms import OTPVerifyForm
from passbook.factors.otp.views import OTP_SETTING_UP_KEY, EnableView
from passbook.flows.factor_base import AuthenticationFactor
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import AuthenticationStage
from passbook.stages.otp.forms import OTPVerifyForm
from passbook.stages.otp.views import OTP_SETTING_UP_KEY, EnableView
LOGGER = get_logger() LOGGER = get_logger()
class OTPFactor(FormView, AuthenticationFactor): class OTPStage(FormView, AuthenticationStage):
"""OTP Factor View""" """OTP Stage View"""
template_name = "otp/factor.html" template_name = "stages/otp/stage.html"
form_class = OTPVerifyForm form_class = OTPVerifyForm
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -29,7 +29,7 @@ class OTPFactor(FormView, AuthenticationFactor):
pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
if not user_has_device(pending_user): if not user_has_device(pending_user):
LOGGER.debug("User doesn't have OTP Setup.") LOGGER.debug("User doesn't have OTP Setup.")
if self.executor.current_factor.enforced: if self.executor.current_stage.enforced:
# Redirect to setup view # Redirect to setup view
LOGGER.debug("OTP is enforced, redirecting to setup") LOGGER.debug("OTP is enforced, redirecting to setup")
request.user = pending_user request.user = pending_user
@ -54,6 +54,6 @@ class OTPFactor(FormView, AuthenticationFactor):
form.cleaned_data.get("code"), form.cleaned_data.get("code"),
) )
if device: if device:
return self.executor.factor_ok() return self.executor.stage_ok()
messages.error(self.request, _("Invalid OTP.")) messages.error(self.request, _("Invalid OTP."))
return self.form_invalid(form) return self.form_invalid(form)

View File

@ -23,10 +23,10 @@
</p> </p>
<p> <p>
{% if not state %} {% if not state %}
<a href="{% url 'passbook_factors_otp:otp-enable' %}" <a href="{% url 'passbook_stages_otp:otp-enable' %}"
class="btn btn-success btn-sm">{% trans "Enable OTP" %}</a> class="btn btn-success btn-sm">{% trans "Enable OTP" %}</a>
{% else %} {% else %}
<a href="{% url 'passbook_factors_otp:otp-disable' %}" <a href="{% url 'passbook_stages_otp:otp-disable' %}"
class="btn btn-danger btn-sm">{% trans "Disable OTP" %}</a> class="btn btn-danger btn-sm">{% trans "Disable OTP" %}</a>
{% endif %} {% endif %}
</p> </p>

View File

@ -2,7 +2,7 @@
from django.urls import path from django.urls import path
from passbook.factors.otp import views from passbook.stages.otp import views
urlpatterns = [ urlpatterns = [
path("", views.UserSettingsView.as_view(), name="otp-user-settings"), path("", views.UserSettingsView.as_view(), name="otp-user-settings"),

View File

@ -19,12 +19,12 @@ from qrcode.image.svg import SvgPathImage
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event, EventAction from passbook.audit.models import Event, EventAction
from passbook.factors.otp.forms import OTPSetupForm
from passbook.factors.otp.utils import otpauth_url
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
from passbook.stages.otp.forms import OTPSetupForm
from passbook.stages.otp.utils import otpauth_url
OTP_SESSION_KEY = "passbook_factors_otp_key" OTP_SESSION_KEY = "passbook_stages_otp_key"
OTP_SETTING_UP_KEY = "passbook_factors_otp_setup" OTP_SETTING_UP_KEY = "passbook_stages_otp_setup"
LOGGER = get_logger() LOGGER = get_logger()
@ -33,7 +33,7 @@ class UserSettingsView(LoginRequiredMixin, TemplateView):
template_name = "otp/user_settings.html" template_name = "otp/user_settings.html"
# TODO: Check if OTP Factor exists and applies to user # TODO: Check if OTP Stage exists and applies to user
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
static = StaticDevice.objects.filter(user=self.request.user, confirmed=True) static = StaticDevice.objects.filter(user=self.request.user, confirmed=True)
@ -61,7 +61,7 @@ class DisableView(LoginRequiredMixin, View):
messages.success(request, "Successfully disabled OTP") messages.success(request, "Successfully disabled OTP")
# Create event with email notification # Create event with email notification
Event.new(EventAction.CUSTOM, message="User disabled OTP.").from_http(request) Event.new(EventAction.CUSTOM, message="User disabled OTP.").from_http(request)
return redirect(reverse("passbook_factors_otp:otp-user-settings")) return redirect(reverse("passbook_stages_otp:otp-user-settings"))
class EnableView(LoginRequiredMixin, FormView): class EnableView(LoginRequiredMixin, FormView):
@ -74,7 +74,7 @@ class EnableView(LoginRequiredMixin, FormView):
totp_device = None totp_device = None
static_device = None static_device = None
# TODO: Check if OTP Factor exists and applies to user # TODO: Check if OTP Stage exists and applies to user
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs["config"] = CONFIG.y("passbook") kwargs["config"] = CONFIG.y("passbook")
kwargs["title"] = _("Configure OTP") kwargs["title"] = _("Configure OTP")
@ -92,7 +92,7 @@ class EnableView(LoginRequiredMixin, FormView):
if finished_totp_devices.exists() and finished_static_devices.exists(): if finished_totp_devices.exists() and finished_static_devices.exists():
messages.error(request, _("You already have TOTP enabled!")) messages.error(request, _("You already have TOTP enabled!"))
del request.session[OTP_SETTING_UP_KEY] del request.session[OTP_SETTING_UP_KEY]
return redirect("passbook_factors_otp:otp-user-settings") return redirect("passbook_stages_otp:otp-user-settings")
request.session[OTP_SETTING_UP_KEY] = True request.session[OTP_SETTING_UP_KEY] = True
# Check if there's an unconfirmed device left to set up # Check if there's an unconfirmed device left to set up
totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=False) totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=False)
@ -127,7 +127,7 @@ class EnableView(LoginRequiredMixin, FormView):
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class=form_class) form = super().get_form(form_class=form_class)
form.device = self.totp_device form.device = self.totp_device
form.fields["qr_code"].initial = reverse("passbook_factors_otp:otp-qr") form.fields["qr_code"].initial = reverse("passbook_stages_otp:otp-qr")
tokens = [(x.token, x.token) for x in self.static_device.token_set.all()] tokens = [(x.token, x.token) for x in self.static_device.token_set.all()]
form.fields["tokens"].choices = tokens form.fields["tokens"].choices = tokens
return form return form
@ -143,7 +143,7 @@ class EnableView(LoginRequiredMixin, FormView):
Event.new(EventAction.CUSTOM, message="User enabled OTP.").from_http( Event.new(EventAction.CUSTOM, message="User enabled OTP.").from_http(
self.request self.request
) )
return redirect("passbook_factors_otp:otp-user-settings") return redirect("passbook_stages_otp:otp-user-settings")
@method_decorator(never_cache, name="dispatch") @method_decorator(never_cache, name="dispatch")

View File

@ -0,0 +1,26 @@
"""PasswordStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.stages.password.models import PasswordStage
class PasswordStageSerializer(ModelSerializer):
"""PasswordStage Serializer"""
class Meta:
model = PasswordStage
fields = [
"pk",
"name",
"backends",
"password_policies",
]
class PasswordStageViewSet(ModelViewSet):
"""PasswordStage Viewset"""
queryset = PasswordStage.objects.all()
serializer_class = PasswordStageSerializer

View File

@ -0,0 +1,10 @@
"""passbook core app config"""
from django.apps import AppConfig
class PassbookStagePasswordConfig(AppConfig):
"""passbook password stage config"""
name = "passbook.stages.password"
label = "passbook_stages_password"
verbose_name = "passbook Stages.Password"

View File

@ -4,9 +4,8 @@ from django.conf import settings
from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.factors.password.models import PasswordFactor
from passbook.flows.forms import GENERAL_FIELDS
from passbook.lib.utils.reflection import path_to_class from passbook.lib.utils.reflection import path_to_class
from passbook.stages.password.models import PasswordStage
def get_authentication_backends(): def get_authentication_backends():
@ -32,25 +31,17 @@ class PasswordForm(forms.Form):
) )
class PasswordFactorForm(forms.ModelForm): class PasswordStageForm(forms.ModelForm):
"""Form to create/edit Password Factors""" """Form to create/edit Password Stages"""
class Meta: class Meta:
model = PasswordFactor model = PasswordStage
fields = GENERAL_FIELDS + ["backends", "password_policies", "reset_factors"] fields = ["name", "backends"]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"order": forms.NumberInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
"backends": FilteredSelectMultiple( "backends": FilteredSelectMultiple(
_("backends"), False, choices=get_authentication_backends() _("backends"), False, choices=get_authentication_backends()
), ),
"password_policies": FilteredSelectMultiple(_("password policies"), False), "password_policies": FilteredSelectMultiple(_("password policies"), False),
"reset_factors": FilteredSelectMultiple(_("reset factors"), False),
}
help_texts = {
"policies": _(
"Policies which determine if this factor applies to the current user."
)
} }

View File

@ -1,4 +1,4 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07 # Generated by Django 3.0.3 on 2020-05-08 17:58
import django.contrib.postgres.fields import django.contrib.postgres.fields
import django.db.models.deletion import django.db.models.deletion
@ -10,28 +10,31 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("passbook_core", "0001_initial"), ("passbook_flows", "0001_initial"),
("passbook_core", "0012_delete_factor"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="PasswordFactor", name="PasswordStage",
fields=[ fields=[
( (
"factor_ptr", "stage_ptr",
models.OneToOneField( models.OneToOneField(
auto_created=True, auto_created=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
parent_link=True, parent_link=True,
primary_key=True, primary_key=True,
serialize=False, serialize=False,
to="passbook_core.Factor", to="passbook_flows.Stage",
), ),
), ),
( (
"backends", "backends",
django.contrib.postgres.fields.ArrayField( django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), size=None base_field=models.TextField(),
help_text="Selection of backends to test the password against.",
size=None,
), ),
), ),
( (
@ -40,9 +43,9 @@ class Migration(migrations.Migration):
), ),
], ],
options={ options={
"verbose_name": "Password Factor", "verbose_name": "Password Stage",
"verbose_name_plural": "Password Factors", "verbose_name_plural": "Password Stages",
}, },
bases=("passbook_core.factor",), bases=("passbook_flows.stage",),
), ),
] ]

View File

@ -1,26 +1,24 @@
"""password factor models""" """password stage models"""
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.core.models import Factor, Policy, User from passbook.core.models import Policy, User
from passbook.core.types import UIUserSettings from passbook.core.types import UIUserSettings
from passbook.flows.models import Stage
class PasswordFactor(Factor): class PasswordStage(Stage):
"""Password-based Django-backend Authentication Factor""" """Password-based Django-backend Authentication Stage"""
backends = ArrayField( backends = ArrayField(
models.TextField(), models.TextField(),
help_text=_("Selection of backends to test the password against."), help_text=_("Selection of backends to test the password against."),
) )
password_policies = models.ManyToManyField(Policy, blank=True) password_policies = models.ManyToManyField(Policy, blank=True)
reset_factors = models.ManyToManyField(
Factor, blank=True, related_name="reset_factors"
)
type = "passbook.factors.password.factor.PasswordFactor" type = "passbook.stages.password.stage.PasswordStage"
form = "passbook.factors.password.forms.PasswordFactorForm" form = "passbook.stages.password.forms.PasswordStageForm"
@property @property
def ui_user_settings(self) -> UIUserSettings: def ui_user_settings(self) -> UIUserSettings:
@ -38,9 +36,9 @@ class PasswordFactor(Factor):
return True return True
def __str__(self): def __str__(self):
return "Password Factor %s" % self.slug return f"Password Stage {self.name}"
class Meta: class Meta:
verbose_name = _("Password Factor") verbose_name = _("Password Stage")
verbose_name_plural = _("Password Factors") verbose_name_plural = _("Password Stages")

View File

@ -1,4 +1,4 @@
"""passbook multi-factor authentication engine""" """passbook password stage"""
from inspect import Signature from inspect import Signature
from typing import Optional from typing import Optional
@ -11,11 +11,11 @@ from django.views.generic import FormView
from structlog import get_logger from structlog import get_logger
from passbook.core.models import User from passbook.core.models import User
from passbook.factors.password.forms import PasswordForm
from passbook.flows.factor_base import AuthenticationFactor
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import AuthenticationStage
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
from passbook.lib.utils.reflection import path_to_class from passbook.lib.utils.reflection import path_to_class
from passbook.stages.password.forms import PasswordForm
LOGGER = get_logger() LOGGER = get_logger()
PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend" PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend"
@ -53,11 +53,11 @@ def authenticate(request, backends, **credentials) -> Optional[User]:
) )
class PasswordFactor(FormView, AuthenticationFactor): class PasswordStage(FormView, AuthenticationStage):
"""Authentication factor which authenticates against django's AuthBackend""" """Authentication stage which authenticates against django's AuthBackend"""
form_class = PasswordForm form_class = PasswordForm
template_name = "factors/password/backend.html" template_name = "stages/password/backend.html"
def form_valid(self, form): def form_valid(self, form):
"""Authenticate against django's authentication backend""" """Authenticate against django's authentication backend"""
@ -71,7 +71,7 @@ class PasswordFactor(FormView, AuthenticationFactor):
) )
try: try:
user = authenticate( user = authenticate(
self.request, self.executor.current_factor.backends, **kwargs self.request, self.executor.current_stage.backends, **kwargs
) )
if user: if user:
# User instance returned from authenticate() has .backend property set # User instance returned from authenticate() has .backend property set
@ -79,7 +79,7 @@ class PasswordFactor(FormView, AuthenticationFactor):
self.executor.plan.context[ self.executor.plan.context[
PLAN_CONTEXT_AUTHENTICATION_BACKEND PLAN_CONTEXT_AUTHENTICATION_BACKEND
] = user.backend ] = user.backend
return self.executor.factor_ok() return self.executor.stage_ok()
# No user was found -> invalid credentials # No user was found -> invalid credentials
LOGGER.debug("Invalid credentials") LOGGER.debug("Invalid credentials")
# Manually inject error into form # Manually inject error into form
@ -90,4 +90,4 @@ class PasswordFactor(FormView, AuthenticationFactor):
except PermissionDenied: except PermissionDenied:
# User was found, but permission was denied (i.e. user is not active) # User was found, but permission was denied (i.e. user is not active)
LOGGER.debug("Denied access", **kwargs) LOGGER.debug("Denied access", **kwargs)
return self.executor.factor_invalid() return self.executor.stage_invalid()

View File

@ -1,5 +1,5 @@
#!/bin/bash -xe #!/bin/bash -xe
coverage run --concurrency=multiprocessing manage.py test coverage run --concurrency=multiprocessing manage.py test --failfast
coverage combine coverage combine
coverage html coverage html
coverage report coverage report