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

View file

@ -5,15 +5,8 @@ from django.views.generic import TemplateView
from passbook import __version__
from passbook.admin.mixins import AdminRequiredMixin
from passbook.core.models import (
Application,
Factor,
Invitation,
Policy,
Provider,
Source,
User,
)
from passbook.core.models import Application, Invitation, Policy, Provider, Source, User
from passbook.flows.models import Flow, Stage
from passbook.root.celery import CELERY_APP
@ -35,7 +28,8 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
kwargs["user_count"] = len(User.objects.all())
kwargs["provider_count"] = len(Provider.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["version"] = __version__
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.auth.mixins import LoginRequiredMixin
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 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.views import CreateAssignPermView
@ -23,18 +23,18 @@ def all_subclasses(cls):
)
class FactorListView(LoginRequiredMixin, PermissionListMixin, ListView):
"""Show list of all factors"""
class StageListView(LoginRequiredMixin, PermissionListMixin, ListView):
"""Show list of all flows"""
model = Factor
template_name = "administration/factor/list.html"
permission_required = "passbook_core.view_factor"
model = Stage
template_name = "administration/flow/list.html"
permission_required = "passbook_core.view_flow"
ordering = "order"
paginate_by = 40
def get_context_data(self, **kwargs):
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)
@ -42,46 +42,46 @@ class FactorListView(LoginRequiredMixin, PermissionListMixin, ListView):
return super().get_queryset().select_subclasses()
class FactorCreateView(
class StageCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create new Factor"""
"""Create new Stage"""
model = Factor
model = Stage
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_message = _("Successfully created Factor")
success_url = reverse_lazy("passbook_admin:flows")
success_message = _("Successfully created Stage")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
factor_type = self.request.GET.get("type")
model = next(x for x in all_subclasses(Factor) if x.__name__ == factor_type)
flow_type = self.request.GET.get("type")
model = next(x for x in all_subclasses(Stage) if x.__name__ == flow_type)
kwargs["type"] = model._meta.verbose_name
return kwargs
def get_form_class(self):
factor_type = self.request.GET.get("type")
model = next(x for x in all_subclasses(Factor) if x.__name__ == factor_type)
flow_type = self.request.GET.get("type")
model = next(x for x in all_subclasses(Stage) if x.__name__ == flow_type)
if not model:
raise Http404
return path_to_class(model.form)
class FactorUpdateView(
class StageUpdateView(
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
):
"""Update factor"""
"""Update flow"""
model = Factor
model = Stage
permission_required = "passbook_core.update_application"
template_name = "generic/update.html"
success_url = reverse_lazy("passbook_admin:factors")
success_message = _("Successfully updated Factor")
success_url = reverse_lazy("passbook_admin:flows")
success_message = _("Successfully updated Stage")
def get_form_class(self):
form_class_path = self.get_object().form
@ -90,24 +90,24 @@ class FactorUpdateView(
def get_object(self, queryset=None):
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
):
"""Delete factor"""
"""Delete flow"""
model = Factor
model = Stage
template_name = "generic/delete.html"
permission_required = "passbook_core.delete_factor"
success_url = reverse_lazy("passbook_admin:factors")
success_message = _("Successfully deleted Factor")
permission_required = "passbook_core.delete_flow"
success_url = reverse_lazy("passbook_admin:flows")
success_message = _("Successfully deleted Stage")
def get_object(self, queryset=None):
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):

View file

@ -9,7 +9,6 @@ from structlog import get_logger
from passbook.api.permissions import CustomObjectPermissions
from passbook.audit.api import EventViewSet
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.invitations import InvitationViewSet
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.sources import SourceViewSet
from passbook.core.api.users import UserViewSet
from passbook.factors.captcha.api import CaptchaFactorViewSet
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.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet
from passbook.lib.utils.reflection import get_apps
from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet
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.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
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()
router = routers.DefaultRouter()
@ -69,14 +68,14 @@ router.register("providers/saml", SAMLProviderViewSet)
router.register("propertymappings/all", PropertyMappingViewSet)
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
router.register("factors/all", FactorViewSet)
router.register("factors/captcha", CaptchaFactorViewSet)
router.register("factors/dummy", DummyFactorViewSet)
router.register("factors/email", EmailFactorViewSet)
router.register("factors/otp", OTPFactorViewSet)
router.register("factors/password", PasswordFactorViewSet)
router.register("stages/all", StageViewSet)
router.register("stages/captcha", CaptchaStageViewSet)
router.register("stages/dummy", DummyStageViewSet)
router.register("stages/email", EmailStageViewSet)
router.register("stages/otp", OTPStageViewSet)
router.register("stages/password", PasswordStageViewSet)
router.register("flows", FlowViewSet)
router.register("flows/bindings", FlowFactorBindingViewSet)
router.register("flows/bindings", FlowStageBindingViewSet)
info = openapi.Info(
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)
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):
"""Every Application which uses passbook for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to

View file

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

View file

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

View file

@ -5,7 +5,7 @@ from typing import Optional
@dataclass
class UIUserSettings:
"""Dataclass for Factor and Source's user_settings"""
"""Dataclass for Stage and Source's user_settings"""
name: 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.models import Invitation, Nonce, Source, User
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.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.lib.config import CONFIG
from passbook.lib.utils.urls import redirect_with_qs
from passbook.stages.password.exceptions import PasswordPolicyInvalid
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 passbook.core.forms.users import PasswordChangeForm, UserDetailForm
from passbook.factors.password.exceptions import PasswordPolicyInvalid
from passbook.lib.config import CONFIG
from passbook.stages.password.exceptions import PasswordPolicyInvalid
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"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from rest_framework.serializers import ModelSerializer, SerializerMethodField
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):
@ -11,7 +11,7 @@ class FlowSerializer(ModelSerializer):
class Meta:
model = Flow
fields = ["pk", "name", "slug", "designation", "factors", "policies"]
fields = ["pk", "name", "slug", "designation", "stages", "policies"]
class FlowViewSet(ModelViewSet):
@ -21,17 +21,42 @@ class FlowViewSet(ModelViewSet):
serializer_class = FlowSerializer
class FlowFactorBindingSerializer(ModelSerializer):
"""FlowFactorBinding Serializer"""
class FlowStageBindingSerializer(ModelSerializer):
"""FlowStageBinding Serializer"""
class Meta:
model = FlowFactorBinding
fields = ["pk", "flow", "factor", "re_evaluate_policies", "order", "policies"]
model = FlowStageBinding
fields = ["pk", "flow", "stage", "re_evaluate_policies", "order", "policies"]
class FlowFactorBindingViewSet(ModelViewSet):
"""FlowFactorBinding Viewset"""
class FlowStageBindingViewSet(ModelViewSet):
"""FlowStageBinding Viewset"""
queryset = FlowFactorBinding.objects.all()
serializer_class = FlowFactorBindingSerializer
queryset = FlowStageBinding.objects.all()
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.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
from passbook.flows.models import Flow, FlowFactorBinding
GENERAL_FIELDS = ["name", "slug", "order", "policies", "enabled"]
from passbook.flows.models import Flow, FlowStageBinding
class FlowForm(forms.ModelForm):
@ -19,29 +17,30 @@ class FlowForm(forms.ModelForm):
"name",
"slug",
"designation",
"factors",
"stages",
"policies",
]
widgets = {
"name": forms.TextInput(),
"factors": FilteredSelectMultiple(_("policies"), False),
"stages": FilteredSelectMultiple(_("stages"), False),
"policies": FilteredSelectMultiple(_("policies"), False),
}
class FlowFactorBindingForm(forms.ModelForm):
"""FlowFactorBinding Form"""
class FlowStageBindingForm(forms.ModelForm):
"""FlowStageBinding Form"""
class Meta:
model = FlowFactorBinding
model = FlowStageBinding
fields = [
"flow",
"factor",
"stage",
"re_evaluate_policies",
"order",
"policies",
]
widgets = {
"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
@ -11,8 +11,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_policies", "0001_initial"),
("passbook_core", "0011_auto_20200222_1822"),
("passbook_policies", "0003_auto_20200508_1642"),
]
operations = [
@ -37,6 +36,7 @@ class Migration(migrations.Migration):
("AUTHENTICATION", "authentication"),
("ENROLLMENT", "enrollment"),
("RECOVERY", "recovery"),
("PASSWORD_CHANGE", "password_change"),
],
max_length=100,
),
@ -55,7 +55,23 @@ class Migration(migrations.Migration):
bases=("passbook_policies.policybindingmodel", models.Model),
),
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=[
(
"policybindingmodel_ptr",
@ -75,14 +91,14 @@ class Migration(migrations.Migration):
serialize=False,
),
),
("order", models.IntegerField()),
(
"factor",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="passbook_core.Factor",
"re_evaluate_policies",
models.BooleanField(
default=False,
help_text="When this option is enabled, the planner will re-evaluate policies bound to this.",
),
),
("order", models.IntegerField()),
(
"flow",
models.ForeignKey(
@ -90,19 +106,29 @@ class Migration(migrations.Migration):
to="passbook_flows.Flow",
),
),
(
"stage",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="passbook_flows.Stage",
),
),
],
options={
"verbose_name": "Flow Factor Binding",
"verbose_name_plural": "Flow Factor Bindings",
"unique_together": {("flow", "factor", "order")},
"verbose_name": "Flow Stage Binding",
"verbose_name_plural": "Flow Stage Bindings",
"ordering": ["order", "flow"],
"unique_together": {("flow", "stage", "order")},
},
bases=("passbook_policies.policybindingmodel", models.Model),
),
migrations.AddField(
model_name="flow",
name="factors",
name="stages",
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):
Flow = apps.get_model("passbook_flows", "Flow")
FlowFactorBinding = apps.get_model("passbook_flows", "FlowFactorBinding")
PasswordFactor = apps.get_model("passbook_factors_password", "PasswordFactor")
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
PasswordStage = apps.get_model("passbook_stages_password", "PasswordStage")
db_alias = schema_editor.connection.alias
if Flow.objects.using(db_alias).all().exists():
# Only create default flow when none exist
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(
name="default-authentication-flow",
slug="default-authentication-flow",
designation=FlowDesignation.AUTHENTICATION,
)
FlowFactorBinding.objects.using(db_alias).create(
flow=flow, factor=pw_factor, order=0,
FlowStageBinding.objects.using(db_alias).create(
flow=flow, stage=pw_stage, order=0,
)
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0003_auto_20200508_1230"),
("passbook_flows", "0001_initial"),
("passbook_stages_password", "0001_initial"),
]
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"""
from enum import Enum
from typing import Tuple
from typing import Optional, Tuple
from django.db import models
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.policies.models import PolicyBindingModel
@ -17,6 +18,7 @@ class FlowDesignation(Enum):
AUTHENTICATION = "authentication"
ENROLLMENT = "enrollment"
RECOVERY = "recovery"
PASSWORD_CHANGE = "password_change" # nosec # noqa
@staticmethod
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):
"""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
have access to this flow."""
@ -36,7 +58,7 @@ class Flow(PolicyBindingModel, UUIDModel):
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(
PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+"
@ -51,13 +73,13 @@ class Flow(PolicyBindingModel, UUIDModel):
verbose_name_plural = _("Flows")
class FlowFactorBinding(PolicyBindingModel, UUIDModel):
"""Relationship between Flow and Factor. Order is required and unique for
each flow-factor Binding. Additionally, policies can be specified, which determine if
class FlowStageBinding(PolicyBindingModel, UUIDModel):
"""Relationship between Flow and Stage. Order is required and unique for
each flow-stage Binding. Additionally, policies can be specified, which determine if
this Binding applies to the current user"""
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(
default=False,
@ -69,12 +91,12 @@ class FlowFactorBinding(PolicyBindingModel, UUIDModel):
order = models.IntegerField()
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:
ordering = ["order", "flow"]
verbose_name = _("Flow Factor Binding")
verbose_name_plural = _("Flow Factor Bindings")
unique_together = (("flow", "factor", "order"),)
verbose_name = _("Flow Stage Binding")
verbose_name_plural = _("Flow Stage Bindings")
unique_together = (("flow", "stage", "order"),)

View file

@ -7,7 +7,7 @@ from django.http import HttpRequest
from structlog import get_logger
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
LOGGER = get_logger()
@ -19,19 +19,19 @@ PLAN_CONTEXT_SSO = "is_sso"
@dataclass
class FlowPlan:
"""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)
def next(self) -> Factor:
"""Return next pending factor from the bottom of the list"""
factor_cls = self.factors.pop(0)
return factor_cls
def next(self) -> Stage:
"""Return next pending stage from the bottom of the list"""
stage_cls = self.stages.pop(0)
return stage_cls
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."""
flow: Flow
@ -45,7 +45,7 @@ class FlowPlanner:
return engine.result
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"""
LOGGER.debug("Starting planning process", flow=self.flow)
start_time = time()
@ -56,13 +56,18 @@ class FlowPlanner:
if not root_passing:
raise FlowNonApplicableError(root_passing_messages)
# Check Flow policies
for factor in self.flow.factors.order_by("order").select_subclasses():
engine = PolicyEngine(factor.policies.all(), request.user, request)
for stage in (
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()
passing, _ = engine.result
if passing:
LOGGER.debug("Factor passing", factor=factor)
plan.factors.append(factor)
LOGGER.debug("Stage passing", stage=stage)
plan.stages.append(stage)
end_time = time()
LOGGER.debug(
"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 django.forms import ModelForm
@ -11,8 +11,8 @@ from passbook.flows.views import FlowExecutorView
from passbook.lib.config import CONFIG
class AuthenticationFactor(TemplateView):
"""Abstract Authentication factor, inherits TemplateView but can be combined with FormView"""
class AuthenticationStage(TemplateView):
"""Abstract Authentication stage, inherits TemplateView but can be combined with FormView"""
form: ModelForm = None

View file

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

View file

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

View file

@ -13,7 +13,6 @@ from django.views.generic import RedirectView, View
from structlog import get_logger
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.planner import (
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.sources.oauth.clients import get_client
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
LOGGER = get_logger()
@ -169,7 +169,7 @@ class OAuthCallback(OAuthClientMixin, View):
return None
def handle_login(self, user, source, access):
"""Prepare AuthenticationView, redirect users to remaining Factors"""
"""Prepare Authentication Plan, redirect user FlowExecutor"""
user = authenticate(
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.utils.translation import gettext_lazy as _
from passbook.core.models import Factor
from passbook.flows.models import Stage
class CaptchaFactor(Factor):
"""Captcha Factor instance"""
class CaptchaStage(Stage):
"""Captcha Stage instance"""
public_key = models.TextField(
help_text=_(
@ -19,13 +19,13 @@ class CaptchaFactor(Factor):
)
)
type = "passbook.factors.captcha.factor.CaptchaFactor"
form = "passbook.factors.captcha.forms.CaptchaFactorForm"
type = "passbook.stages.captcha.stage.CaptchaStage"
form = "passbook.stages.captcha.forms.CaptchaStageForm"
def __str__(self):
return f"Captcha Factor {self.slug}"
return f"Captcha Stage {self.name}"
class Meta:
verbose_name = _("Captcha Factor")
verbose_name_plural = _("Captcha Factors")
verbose_name = _("Captcha Stage")
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
RECAPTCHA_PUBLIC_KEY = "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
RECAPTCHA_PRIVATE_KEY = "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe"

View file

@ -1,23 +1,23 @@
"""passbook captcha factor"""
"""passbook captcha stage"""
from django.views.generic import FormView
from passbook.factors.captcha.forms import CaptchaForm
from passbook.flows.factor_base import AuthenticationFactor
from passbook.flows.stage import AuthenticationStage
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"""
form_class = CaptchaForm
def form_valid(self, form):
return self.executor.factor_ok()
return self.executor.stage_ok()
def get_form(self, form_class=None):
form = CaptchaForm(**self.get_form_kwargs())
form.fields["captcha"].public_key = self.executor.current_factor.public_key
form.fields["captcha"].private_key = self.executor.current_factor.private_key
form.fields["captcha"].public_key = self.executor.current_stage.public_key
form.fields["captcha"].private_key = self.executor.current_stage.private_key
form.fields["captcha"].widget.attrs["data-sitekey"] = form.fields[
"captcha"
].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
from django.db import migrations, models
@ -9,29 +9,29 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0001_initial"),
("passbook_flows", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="DummyFactor",
name="DummyStage",
fields=[
(
"factor_ptr",
"stage_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",
to="passbook_flows.Stage",
),
),
],
options={
"verbose_name": "Dummy Factor",
"verbose_name_plural": "Dummy Factors",
"verbose_name": "Dummy Stage",
"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.viewsets import ModelViewSet
from passbook.factors.email.models import EmailFactor
from passbook.stages.email.models import EmailStage
class EmailFactorSerializer(ModelSerializer):
"""EmailFactor Serializer"""
class EmailStageSerializer(ModelSerializer):
"""EmailStage Serializer"""
class Meta:
model = EmailFactor
model = EmailStage
fields = [
"pk",
"name",
"slug",
"order",
"enabled",
"host",
"port",
"username",
@ -31,8 +28,8 @@ class EmailFactorSerializer(ModelSerializer):
extra_kwargs = {"password": {"write_only": True}}
class EmailFactorViewSet(ModelViewSet):
"""EmailFactor Viewset"""
class EmailStageViewSet(ModelViewSet):
"""EmailStage Viewset"""
queryset = EmailFactor.objects.all()
serializer_class = EmailFactorSerializer
queryset = EmailStage.objects.all()
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"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
from passbook.factors.email.models import EmailFactor
from passbook.flows.forms import GENERAL_FIELDS
from passbook.stages.email.models import EmailStage
class EmailFactorForm(forms.ModelForm):
"""Form to create/edit Dummy Factor"""
class EmailStageForm(forms.ModelForm):
"""Form to create/edit Dummy Stage"""
class Meta:
model = EmailFactor
fields = GENERAL_FIELDS + [
model = EmailStage
fields = [
"name",
"host",
"port",
"username",
@ -27,8 +26,6 @@ class EmailFactorForm(forms.ModelForm):
]
widgets = {
"name": forms.TextInput(),
"order": forms.NumberInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
"host": forms.TextInput(),
"username": forms.TextInput(),
"password": forms.TextInput(),
@ -41,8 +38,3 @@ class EmailFactorForm(forms.ModelForm):
"ssl_keyfile": _("SSL Keyfile (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
from django.db import migrations, models
@ -9,22 +9,22 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0001_initial"),
("passbook_flows", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="EmailFactor",
name="EmailStage",
fields=[
(
"factor_ptr",
"stage_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",
to="passbook_flows.Stage",
),
),
("host", models.TextField(default="localhost")),
@ -33,7 +33,7 @@ class Migration(migrations.Migration):
("password", models.TextField(blank=True, default="")),
("use_tls", 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_certfile", models.TextField(blank=True, default=None, null=True)),
(
@ -42,9 +42,9 @@ class Migration(migrations.Migration):
),
],
options={
"verbose_name": "Email Factor",
"verbose_name_plural": "Email Factors",
"verbose_name": "Email Stage",
"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.db import models
from django.utils.translation import gettext as _
from passbook.core.models import Factor
from passbook.flows.models import Stage
class EmailFactor(Factor):
"""email factor"""
class EmailStage(Stage):
"""email stage"""
host = models.TextField(default="localhost")
port = models.IntegerField(default=25)
@ -22,8 +22,8 @@ class EmailFactor(Factor):
from_address = models.EmailField(default="system@passbook.local")
type = "passbook.factors.email.factor.EmailFactorView"
form = "passbook.factors.email.forms.EmailFactorForm"
type = "passbook.stages.email.stage.EmailStageView"
form = "passbook.stages.email.forms.EmailStageForm"
@property
def backend(self) -> EmailBackend:
@ -41,9 +41,9 @@ class EmailFactor(Factor):
)
def __str__(self):
return f"Email Factor {self.slug}"
return f"Email Stage {self.name}"
class Meta:
verbose_name = _("Email Factor")
verbose_name_plural = _("Email Factors")
verbose_name = _("Email Stage")
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.http import HttpRequest
from django.shortcuts import reverse
@ -6,17 +6,17 @@ from django.utils.translation import gettext as _
from structlog import get_logger
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.stage import AuthenticationStage
from passbook.lib.config import CONFIG
from passbook.stages.email.tasks import send_mails
from passbook.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger()
class EmailFactorView(AuthenticationFactor):
"""Dummy factor for testing with multiple factors"""
class EmailStageView(AuthenticationStage):
"""E-Mail stage which sends E-Mail for verification"""
def get_context_data(self, **kwargs):
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."))
return self.executor.cancel()
def post(self, request: HttpRequest):
"""Just redirect to next factor"""
return self.executor.factor_ok()
"""Just redirect to next stage"""
return self.executor.stage_ok()

View file

@ -1,4 +1,4 @@
"""email factor tasks"""
"""email stage tasks"""
from smtplib import SMTPException
from typing import Any, Dict, List
@ -6,38 +6,38 @@ from celery import group
from django.core.mail import EmailMessage
from structlog import get_logger
from passbook.factors.email.models import EmailFactor
from passbook.root.celery import CELERY_APP
from passbook.stages.email.models import EmailStage
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"""
tasks = []
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)
promise = lazy_group()
return promise
@CELERY_APP.task(bind=True)
def _send_mail_task(self, email_factor_pk: int, message: Dict[Any, Any]):
"""Send E-Mail according to EmailFactor parameters from background worker.
def _send_mail_task(self, email_stage_pk: int, message: Dict[Any, Any]):
"""Send E-Mail according to EmailStage parameters from background worker.
Automatically retries if message couldn't be sent."""
factor: EmailFactor = EmailFactor.objects.get(pk=email_factor_pk)
backend = factor.backend
stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
backend = stage.backend
backend.open()
# Since django's EmailMessage objects are not JSON serialisable,
# we need to rebuild them from a dict
message_object = EmailMessage()
for key, value in message.items():
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)
try:
num_sent = factor.backend.send_messages([message_object])
num_sent = stage.backend.send_messages([message_object])
except SMTPException as exc:
raise self.retry(exc=exc)
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"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.core.validators import RegexValidator
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_otp.models import Device
from passbook.factors.otp.models import OTPFactor
from passbook.flows.forms import GENERAL_FIELDS
from passbook.stages.otp.models import OTPStage
OTP_CODE_VALIDATOR = RegexValidator(
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")
class OTPFactorForm(forms.ModelForm):
"""Form to edit OTPFactor instances"""
class OTPStageForm(forms.ModelForm):
"""Form to edit OTPStage instances"""
class Meta:
model = OTPFactor
fields = GENERAL_FIELDS + ["enforced"]
model = OTPStage
fields = ["name", "enforced"]
widgets = {
"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
from django.db import migrations, models
@ -9,36 +9,33 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0001_initial"),
("passbook_flows", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="OTPFactor",
name="OTPStage",
fields=[
(
"factor_ptr",
"stage_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",
to="passbook_flows.Stage",
),
),
(
"enforced",
models.BooleanField(
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={
"verbose_name": "OTP Factor",
"verbose_name_plural": "OTP Factors",
},
bases=("passbook_core.factor",),
options={"verbose_name": "OTP Stage", "verbose_name_plural": "OTP Stages",},
bases=("passbook_flows.stage",),
),
]

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

View file

@ -23,10 +23,10 @@
</p>
<p>
{% 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>
{% 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>
{% endif %}
</p>

View file

@ -2,7 +2,7 @@
from django.urls import path
from passbook.factors.otp import views
from passbook.stages.otp import views
urlpatterns = [
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 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.stages.otp.forms import OTPSetupForm
from passbook.stages.otp.utils import otpauth_url
OTP_SESSION_KEY = "passbook_factors_otp_key"
OTP_SETTING_UP_KEY = "passbook_factors_otp_setup"
OTP_SESSION_KEY = "passbook_stages_otp_key"
OTP_SETTING_UP_KEY = "passbook_stages_otp_setup"
LOGGER = get_logger()
@ -33,7 +33,7 @@ class UserSettingsView(LoginRequiredMixin, TemplateView):
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):
kwargs = super().get_context_data(**kwargs)
static = StaticDevice.objects.filter(user=self.request.user, confirmed=True)
@ -61,7 +61,7 @@ class DisableView(LoginRequiredMixin, View):
messages.success(request, "Successfully disabled OTP")
# Create event with email notification
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):
@ -74,7 +74,7 @@ class EnableView(LoginRequiredMixin, FormView):
totp_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):
kwargs["config"] = CONFIG.y("passbook")
kwargs["title"] = _("Configure OTP")
@ -92,7 +92,7 @@ class EnableView(LoginRequiredMixin, FormView):
if finished_totp_devices.exists() and finished_static_devices.exists():
messages.error(request, _("You already have TOTP enabled!"))
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
# Check if there's an unconfirmed device left to set up
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):
form = super().get_form(form_class=form_class)
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()]
form.fields["tokens"].choices = tokens
return form
@ -143,7 +143,7 @@ class EnableView(LoginRequiredMixin, FormView):
Event.new(EventAction.CUSTOM, message="User enabled OTP.").from_http(
self.request
)
return redirect("passbook_factors_otp:otp-user-settings")
return redirect("passbook_stages_otp:otp-user-settings")
@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.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.stages.password.models import PasswordStage
def get_authentication_backends():
@ -32,25 +31,17 @@ class PasswordForm(forms.Form):
)
class PasswordFactorForm(forms.ModelForm):
"""Form to create/edit Password Factors"""
class PasswordStageForm(forms.ModelForm):
"""Form to create/edit Password Stages"""
class Meta:
model = PasswordFactor
fields = GENERAL_FIELDS + ["backends", "password_policies", "reset_factors"]
model = PasswordStage
fields = ["name", "backends"]
widgets = {
"name": forms.TextInput(),
"order": forms.NumberInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
"backends": FilteredSelectMultiple(
_("backends"), False, choices=get_authentication_backends()
),
"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.db.models.deletion
@ -10,28 +10,31 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0001_initial"),
("passbook_flows", "0001_initial"),
("passbook_core", "0012_delete_factor"),
]
operations = [
migrations.CreateModel(
name="PasswordFactor",
name="PasswordStage",
fields=[
(
"factor_ptr",
"stage_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",
to="passbook_flows.Stage",
),
),
(
"backends",
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={
"verbose_name": "Password Factor",
"verbose_name_plural": "Password Factors",
"verbose_name": "Password Stage",
"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.db import models
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.flows.models import Stage
class PasswordFactor(Factor):
"""Password-based Django-backend Authentication Factor"""
class PasswordStage(Stage):
"""Password-based Django-backend Authentication Stage"""
backends = ArrayField(
models.TextField(),
help_text=_("Selection of backends to test the password against."),
)
password_policies = models.ManyToManyField(Policy, blank=True)
reset_factors = models.ManyToManyField(
Factor, blank=True, related_name="reset_factors"
)
type = "passbook.factors.password.factor.PasswordFactor"
form = "passbook.factors.password.forms.PasswordFactorForm"
type = "passbook.stages.password.stage.PasswordStage"
form = "passbook.stages.password.forms.PasswordStageForm"
@property
def ui_user_settings(self) -> UIUserSettings:
@ -38,9 +36,9 @@ class PasswordFactor(Factor):
return True
def __str__(self):
return "Password Factor %s" % self.slug
return f"Password Stage {self.name}"
class Meta:
verbose_name = _("Password Factor")
verbose_name_plural = _("Password Factors")
verbose_name = _("Password Stage")
verbose_name_plural = _("Password Stages")

View file

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

View file

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