factors: -> stage
This commit is contained in:
parent
08c0eb2ec6
commit
212e966dd4
|
@ -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(),
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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):
|
|
@ -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",
|
||||
|
|
|
@ -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()
|
14
passbook/core/migrations/0012_delete_factor.py
Normal file
14
passbook/core/migrations/0012_delete_factor.py
Normal 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",),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
|
@ -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"
|
|
@ -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."
|
||||
)
|
||||
}
|
|
@ -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",),
|
||||
),
|
||||
]
|
|
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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"
|
|
@ -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()
|
|
@ -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),
|
||||
}
|
|
@ -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")
|
|
@ -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")
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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/"
|
|
@ -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")
|
|
@ -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
|
|
@ -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")
|
|
@ -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)]
|
|
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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)
|
|
@ -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()
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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)]
|
|
@ -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.",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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"),)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
21
passbook/stages/captcha/api.py
Normal file
21
passbook/stages/captcha/api.py
Normal 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
|
10
passbook/stages/captcha/apps.py
Normal file
10
passbook/stages/captcha/apps.py
Normal 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"
|
25
passbook/stages/captcha/forms.py
Normal file
25
passbook/stages/captcha/forms.py
Normal 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(),
|
||||
}
|
49
passbook/stages/captcha/migrations/0001_initial.py
Normal file
49
passbook/stages/captcha/migrations/0001_initial.py
Normal 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",),
|
||||
),
|
||||
]
|
|
@ -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")
|
|
@ -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"
|
|
@ -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
|
21
passbook/stages/dummy/api.py
Normal file
21
passbook/stages/dummy/api.py
Normal 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
|
11
passbook/stages/dummy/apps.py
Normal file
11
passbook/stages/dummy/apps.py
Normal 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"
|
16
passbook/stages/dummy/forms.py
Normal file
16
passbook/stages/dummy/forms.py
Normal 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(),
|
||||
}
|
|
@ -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",),
|
||||
),
|
||||
]
|
19
passbook/stages/dummy/models.py
Normal file
19
passbook/stages/dummy/models.py
Normal 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")
|
12
passbook/stages/dummy/stage.py
Normal file
12
passbook/stages/dummy/stage.py
Normal 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()
|
|
@ -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
|
15
passbook/stages/email/apps.py
Normal file
15
passbook/stages/email/apps.py
Normal 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")
|
|
@ -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."
|
||||
)
|
||||
}
|
|
@ -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",),
|
||||
),
|
||||
]
|
|
@ -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")
|
|
@ -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()
|
|
@ -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:
|
21
passbook/stages/otp/api.py
Normal file
21
passbook/stages/otp/api.py
Normal 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
|
12
passbook/stages/otp/apps.py
Normal file
12
passbook/stages/otp/apps.py
Normal 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/"
|
|
@ -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."
|
||||
)
|
||||
}
|
|
@ -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",),
|
||||
),
|
||||
]
|
34
passbook/stages/otp/models.py
Normal file
34
passbook/stages/otp/models.py
Normal 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")
|
|
@ -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)
|
|
@ -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>
|
|
@ -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"),
|
|
@ -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")
|
26
passbook/stages/password/api.py
Normal file
26
passbook/stages/password/api.py
Normal 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
|
10
passbook/stages/password/apps.py
Normal file
10
passbook/stages/password/apps.py
Normal 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"
|
|
@ -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."
|
||||
)
|
||||
}
|
|
@ -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",),
|
||||
),
|
||||
]
|
|
@ -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")
|
|
@ -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()
|
|
@ -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
|
||||
|
|
Reference in a new issue