commit
8cfd3f9a2b
|
@ -0,0 +1,20 @@
|
|||
# Generated by FOSSA CLI (https://github.com/fossas/fossa-cli)
|
||||
# Visit https://fossa.com to learn more
|
||||
|
||||
version: 2
|
||||
cli:
|
||||
server: https://app.fossa.com
|
||||
fetcher: custom
|
||||
project: git@github.com:BeryJu/passbook.git
|
||||
analyze:
|
||||
modules:
|
||||
- name: static
|
||||
type: npm
|
||||
target: passbook/static/static
|
||||
path: passbook/static/static
|
||||
- name: .
|
||||
type: pip
|
||||
target: .
|
||||
path: .
|
||||
options:
|
||||
strategy: pipenv
|
|
@ -123,6 +123,11 @@ jobs:
|
|||
run: sudo pip install -U wheel pipenv && pipenv install --dev
|
||||
- name: Run coverage
|
||||
run: pipenv run ./scripts/coverage.sh
|
||||
- name: Create XML Report
|
||||
run: pipenv run coverage xml
|
||||
- uses: codecov/codecov-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
# Build
|
||||
build-server:
|
||||
needs:
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
# passbook
|
||||
|
||||
![](https://github.com/BeryJu/passbook/workflows/passbook-ci/badge.svg)
|
||||
![](https://img.shields.io/github/workflow/status/beryju/passbook/passbook-ci?style=flat-square)
|
||||
![](https://img.shields.io/docker/pulls/beryju/passbook.svg?style=flat-square)
|
||||
![](https://img.shields.io/docker/v/beryju/passbook?sort=semver&style=flat-square)
|
||||
![](https://img.shields.io/codecov/c/gh/beryju/passbook?style=flat-square)
|
||||
|
||||
## Quick instance
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ The following objects are passed into the variable:
|
|||
- `request.user`: The current User, which the Policy is applied against. ([ref](../../property-mappings/reference/user-object.md))
|
||||
- `request.http_request`: The Django HTTP Request, as documented [here](https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects).
|
||||
- `request.obj`: A Django Model instance. This is only set if the Policy is ran against an object.
|
||||
- `pb_flow_plan`: Current Plan if Policy is called while a flow is active.
|
||||
- `pb_is_sso_flow`: Boolean which is true if request was initiated by authenticating through an external Provider.
|
||||
- `pb_is_group_member(user, group_name)`: Function which checks if `user` is member of a Group with Name `gorup_name`.
|
||||
- `pb_logger`: Standard Python Logger Object, which can be used to debug expressions.
|
||||
|
|
|
@ -6,8 +6,8 @@ from defusedxml import defuse_stdlib
|
|||
|
||||
defuse_stdlib()
|
||||
|
||||
if __name__ == '__main__':
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'passbook.root.settings')
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.root.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
|
|
|
@ -53,9 +53,15 @@
|
|||
</a>
|
||||
</li>
|
||||
<li class="pf-c-nav__item">
|
||||
<a href="{% url 'passbook_admin:factors' %}"
|
||||
class="pf-c-nav__link {% is_active 'passbook_admin:factors' 'passbook_admin:factor-create' 'passbook_admin:factor-update' 'passbook_admin:factor-delete' %}">
|
||||
{% trans 'Factors' %}
|
||||
<a href="{% url 'passbook_admin:flows' %}"
|
||||
class="pf-c-nav__link {% is_active 'passbook_admin:flows' 'passbook_admin:flow-create' 'passbook_admin:flow-update' 'passbook_admin:flow-delete' %}">
|
||||
{% trans 'Flows' %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="pf-c-nav__item">
|
||||
<a href="{% url 'passbook_admin:stages' %}"
|
||||
class="pf-c-nav__link {% is_active 'passbook_admin:stages' 'passbook_admin:stage-create' 'passbook_admin:stage-update' 'passbook_admin:stage-delete' %}">
|
||||
{% trans 'Stages' %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="pf-c-nav__item">
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
{% extends "administration/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load utils %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
<div class="pf-c-content">
|
||||
<h1>
|
||||
<i class="pf-icon pf-icon-process-automation"></i>
|
||||
{% trans 'Flows' %}
|
||||
</h1>
|
||||
<p>{% trans "Flows describe a chain of Stages to authenticate, enroll or recover a user. Stages are chosen based on policies applied to them." %}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-toolbar" id="page-layout-table-simple-toolbar-top">
|
||||
<div class="pf-c-toolbar__action-group">
|
||||
<a href="{% url 'passbook_admin:flow-create' %}?back={{ request.get_full_path }}" class="pf-c-button pf-m-primary" type="button">{% trans 'Create' %}</a>
|
||||
</div>
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Designation' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Stages' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Policies' %}</th>
|
||||
<th role="cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{% for flow in object_list %}
|
||||
<tr role="row">
|
||||
<th role="columnheader">
|
||||
<div>
|
||||
<div>{{ flow.name }}</div>
|
||||
<small>{{ flow.slug }}</small>
|
||||
</div>
|
||||
</th>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ flow.designation }}
|
||||
</span>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ flow.stages.all|length }}
|
||||
</span>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ flow.policies.all|length }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:flow-update' pk=flow.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:flow-delete' pk=flow.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pf-c-toolbar" id="page-layout-table-simple-toolbar-bottom">
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
|
@ -48,16 +48,16 @@
|
|||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'passbook_admin:factors' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
|
||||
<a href="{% url 'passbook_admin:stages' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card__head">
|
||||
<div class="pf-c-card__head-main">
|
||||
<i class="pf-icon pf-icon-plugged"></i> {% trans 'Factors' %}
|
||||
<i class="pf-icon pf-icon-plugged"></i> {% trans 'Stages' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
{% if factor_count < 1 %}
|
||||
<i class="pficon-error-circle-o"></i> {{ factor_count }}
|
||||
<p>{% trans 'No Factors configured. No Users will be able to login.' %}"></p>
|
||||
<p>{% trans 'No Stages configured. No Users will be able to login.' %}"></p>
|
||||
{% else %}
|
||||
<i class="pf-icon pf-icon-ok"></i> {{ factor_count }}
|
||||
{% endif %}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<i class="pf-icon pf-icon-infrastructure"></i>
|
||||
{% trans 'Policies' %}
|
||||
</h1>
|
||||
<p>{% trans "Allow users to use Applications based on properties, enforce Password Criteria and selectively apply Factors." %}</p>
|
||||
<p>{% trans "Allow users to use Applications based on properties, enforce Password Criteria and selectively apply Stages." %}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
|
|
|
@ -9,9 +9,9 @@
|
|||
<div class="pf-c-content">
|
||||
<h1>
|
||||
<i class="pf-icon pf-icon-plugged"></i>
|
||||
{% trans 'Factors' %}
|
||||
{% trans 'Stages' %}
|
||||
</h1>
|
||||
<p>{% trans "Factors required for a user to successfully authenticate." %}
|
||||
<p>{% trans "Stages required for a user to successfully authenticate." %}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -27,7 +27,7 @@
|
|||
<ul class="pf-c-dropdown__menu" hidden>
|
||||
{% for type, name in types.items %}
|
||||
<li>
|
||||
<a class="pf-c-dropdown__menu-item" href="{% url 'passbook_admin:factor-create' %}?type={{ type }}&back={{ request.get_full_path }}">{{ name }}</a>
|
||||
<a class="pf-c-dropdown__menu-item" href="{% url 'passbook_admin:stage-create' %}?type={{ type }}&back={{ request.get_full_path }}">{{ name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -39,34 +39,36 @@
|
|||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Order' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Flows' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Enabled' %}</th>
|
||||
<th role="cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{% for factor in object_list %}
|
||||
{% for stage in object_list %}
|
||||
<tr role="row">
|
||||
<th role="columnheader">
|
||||
<div>
|
||||
<div>{{ factor.name }} ({{ factor.slug }})</div>
|
||||
<small>{{ factor|verbose_name }}</small>
|
||||
<div>{{ stage.name }}</div>
|
||||
<small>{{ stage|verbose_name }}</small>
|
||||
</div>
|
||||
</th>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ factor.order }}
|
||||
</span>
|
||||
<ul>
|
||||
{% for flow in stage.flow_set.all %}
|
||||
<li><a href="{% url 'passbook_admin:flow-update' pk=flow.pk %}">{{ flow.slug }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ factor.enabled }}
|
||||
{{ stage.enabled }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:factor-update' pk=factor.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:factor-delete' pk=factor.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
|
||||
{% get_links factor as links %}
|
||||
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:stage-update' pk=stage.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:stage-delete' pk=stage.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
|
||||
{% get_links stage as links %}
|
||||
{% for name, href in links.items %}
|
||||
<a class="pf-c-button pf-m-tertiary" href="{{ href }}?back={{ request.get_full_path }}">{% trans name %}</a>
|
||||
{% endfor %}
|
|
@ -6,7 +6,7 @@ from passbook.admin.views import (
|
|||
audit,
|
||||
certificate_key_pair,
|
||||
debug,
|
||||
factors,
|
||||
flows,
|
||||
groups,
|
||||
invitations,
|
||||
overview,
|
||||
|
@ -14,6 +14,7 @@ from passbook.admin.views import (
|
|||
property_mapping,
|
||||
providers,
|
||||
sources,
|
||||
stages,
|
||||
users,
|
||||
)
|
||||
|
||||
|
@ -84,20 +85,29 @@ 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",
|
||||
),
|
||||
# Factors
|
||||
# Flows
|
||||
path("flows/", flows.FlowListView.as_view(), name="flows"),
|
||||
path("flows/create/", flows.FlowCreateView.as_view(), name="flow-create",),
|
||||
path(
|
||||
"flows/<uuid:pk>/update/", flows.FlowUpdateView.as_view(), name="flow-update",
|
||||
),
|
||||
path(
|
||||
"flows/<uuid:pk>/delete/", flows.FlowDeleteView.as_view(), name="flow-delete",
|
||||
),
|
||||
# Property Mappings
|
||||
path(
|
||||
"property-mappings/",
|
||||
property_mapping.PropertyMappingListView.as_view(),
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
"""passbook Flow administration"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.mixins import (
|
||||
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.generic import DeleteView, ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.flows.forms import FlowForm
|
||||
from passbook.flows.models import Flow
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
class FlowListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||
"""Show list of all flows"""
|
||||
|
||||
model = Flow
|
||||
permission_required = "passbook_flows.view_flow"
|
||||
ordering = "name"
|
||||
paginate_by = 40
|
||||
template_name = "administration/flow/list.html"
|
||||
|
||||
|
||||
class FlowCreateView(
|
||||
SuccessMessageMixin,
|
||||
LoginRequiredMixin,
|
||||
DjangoPermissionRequiredMixin,
|
||||
CreateAssignPermView,
|
||||
):
|
||||
"""Create new Flow"""
|
||||
|
||||
model = Flow
|
||||
form_class = FlowForm
|
||||
permission_required = "passbook_flows.add_flow"
|
||||
|
||||
template_name = "generic/create.html"
|
||||
success_url = reverse_lazy("passbook_admin:flows")
|
||||
success_message = _("Successfully created Flow")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["type"] = "Flow"
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class FlowUpdateView(
|
||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
||||
):
|
||||
"""Update flow"""
|
||||
|
||||
model = Flow
|
||||
form_class = FlowForm
|
||||
permission_required = "passbook_flows.change_flow"
|
||||
|
||||
template_name = "generic/update.html"
|
||||
success_url = reverse_lazy("passbook_admin:flows")
|
||||
success_message = _("Successfully updated Flow")
|
||||
|
||||
|
||||
class FlowDeleteView(
|
||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, DeleteView
|
||||
):
|
||||
"""Delete flow"""
|
||||
|
||||
model = Flow
|
||||
permission_required = "passbook_flows.delete_flow"
|
||||
|
||||
template_name = "generic/delete.html"
|
||||
success_url = reverse_lazy("passbook_admin:flows")
|
||||
success_message = _("Successfully deleted Flow")
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
messages.success(self.request, self.success_message)
|
||||
return super().delete(request, *args, **kwargs)
|
|
@ -11,17 +11,17 @@ from django.utils.translation import ugettext as _
|
|||
from django.views.generic import DeleteView, ListView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.core.forms.invitations import InvitationForm
|
||||
from passbook.core.models import Invitation
|
||||
from passbook.core.signals import invitation_created
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
from passbook.stages.invitation.forms import InvitationForm
|
||||
from passbook.stages.invitation.models import Invitation
|
||||
|
||||
|
||||
class InvitationListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||
"""Show list of all invitations"""
|
||||
|
||||
model = Invitation
|
||||
permission_required = "passbook_core.view_invitation"
|
||||
permission_required = "passbook_stages_invitation.view_invitation"
|
||||
template_name = "administration/invitation/list.html"
|
||||
paginate_by = 10
|
||||
ordering = "-expires"
|
||||
|
@ -37,7 +37,7 @@ class InvitationCreateView(
|
|||
|
||||
model = Invitation
|
||||
form_class = InvitationForm
|
||||
permission_required = "passbook_core.add_invitation"
|
||||
permission_required = "passbook_stages_invitation.add_invitation"
|
||||
|
||||
template_name = "generic/create.html"
|
||||
success_url = reverse_lazy("passbook_admin:invitations")
|
||||
|
@ -61,7 +61,7 @@ class InvitationDeleteView(
|
|||
"""Delete invitation"""
|
||||
|
||||
model = Invitation
|
||||
permission_required = "passbook_core.delete_invitation"
|
||||
permission_required = "passbook_stages_invitation.delete_invitation"
|
||||
|
||||
template_name = "generic/delete.html"
|
||||
success_url = reverse_lazy("passbook_admin:invitations")
|
||||
|
|
|
@ -5,16 +5,10 @@ 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, Policy, Provider, Source, User
|
||||
from passbook.flows.models import Flow, Stage
|
||||
from passbook.root.celery import CELERY_APP
|
||||
from passbook.stages.invitation.models import Invitation
|
||||
|
||||
|
||||
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
||||
|
@ -26,7 +20,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
|||
"""Handle post (clear cache from modal)"""
|
||||
if "clear" in self.request.POST:
|
||||
cache.clear()
|
||||
return redirect(reverse("passbook_core:auth-login"))
|
||||
return redirect(reverse("passbook_flows:default-authentication"))
|
||||
return self.get(*args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
@ -35,7 +29,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))
|
||||
|
|
|
@ -14,7 +14,7 @@ from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
|||
|
||||
from passbook.admin.forms.policies import PolicyTestForm
|
||||
from passbook.core.models import Policy
|
||||
from passbook.lib.utils.reflection import path_to_class
|
||||
from passbook.lib.utils.reflection import all_subclasses, path_to_class
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
|
||||
|
@ -30,7 +30,7 @@ class PolicyListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
|||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["types"] = {
|
||||
x.__name__: x._meta.verbose_name for x in Policy.__subclasses__()
|
||||
x.__name__: x._meta.verbose_name for x in all_subclasses(Policy)
|
||||
}
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
@ -62,7 +62,7 @@ class PolicyCreateView(
|
|||
|
||||
def get_form_class(self):
|
||||
policy_type = self.request.GET.get("type")
|
||||
model = next(x for x in Policy.__subclasses__() if x.__name__ == policy_type)
|
||||
model = next(x for x in all_subclasses(Policy) if x.__name__ == policy_type)
|
||||
if not model:
|
||||
raise Http404
|
||||
return path_to_class(model.form)
|
||||
|
|
|
@ -12,17 +12,10 @@ from django.views.generic import DeleteView, ListView, UpdateView
|
|||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.core.models import PropertyMapping
|
||||
from passbook.lib.utils.reflection import path_to_class
|
||||
from passbook.lib.utils.reflection import all_subclasses, path_to_class
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
def all_subclasses(cls):
|
||||
"""Recursively return all subclassess of cls"""
|
||||
return set(cls.__subclasses__()).union(
|
||||
[s for c in cls.__subclasses__() for s in all_subclasses(c)]
|
||||
)
|
||||
|
||||
|
||||
class PropertyMappingListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||
"""Show list of all property_mappings"""
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ from django.views.generic import DeleteView, ListView, UpdateView
|
|||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.core.models import Provider
|
||||
from passbook.lib.utils.reflection import path_to_class
|
||||
from passbook.lib.utils.reflection import all_subclasses, path_to_class
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
|
@ -27,7 +27,7 @@ class ProviderListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
|||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["types"] = {
|
||||
x.__name__: x._meta.verbose_name for x in Provider.__subclasses__()
|
||||
x.__name__: x._meta.verbose_name for x in all_subclasses(Provider)
|
||||
}
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
@ -52,9 +52,7 @@ class ProviderCreateView(
|
|||
|
||||
def get_form_class(self):
|
||||
provider_type = self.request.GET.get("type")
|
||||
model = next(
|
||||
x for x in Provider.__subclasses__() if x.__name__ == provider_type
|
||||
)
|
||||
model = next(x for x in all_subclasses(Provider) if x.__name__ == provider_type)
|
||||
if not model:
|
||||
raise Http404
|
||||
return path_to_class(model.form)
|
||||
|
|
|
@ -12,17 +12,10 @@ from django.views.generic import DeleteView, ListView, UpdateView
|
|||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.core.models import Source
|
||||
from passbook.lib.utils.reflection import path_to_class
|
||||
from passbook.lib.utils.reflection import all_subclasses, path_to_class
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
def all_subclasses(cls):
|
||||
"""Recursively return all subclassess of cls"""
|
||||
return set(cls.__subclasses__()).union(
|
||||
[s for c in cls.__subclasses__() for s in all_subclasses(c)]
|
||||
)
|
||||
|
||||
|
||||
class SourceListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||
"""Show list of all sources"""
|
||||
|
||||
|
|
|
@ -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,30 +11,23 @@ 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.lib.utils.reflection import path_to_class
|
||||
from passbook.flows.models import Stage
|
||||
from passbook.lib.utils.reflection import all_subclasses, path_to_class
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
|
||||
|
||||
def all_subclasses(cls):
|
||||
"""Recursively return all subclassess of cls"""
|
||||
return set(cls.__subclasses__()).union(
|
||||
[s for c in cls.__subclasses__() for s in all_subclasses(c)]
|
||||
)
|
||||
class StageListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||
"""Show list of all stages"""
|
||||
|
||||
|
||||
class FactorListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||
"""Show list of all factors"""
|
||||
|
||||
model = Factor
|
||||
template_name = "administration/factor/list.html"
|
||||
permission_required = "passbook_core.view_factor"
|
||||
ordering = "order"
|
||||
model = Stage
|
||||
template_name = "administration/stage/list.html"
|
||||
permission_required = "passbook_flows.view_stage"
|
||||
ordering = "name"
|
||||
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 +35,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_flows.add_stage"
|
||||
|
||||
success_url = reverse_lazy("passbook_admin:factors")
|
||||
success_message = _("Successfully created Factor")
|
||||
success_url = reverse_lazy("passbook_admin:stages")
|
||||
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)
|
||||
stage_type = self.request.GET.get("type")
|
||||
model = next(x for x in all_subclasses(Stage) if x.__name__ == stage_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)
|
||||
stage_type = self.request.GET.get("type")
|
||||
model = next(x for x in all_subclasses(Stage) if x.__name__ == stage_type)
|
||||
if not model:
|
||||
raise Http404
|
||||
return path_to_class(model.form)
|
||||
|
||||
|
||||
class FactorUpdateView(
|
||||
class StageUpdateView(
|
||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
||||
):
|
||||
"""Update factor"""
|
||||
"""Update stage"""
|
||||
|
||||
model = Factor
|
||||
permission_required = "passbook_core.update_application"
|
||||
model = Stage
|
||||
permission_required = "passbook_flows.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:stages")
|
||||
success_message = _("Successfully updated Stage")
|
||||
|
||||
def get_form_class(self):
|
||||
form_class_path = self.get_object().form
|
||||
|
@ -90,24 +83,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 stage"""
|
||||
|
||||
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_flows.delete_stage"
|
||||
success_url = reverse_lazy("passbook_admin:stages")
|
||||
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):
|
|
@ -94,9 +94,10 @@ class UserPasswordResetView(LoginRequiredMixin, PermissionRequiredMixin, DetailV
|
|||
def get(self, request, *args, **kwargs):
|
||||
"""Create nonce for user and return link"""
|
||||
super().get(request, *args, **kwargs)
|
||||
# TODO: create plan for user, get token
|
||||
nonce = Nonce.objects.create(user=self.object)
|
||||
link = request.build_absolute_uri(
|
||||
reverse("passbook_core:auth-password-reset", kwargs={"nonce": nonce.uuid})
|
||||
reverse("passbook_flows:default-recovery", kwargs={"nonce": nonce.uuid})
|
||||
)
|
||||
messages.success(
|
||||
request, _("Password reset link: <pre>%(link)s</pre>" % {"link": link})
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
"""api v2 urls"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
from django.urls import path
|
||||
from drf_yasg import openapi
|
||||
|
@ -9,20 +10,15 @@ 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
|
||||
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 FlowStageBindingViewSet, FlowViewSet, StageViewSet
|
||||
from passbook.lib.utils.reflection import get_apps
|
||||
from passbook.policies.api import PolicyBindingViewSet
|
||||
from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet
|
||||
from passbook.policies.expression.api import ExpressionPolicyViewSet
|
||||
from passbook.policies.hibp.api import HaveIBeenPwendPolicyViewSet
|
||||
|
@ -35,6 +31,17 @@ 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.email.api import EmailStageViewSet
|
||||
from passbook.stages.identification.api import IdentificationStageViewSet
|
||||
from passbook.stages.invitation.api import InvitationStageViewSet, InvitationViewSet
|
||||
from passbook.stages.otp.api import OTPStageViewSet
|
||||
from passbook.stages.password.api import PasswordStageViewSet
|
||||
from passbook.stages.prompt.api import PromptStageViewSet, PromptViewSet
|
||||
from passbook.stages.user_delete.api import UserDeleteStageViewSet
|
||||
from passbook.stages.user_login.api import UserLoginStageViewSet
|
||||
from passbook.stages.user_logout.api import UserLogoutStageViewSet
|
||||
from passbook.stages.user_write.api import UserWriteStageViewSet
|
||||
|
||||
LOGGER = get_logger()
|
||||
router = routers.DefaultRouter()
|
||||
|
@ -46,40 +53,62 @@ for _passbook_app in get_apps():
|
|||
LOGGER.debug("Mounted API URLs", app_name=_passbook_app.name)
|
||||
|
||||
router.register("core/applications", ApplicationViewSet)
|
||||
router.register("core/invitations", InvitationViewSet)
|
||||
router.register("core/groups", GroupViewSet)
|
||||
router.register("core/users", UserViewSet)
|
||||
|
||||
router.register("audit/events", EventViewSet)
|
||||
|
||||
router.register("sources/all", SourceViewSet)
|
||||
router.register("sources/ldap", LDAPSourceViewSet)
|
||||
router.register("sources/oauth", OAuthSourceViewSet)
|
||||
|
||||
router.register("policies/all", PolicyViewSet)
|
||||
router.register("policies/passwordexpiry", PasswordExpiryPolicyViewSet)
|
||||
router.register("policies/bindings", PolicyBindingViewSet)
|
||||
router.register("policies/expression", ExpressionPolicyViewSet)
|
||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
||||
router.register("policies/password", PasswordPolicyViewSet)
|
||||
router.register("policies/passwordexpiry", PasswordExpiryPolicyViewSet)
|
||||
router.register("policies/reputation", ReputationPolicyViewSet)
|
||||
router.register("policies/webhook", WebhookPolicyViewSet)
|
||||
router.register("policies/expression", ExpressionPolicyViewSet)
|
||||
|
||||
router.register("providers/all", ProviderViewSet)
|
||||
router.register("providers/applicationgateway", ApplicationGatewayProviderViewSet)
|
||||
router.register("providers/oauth", OAuth2ProviderViewSet)
|
||||
router.register("providers/openid", OpenIDProviderViewSet)
|
||||
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/email", EmailStageViewSet)
|
||||
router.register("stages/identification", IdentificationStageViewSet)
|
||||
router.register("stages/invitation", InvitationStageViewSet)
|
||||
router.register("stages/invitation/invitations", InvitationViewSet)
|
||||
router.register("stages/otp", OTPStageViewSet)
|
||||
router.register("stages/password", PasswordStageViewSet)
|
||||
router.register("stages/prompt/stages", PromptStageViewSet)
|
||||
router.register("stages/prompt/prompts", PromptViewSet)
|
||||
router.register("stages/user_delete", UserDeleteStageViewSet)
|
||||
router.register("stages/user_login", UserLoginStageViewSet)
|
||||
router.register("stages/user_logout", UserLogoutStageViewSet)
|
||||
router.register("stages/user_write", UserWriteStageViewSet)
|
||||
|
||||
router.register("flows/instances", FlowViewSet)
|
||||
router.register("flows/bindings", FlowStageBindingViewSet)
|
||||
|
||||
if settings.DEBUG:
|
||||
from passbook.stages.dummy.api import DummyStageViewSet
|
||||
from passbook.policies.dummy.api import DummyPolicyViewSet
|
||||
|
||||
router.register("stages/dummy", DummyStageViewSet)
|
||||
router.register("policies/dummy", DummyPolicyViewSet)
|
||||
|
||||
info = openapi.Info(
|
||||
title="passbook API",
|
||||
default_version="v2",
|
||||
# description="Test description",
|
||||
# terms_of_service="https://www.google.com/policies/terms/",
|
||||
contact=openapi.Contact(email="hello@beryju.org"),
|
||||
license=openapi.License(name="MIT License"),
|
||||
)
|
||||
|
|
|
@ -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()
|
|
@ -1,27 +0,0 @@
|
|||
"""Invitation API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.core.models import Invitation
|
||||
|
||||
|
||||
class InvitationSerializer(ModelSerializer):
|
||||
"""Invitation Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Invitation
|
||||
fields = [
|
||||
"pk",
|
||||
"expires",
|
||||
"fixed_username",
|
||||
"fixed_email",
|
||||
"needs_confirmation",
|
||||
]
|
||||
|
||||
|
||||
class InvitationViewSet(ModelViewSet):
|
||||
"""Invitation Viewset"""
|
||||
|
||||
queryset = Invitation.objects.all()
|
||||
serializer_class = InvitationSerializer
|
|
@ -1,89 +0,0 @@
|
|||
"""passbook core authentication forms"""
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook.lib.utils.ui import human_list
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
"""Allow users to login"""
|
||||
|
||||
title = _("Log in to your account")
|
||||
uid_field = forms.CharField(label=_(""))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if CONFIG.y("passbook.uid_fields") == ["e-mail"]:
|
||||
self.fields["uid_field"] = forms.EmailField()
|
||||
self.fields["uid_field"].label = human_list(
|
||||
[x.title() for x in CONFIG.y("passbook.uid_fields")]
|
||||
)
|
||||
|
||||
def clean_uid_field(self):
|
||||
"""Validate uid_field after EmailValidator if 'email' is the only selected uid_fields"""
|
||||
if CONFIG.y("passbook.uid_fields") == ["email"]:
|
||||
validate_email(self.cleaned_data.get("uid_field"))
|
||||
return self.cleaned_data.get("uid_field")
|
||||
|
||||
|
||||
class SignUpForm(forms.Form):
|
||||
"""SignUp Form"""
|
||||
|
||||
title = _("Sign Up")
|
||||
name = forms.CharField(
|
||||
label=_("Name"), widget=forms.TextInput(attrs={"placeholder": _("Name")})
|
||||
)
|
||||
username = forms.CharField(
|
||||
label=_("Username"),
|
||||
widget=forms.TextInput(attrs={"placeholder": _("Username")}),
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label=_("E-Mail"), widget=forms.TextInput(attrs={"placeholder": _("E-Mail")})
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_("Password"),
|
||||
widget=forms.PasswordInput(attrs={"placeholder": _("Password")}),
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
label=_("Repeat Password"),
|
||||
widget=forms.PasswordInput(attrs={"placeholder": _("Repeat Password")}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# All fields which have initial data supplied are set to read only
|
||||
if "initial" in kwargs:
|
||||
for field in kwargs.get("initial").keys():
|
||||
self.fields[field].widget.attrs["readonly"] = "readonly"
|
||||
|
||||
def clean_username(self):
|
||||
"""Check if username is used already"""
|
||||
username = self.cleaned_data.get("username")
|
||||
if User.objects.filter(username=username).exists():
|
||||
LOGGER.warning("username already exists", username=username)
|
||||
raise ValidationError(_("Username already exists"))
|
||||
return username
|
||||
|
||||
def clean_email(self):
|
||||
"""Check if email is already used in django or other auth sources"""
|
||||
email = self.cleaned_data.get("email")
|
||||
# Check if user exists already, error early
|
||||
if User.objects.filter(email=email).exists():
|
||||
LOGGER.debug("email already exists", email=email)
|
||||
raise ValidationError(_("Email already exists"))
|
||||
return email
|
||||
|
||||
def clean_password_repeat(self):
|
||||
"""Check if Password adheres to filter and if passwords matche"""
|
||||
password = self.cleaned_data.get("password")
|
||||
password_repeat = self.cleaned_data.get("password_repeat")
|
||||
if password != password_repeat:
|
||||
raise ValidationError(_("Passwords don't match"))
|
||||
return self.cleaned_data.get("password_repeat")
|
|
@ -1,38 +0,0 @@
|
|||
"""passbook core invitation form"""
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.models import Invitation, User
|
||||
|
||||
|
||||
class InvitationForm(forms.ModelForm):
|
||||
"""InvitationForm"""
|
||||
|
||||
def clean_fixed_username(self):
|
||||
"""Check if username is already used"""
|
||||
username = self.cleaned_data.get("fixed_username")
|
||||
if User.objects.filter(username=username).exists():
|
||||
raise ValidationError(_("Username is already in use."))
|
||||
return username
|
||||
|
||||
def clean_fixed_email(self):
|
||||
"""Check if email is already used"""
|
||||
email = self.cleaned_data.get("fixed_email")
|
||||
if User.objects.filter(email=email).exists():
|
||||
raise ValidationError(_("E-Mail is already in use."))
|
||||
return email
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Invitation
|
||||
fields = ["expires", "fixed_username", "fixed_email", "needs_confirmation"]
|
||||
labels = {
|
||||
"fixed_username": "Force user's username (optional)",
|
||||
"fixed_email": "Force user's email (optional)",
|
||||
}
|
||||
widgets = {
|
||||
"fixed_username": forms.TextInput(),
|
||||
"fixed_email": forms.TextInput(),
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
"""passbook core user forms"""
|
||||
|
||||
from django import forms
|
||||
from django.forms import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.core.models import User
|
||||
|
||||
|
@ -15,28 +13,3 @@ class UserDetailForm(forms.ModelForm):
|
|||
model = User
|
||||
fields = ["username", "name", "email"]
|
||||
widgets = {"name": forms.TextInput}
|
||||
|
||||
|
||||
class PasswordChangeForm(forms.Form):
|
||||
"""Form to update password"""
|
||||
|
||||
password = forms.CharField(
|
||||
label=_("Password"),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={"placeholder": _("New Password"), "autocomplete": "new-password"}
|
||||
),
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
label=_("Repeat Password"),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={"placeholder": _("Repeat Password"), "autocomplete": "new-password"}
|
||||
),
|
||||
)
|
||||
|
||||
def clean_password_repeat(self):
|
||||
"""Check if Password adheres to filter and if passwords matche"""
|
||||
password = self.cleaned_data.get("password")
|
||||
password_repeat = self.cleaned_data.get("password_repeat")
|
||||
if password != password_repeat:
|
||||
raise ValidationError(_("Passwords don't match"))
|
||||
return self.cleaned_data.get("password_repeat")
|
||||
|
|
|
@ -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",),
|
||||
]
|
|
@ -0,0 +1,16 @@
|
|||
# Generated by Django 3.0.5 on 2020-05-10 10:01
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_policies", "0003_auto_20200508_1642"),
|
||||
("passbook_stages_password", "0001_initial"),
|
||||
("passbook_core", "0012_delete_factor"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(name="DebugPolicy",),
|
||||
]
|
|
@ -0,0 +1,14 @@
|
|||
# Generated by Django 3.0.5 on 2020-05-11 19:57
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0013_delete_debugpolicy"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(name="Invitation",),
|
||||
]
|
|
@ -1,7 +1,5 @@
|
|||
"""passbook core models"""
|
||||
from datetime import timedelta
|
||||
from random import SystemRandom
|
||||
from time import sleep
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
|
@ -10,7 +8,6 @@ from django.contrib.postgres.fields import JSONField
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_prometheus.models import ExportModelOperationsMixin
|
||||
|
@ -34,7 +31,7 @@ NATIVE_ENVIRONMENT = NativeEnvironment()
|
|||
|
||||
def default_nonce_duration():
|
||||
"""Default duration a Nonce is valid"""
|
||||
return now() + timedelta(hours=4)
|
||||
return now() + timedelta(minutes=30)
|
||||
|
||||
|
||||
class Group(ExportModelOperationsMixin("group"), UUIDModel):
|
||||
|
@ -103,30 +100,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
|
||||
|
@ -222,54 +195,6 @@ class Policy(ExportModelOperationsMixin("policy"), UUIDModel, CreatedUpdatedMode
|
|||
raise PolicyException()
|
||||
|
||||
|
||||
class DebugPolicy(Policy):
|
||||
"""Policy used for debugging the PolicyEngine. Returns a fixed result,
|
||||
but takes a random time to process."""
|
||||
|
||||
result = models.BooleanField(default=False)
|
||||
wait_min = models.IntegerField(default=5)
|
||||
wait_max = models.IntegerField(default=30)
|
||||
|
||||
form = "passbook.core.forms.policies.DebugPolicyForm"
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""Wait random time then return result"""
|
||||
wait = SystemRandom().randrange(self.wait_min, self.wait_max)
|
||||
LOGGER.debug("Policy waiting", policy=self, delay=wait)
|
||||
sleep(wait)
|
||||
return PolicyResult(self.result, "Debugging")
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Debug Policy")
|
||||
verbose_name_plural = _("Debug Policies")
|
||||
|
||||
|
||||
class Invitation(ExportModelOperationsMixin("invitation"), UUIDModel):
|
||||
"""Single-use invitation link"""
|
||||
|
||||
created_by = models.ForeignKey("User", on_delete=models.CASCADE)
|
||||
expires = models.DateTimeField(default=None, blank=True, null=True)
|
||||
fixed_username = models.TextField(blank=True, default=None)
|
||||
fixed_email = models.TextField(blank=True, default=None)
|
||||
needs_confirmation = models.BooleanField(default=True)
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
"""Get link to use invitation"""
|
||||
return (
|
||||
reverse_lazy("passbook_core:auth-sign-up") + f"?invitation={self.uuid.hex}"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"Invitation {self.uuid.hex} created by {self.created_by}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Invitation")
|
||||
verbose_name_plural = _("Invitations")
|
||||
|
||||
|
||||
class Nonce(ExportModelOperationsMixin("nonce"), UUIDModel):
|
||||
"""One-time link for password resets/sign-up-confirmations"""
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
</div>
|
||||
<div class="pf-c-page__header-tools">
|
||||
<div class="pf-c-page__header-tools-group pf-m-icons">
|
||||
<a href="{% url 'passbook_core:auth-logout' %}" class="pf-c-button pf-m-plain" type="button">
|
||||
<a href="{% url 'passbook_flows:default-invalidation' %}" class="pf-c-button pf-m-plain" type="button">
|
||||
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -1,77 +0,0 @@
|
|||
{% extends "email/base.html" %}
|
||||
|
||||
{% load utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_header %}
|
||||
{% trans "Looks like you tried signing in a few too many times. Let's see if we can get you back into your account." %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- HERO -->
|
||||
<tr>
|
||||
<td bgcolor="#7c72dc" align="center" style="padding: 0px 10px 0px 10px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="600" class="wrapper">
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
|
||||
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">{% trans 'Trouble signing in?' %}</h1>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- COPY BLOCK -->
|
||||
<tr>
|
||||
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="600" class="wrapper">
|
||||
<!-- COPY -->
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
|
||||
<p style="margin: 0;">{% trans "Resetting your password is easy. Just press the button below and follow the instructions. We'll have you up and running in no time." %}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- BULLETPROOF BUTTON -->
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="left">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 3px;" bgcolor="#7c72dc"><a href="{{ url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #7c72dc; display: inline-block;">{% trans 'Reset Password' %}</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- COPY CALLOUT -->
|
||||
<tr>
|
||||
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="600" class="wrapper">
|
||||
<!-- HEADLINE -->
|
||||
<tr>
|
||||
<td bgcolor="#111111" align="left" style="padding: 40px 30px 20px 30px; color: #ffffff; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
|
||||
<h2 style="font-size: 24px; font-weight: 400; margin: 0;">{% trans 'Want a more secure account?' %}</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- COPY -->
|
||||
<tr>
|
||||
<td bgcolor="#111111" align="left" style="padding: 0px 30px 20px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
|
||||
<p style="margin: 0;">{% trans 'We support two-factor authentication to help keep your information private.' %}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- COPY -->
|
||||
<tr>
|
||||
<td bgcolor="#111111" align="left" style="padding: 0px 30px 40px 30px; border-radius: 0px 0px 4px 4px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
|
||||
<p style="margin: 0;"><a href="http://litmus.com" target="_blank" style="color: #7c72dc;">{% trans 'See how easy it is to get started' %}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{% endblock %}
|
|
@ -1,129 +0,0 @@
|
|||
{% load inline %}
|
||||
{% load utils %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>passbook</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<style type="text/css">
|
||||
/* CLIENT-SPECIFIC STYLES */
|
||||
body, table, td, a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* RESET STYLES */
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse !important;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* iOS BLUE LINKS */
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
font-size: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
}
|
||||
|
||||
/* ANDROID CENTER FIX */
|
||||
div[style*="margin: 16px 0;"] {
|
||||
margin: 0 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="background-color: #1b2a32; margin: 0 !important; padding: 0 !important;">
|
||||
|
||||
<!-- HIDDEN PREHEADER TEXT -->
|
||||
<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; font-family: 'Metropolis', Helvetica, Arial, sans-serif; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;">
|
||||
{% block pre_header %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<!-- LOGO -->
|
||||
<tr>
|
||||
<td bgcolor="#3625b7" align="center">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="480">
|
||||
<tr>
|
||||
<td align="center" valign="top" style="padding: 40px 10px 40px 10px;">
|
||||
<a href="" target="_blank">
|
||||
<img alt="Logo" src="{% inline_static 'assets/dark.svg' %}" width="64" height="64"
|
||||
style="display: block; width: 64px; max-width: 64px; min-width: 64px; font-family: 'Metropolis', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;"
|
||||
border="0">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
<!-- SUPPORT CALLOUT -->
|
||||
<!-- <tr>
|
||||
<td bgcolor="#1b2a32" align="center" style="padding: 30px 10px 0px 10px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="480">
|
||||
HEADLINE
|
||||
<tr>
|
||||
<td bgcolor="#566572" align="center" style="padding: 30px 30px 30px 30px; border-radius: 4px 4px 4px 4px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
|
||||
<h2 style="font-size: 20px; font-weight: 400; color: ##E9ECEF; margin: 0;">Need more help?</h2>
|
||||
<p style="margin: 0;"><a href="http://litmus.com" target="_blank" style="color: #3625b7;">We’re
|
||||
here, ready to talk</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr> -->
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td bgcolor="#1b2a32" align="center" style="padding: 0px 10px 0px 10px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="480">
|
||||
<!-- NAVIGATION -->
|
||||
<tr>
|
||||
<td bgcolor="#1b2a32" align="left" style="padding: 30px 30px 30px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;">
|
||||
<p style="margin: 0;">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- ADDRESS -->
|
||||
<tr>
|
||||
<td bgcolor="#1b2a32" align="left" style="padding: 0px 30px 30px 30px; color: #E9ECEF; font-family: 'Metropolis', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;">
|
||||
<p style="margin: 0;"><a href="passbook">passbook</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
|
@ -63,12 +63,21 @@
|
|||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if show_sign_up_notice %}
|
||||
{% if enroll_url or recovery_url %}
|
||||
<div class="pf-c-login__main-footer-band">
|
||||
{% if enroll_url %}
|
||||
<p class="pf-c-login__main-footer-band-item">
|
||||
{% trans 'Need an account?' %}
|
||||
<a href="{% url 'passbook_core:auth-sign-up' %}">{% trans 'Sign up.' %}</a>
|
||||
<a href="{{ enroll_url }}">{% trans 'Sign up.' %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if recovery_url %}
|
||||
<p class="pf-c-login__main-footer-band-item">
|
||||
<a href="{{ recovery_url }}">
|
||||
{% trans 'Forgot username or password?' %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</footer>
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
{{ user.username }}
|
||||
</div>
|
||||
<div class="right">
|
||||
<a href="{% url 'passbook_core:auth-login' %}">{% trans 'Not you?' %}</a>
|
||||
<a href="{% url 'passbook_flows:default-authentication' %}">{% trans 'Not you?' %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -25,6 +25,11 @@
|
|||
<div class="select col-sm-10">
|
||||
{{ field }}
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
<span>
|
||||
{{ field.help_text }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
|
||||
<label class="checkbox-label">
|
||||
{{ field }} {{ field.label }}
|
||||
|
|
|
@ -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>
|
||||
<h2 class="pf-c-nav__section-title">{% trans 'Stages' %}</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 %}
|
||||
|
@ -57,13 +57,9 @@
|
|||
<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
|
||||
<section class="pf-c-page__main-section">
|
||||
<div class="pf-l-split pf-m-gutter">
|
||||
<div class="pf-l-split__item">
|
||||
<div class="pf-c-card">
|
||||
{% block page %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block page %}
|
||||
<div class="pf-l-split__item">
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__header pf-c-title pf-m-md">
|
||||
<h1>{% trans 'Update details' %}</h1>
|
||||
</div>
|
||||
|
@ -15,10 +17,41 @@
|
|||
<div class="pf-c-form__horizontal-group">
|
||||
<div class="pf-c-form__actions">
|
||||
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_core:user-delete' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-l-split__item">
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__header pf-c-title pf-m-md">
|
||||
<h1>{% trans 'Sessions' %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<table class="pf-c-table pf-m-grid-md" role="grid" aria-label="This is a simple table example" id="table-basic">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">Repositories</th>
|
||||
<th role="columnheader" scope="col">Branches</th>
|
||||
<th role="columnheader" scope="col">Pull requests</th>
|
||||
<th role="columnheader" scope="col">Workspaces</th>
|
||||
<th role="columnheader" scope="col">Last commit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
<tr role="row">
|
||||
<td role="cell" data-label="Repository name">Repository 1</td>
|
||||
<td role="cell" data-label="Branches">10</td>
|
||||
<td role="cell" data-label="Pull requests">25</td>
|
||||
<td role="cell" data-label="Workspaces">5</td>
|
||||
<td role="cell" data-label="Last commit">2 days ago</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -4,32 +4,26 @@ 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.flows.models import Stage
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
|
||||
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
|
||||
# pylint: disable=unused-argument
|
||||
def user_stages(context: RequestContext) -> List[UIUserSettings]:
|
||||
"""Return list of all stages which apply to 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(
|
||||
factor.policies.all(), user, context.get("request")
|
||||
)
|
||||
policy_engine.build()
|
||||
if policy_engine.passing:
|
||||
matching_factors.append(user_settings)
|
||||
return matching_factors
|
||||
matching_stages.append(user_settings)
|
||||
return matching_stages
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
|
@ -40,12 +34,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:
|
||||
|
|
|
@ -1,158 +0,0 @@
|
|||
"""passbook Core Account Test"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from passbook.core.forms.authentication import LoginForm, SignUpForm
|
||||
from passbook.core.models import User
|
||||
|
||||
|
||||
class TestAuthenticationViews(TestCase):
|
||||
"""passbook Core Account Test"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.sign_up_data = {
|
||||
"name": "Test",
|
||||
"username": "beryjuorg",
|
||||
"email": "unittest@passbook.beryju.org",
|
||||
"password": "B3ryju0rg!",
|
||||
"password_repeat": "B3ryju0rg!",
|
||||
}
|
||||
self.login_data = {
|
||||
"uid_field": "unittest@example.com",
|
||||
}
|
||||
self.user = User.objects.create_superuser(
|
||||
username="unittest user",
|
||||
email="unittest@example.com",
|
||||
password="".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits)
|
||||
for _ in range(8)
|
||||
),
|
||||
)
|
||||
|
||||
def test_sign_up_view(self):
|
||||
"""Test account.sign_up view (Anonymous)"""
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("passbook_core:auth-sign-up"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_login_view(self):
|
||||
"""Test account.login view (Anonymous)"""
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("passbook_core:auth-login"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# test login with post
|
||||
form = LoginForm(self.login_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
response = self.client.post(
|
||||
reverse("passbook_core:auth-login"), data=form.cleaned_data
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_logout_view(self):
|
||||
"""Test account.logout view"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("passbook_core:auth-logout"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_sign_up_view_auth(self):
|
||||
"""Test account.sign_up view (Authenticated)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("passbook_core:auth-logout"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_login_view_auth(self):
|
||||
"""Test account.login view (Authenticated)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("passbook_core:auth-login"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_login_view_post(self):
|
||||
"""Test account.login view POST (Anonymous)"""
|
||||
login_response = self.client.post(
|
||||
reverse("passbook_core:auth-login"), data=self.login_data
|
||||
)
|
||||
self.assertEqual(login_response.status_code, 302)
|
||||
self.assertEqual(login_response.url, reverse("passbook_core:auth-process"))
|
||||
|
||||
def test_sign_up_view_post(self):
|
||||
"""Test account.sign_up view POST (Anonymous)"""
|
||||
form = SignUpForm(self.sign_up_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
response = self.client.post(
|
||||
reverse("passbook_core:auth-sign-up"), data=form.cleaned_data
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
# def test_reset_password_init_view(self):
|
||||
# """Test account.reset_password_init view POST (Anonymous)"""
|
||||
# form = SignUpForm(self.sign_up_data)
|
||||
# self.assertTrue(form.is_valid())
|
||||
|
||||
# res = test_request(accounts.SignUpView.as_view(),
|
||||
# method='POST',
|
||||
# req_kwargs=form.cleaned_data)
|
||||
# self.assertEqual(res.status_code, 302)
|
||||
|
||||
# res = test_request(accounts.PasswordResetInitView.as_view())
|
||||
# self.assertEqual(res.status_code, 200)
|
||||
|
||||
# def test_resend_confirmation(self):
|
||||
# """Test AccountController.resend_confirmation"""
|
||||
# form = SignUpForm(self.sign_up_data)
|
||||
# self.assertTrue(form.is_valid())
|
||||
|
||||
# res = test_request(accounts.SignUpView.as_view(),
|
||||
# method='POST',
|
||||
# req_kwargs=form.cleaned_data)
|
||||
# self.assertEqual(res.status_code, 302)
|
||||
# user = User.objects.get(email=self.sign_up_data['email'])
|
||||
# # Invalidate all other links for this user
|
||||
# old_acs = AccountConfirmation.objects.filter(
|
||||
# user=user)
|
||||
# for old_ac in old_acs:
|
||||
# old_ac.confirmed = True
|
||||
# old_ac.save()
|
||||
# # Create Account Confirmation UUID
|
||||
# new_ac = AccountConfirmation.objects.create(user=user)
|
||||
# self.assertFalse(new_ac.is_expired)
|
||||
# on_user_confirm_resend.send(
|
||||
# sender=None,
|
||||
# user=user,
|
||||
# request=None)
|
||||
|
||||
# def test_reset_passowrd(self):
|
||||
# """Test reset password POST"""
|
||||
# # Signup user first
|
||||
# sign_up_form = SignUpForm(self.sign_up_data)
|
||||
# self.assertTrue(sign_up_form.is_valid())
|
||||
|
||||
# sign_up_res = test_request(accounts.SignUpView.as_view(),
|
||||
# method='POST',
|
||||
# req_kwargs=sign_up_form.cleaned_data)
|
||||
# self.assertEqual(sign_up_res.status_code, 302)
|
||||
|
||||
# user = User.objects.get(email=self.sign_up_data['email'])
|
||||
# # Invalidate all other links for this user
|
||||
# old_acs = AccountConfirmation.objects.filter(
|
||||
# user=user)
|
||||
# for old_ac in old_acs:
|
||||
# old_ac.confirmed = True
|
||||
# old_ac.save()
|
||||
# # Create Account Confirmation UUID
|
||||
# new_ac = AccountConfirmation.objects.create(user=user)
|
||||
# self.assertFalse(new_ac.is_expired)
|
||||
# uuid = AccountConfirmation.objects.filter(user=user).first().pk
|
||||
# reset_res = test_request(accounts.PasswordResetFinishView.as_view(),
|
||||
# method='POST',
|
||||
# user=user,
|
||||
# url_kwargs={'uuid': uuid},
|
||||
# req_kwargs=self.change_data)
|
||||
|
||||
# self.assertEqual(reset_res.status_code, 302)
|
||||
# self.assertEqual(reset_res.url, reverse('common-index'))
|
|
@ -5,7 +5,6 @@ from random import SystemRandom
|
|||
from django.shortcuts import reverse
|
||||
from django.test import TestCase
|
||||
|
||||
from passbook.core.forms.users import PasswordChangeForm
|
||||
from passbook.core.models import User
|
||||
|
||||
|
||||
|
@ -29,29 +28,3 @@ class TestUserViews(TestCase):
|
|||
self.assertEqual(
|
||||
self.client.get(reverse("passbook_core:user-settings")).status_code, 200
|
||||
)
|
||||
|
||||
def test_user_delete(self):
|
||||
"""Test UserDeleteView"""
|
||||
self.assertEqual(
|
||||
self.client.post(reverse("passbook_core:user-delete")).status_code, 302
|
||||
)
|
||||
self.assertEqual(User.objects.filter(username="unittest user").exists(), False)
|
||||
self.setUp()
|
||||
|
||||
def test_user_change_password(self):
|
||||
"""Test UserChangePasswordView"""
|
||||
form_data = {"password": "test2", "password_repeat": "test2"}
|
||||
form = PasswordChangeForm(data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(
|
||||
self.client.get(reverse("passbook_core:user-change-password")).status_code,
|
||||
200,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.post(
|
||||
reverse("passbook_core:user-change-password"), data=form_data
|
||||
).status_code,
|
||||
302,
|
||||
)
|
||||
self.user.refresh_from_db()
|
||||
self.assertTrue(self.user.check_password("test2"))
|
||||
|
|
|
@ -27,7 +27,7 @@ class TestUtilViews(TestCase):
|
|||
request = self.factory.get("something")
|
||||
response = LoadingView.as_view(target_url="somestring")(request)
|
||||
response.render()
|
||||
self.assertIn("somestring", response.content.decode("utf-8"))
|
||||
self.assertIn("somestring", response.rendered_content)
|
||||
|
||||
def test_permission_denied_view(self):
|
||||
"""Test PermissionDeniedView"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,46 +1,11 @@
|
|||
"""passbook URL Configuration"""
|
||||
from django.urls import path
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.views import authentication, overview, user
|
||||
from passbook.factors import view
|
||||
|
||||
LOGGER = get_logger()
|
||||
from passbook.core.views import overview, user
|
||||
|
||||
urlpatterns = [
|
||||
# Authentication views
|
||||
path("auth/login/", authentication.LoginView.as_view(), name="auth-login"),
|
||||
path("auth/logout/", authentication.LogoutView.as_view(), name="auth-logout"),
|
||||
path("auth/sign_up/", authentication.SignUpView.as_view(), name="auth-sign-up"),
|
||||
path(
|
||||
"auth/sign_up/<uuid:nonce>/confirm/",
|
||||
authentication.SignUpConfirmView.as_view(),
|
||||
name="auth-sign-up-confirm",
|
||||
),
|
||||
path(
|
||||
"auth/process/denied/",
|
||||
view.FactorPermissionDeniedView.as_view(),
|
||||
name="auth-denied",
|
||||
),
|
||||
path(
|
||||
"auth/password/reset/<uuid:nonce>/",
|
||||
authentication.PasswordResetView.as_view(),
|
||||
name="auth-password-reset",
|
||||
),
|
||||
path("auth/process/", view.AuthenticationView.as_view(), name="auth-process"),
|
||||
path(
|
||||
"auth/process/<slug:factor>/",
|
||||
view.AuthenticationView.as_view(),
|
||||
name="auth-process",
|
||||
),
|
||||
# User views
|
||||
path("_/user/", user.UserSettingsView.as_view(), name="user-settings"),
|
||||
path("_/user/delete/", user.UserDeleteView.as_view(), name="user-delete"),
|
||||
path(
|
||||
"_/user/change_password/",
|
||||
user.UserChangePasswordView.as_view(),
|
||||
name="user-change-password",
|
||||
),
|
||||
path("-/user/", user.UserSettingsView.as_view(), name="user-settings"),
|
||||
# Overview
|
||||
path("", overview.OverviewView.as_view(), name="overview"),
|
||||
]
|
||||
|
|
|
@ -1,236 +0,0 @@
|
|||
"""passbook core authentication views"""
|
||||
from typing import Dict, Optional
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login, logout
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.forms.utils import ErrorList
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views import View
|
||||
from django.views.generic import FormView
|
||||
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.factors.view import AuthenticationView, _redirect_with_qs
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class LoginView(UserPassesTestMixin, FormView):
|
||||
"""Allow users to sign in"""
|
||||
|
||||
template_name = "login/form.html"
|
||||
form_class = LoginForm
|
||||
success_url = "."
|
||||
|
||||
# Allow only not authenticated users to login
|
||||
def test_func(self):
|
||||
return self.request.user.is_authenticated is False
|
||||
|
||||
def handle_no_permission(self):
|
||||
if "next" in self.request.GET:
|
||||
return redirect(self.request.GET.get("next"))
|
||||
return redirect(reverse("passbook_core:overview"))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["config"] = CONFIG.y("passbook")
|
||||
kwargs["title"] = _("Log in to your account")
|
||||
kwargs["primary_action"] = _("Log in")
|
||||
kwargs["show_sign_up_notice"] = CONFIG.y("passbook.sign_up.enabled")
|
||||
kwargs["sources"] = []
|
||||
sources = (
|
||||
Source.objects.filter(enabled=True).order_by("name").select_subclasses()
|
||||
)
|
||||
for source in sources:
|
||||
ui_login_button = source.ui_login_button
|
||||
if ui_login_button:
|
||||
kwargs["sources"].append(ui_login_button)
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def get_user(self, uid_value) -> Optional[User]:
|
||||
"""Find user instance. Returns None if no user was found."""
|
||||
for search_field in CONFIG.y("passbook.uid_fields"):
|
||||
# Workaround for E-Mail -> email
|
||||
if search_field == "e-mail":
|
||||
search_field = "email"
|
||||
users = User.objects.filter(**{search_field: uid_value})
|
||||
if users.exists():
|
||||
LOGGER.debug("Found user", user=users.first(), uid_field=search_field)
|
||||
return users.first()
|
||||
return None
|
||||
|
||||
def form_valid(self, form: LoginForm) -> HttpResponse:
|
||||
"""Form data is valid"""
|
||||
pre_user = self.get_user(form.cleaned_data.get("uid_field"))
|
||||
if not pre_user:
|
||||
# No user found
|
||||
return self.invalid_login(self.request)
|
||||
self.request.session[AuthenticationView.SESSION_PENDING_USER] = pre_user.pk
|
||||
return _redirect_with_qs("passbook_core:auth-process", self.request.GET)
|
||||
|
||||
def invalid_login(
|
||||
self, request: HttpRequest, disabled_user: User = None
|
||||
) -> HttpResponse:
|
||||
"""Handle login for disabled users/invalid login attempts"""
|
||||
LOGGER.debug("invalid_login", user=disabled_user)
|
||||
messages.error(request, _("Failed to authenticate."))
|
||||
return self.render_to_response(self.get_context_data())
|
||||
|
||||
|
||||
class LogoutView(LoginRequiredMixin, View):
|
||||
"""Log current user out"""
|
||||
|
||||
def dispatch(self, request):
|
||||
"""Log current user out"""
|
||||
logout(request)
|
||||
messages.success(request, _("You've successfully been logged out."))
|
||||
return redirect(reverse("passbook_core:auth-login"))
|
||||
|
||||
|
||||
class SignUpView(UserPassesTestMixin, FormView):
|
||||
"""Sign up new user, optionally consume one-use invitation link."""
|
||||
|
||||
template_name = "login/form.html"
|
||||
form_class = SignUpForm
|
||||
success_url = "."
|
||||
# Invitation instance, if invitation link was used
|
||||
_invitation = None
|
||||
# Instance of newly created user
|
||||
_user = None
|
||||
|
||||
# Allow only not authenticated users to login
|
||||
def test_func(self):
|
||||
return self.request.user.is_authenticated is False
|
||||
|
||||
def handle_no_permission(self):
|
||||
return redirect(reverse("passbook_core:overview"))
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""Check if sign-up is enabled or invitation link given"""
|
||||
allowed = False
|
||||
if "invitation" in request.GET:
|
||||
invitations = Invitation.objects.filter(uuid=request.GET.get("invitation"))
|
||||
allowed = invitations.exists()
|
||||
if allowed:
|
||||
self._invitation = invitations.first()
|
||||
if CONFIG.y("passbook.sign_up.enabled"):
|
||||
allowed = True
|
||||
if not allowed:
|
||||
messages.error(request, _("Sign-ups are currently disabled."))
|
||||
return redirect(reverse("passbook_core:auth-login"))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
if self._invitation:
|
||||
initial = {}
|
||||
if self._invitation.fixed_username:
|
||||
initial["username"] = self._invitation.fixed_username
|
||||
if self._invitation.fixed_email:
|
||||
initial["email"] = self._invitation.fixed_email
|
||||
return initial
|
||||
return super().get_initial()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["config"] = CONFIG.y("passbook")
|
||||
kwargs["title"] = _("Sign Up")
|
||||
kwargs["primary_action"] = _("Sign up")
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def form_valid(self, form: SignUpForm) -> HttpResponse:
|
||||
"""Create user"""
|
||||
try:
|
||||
self._user = SignUpView.create_user(form.cleaned_data, self.request)
|
||||
except PasswordPolicyInvalid as exc:
|
||||
# Manually inject error into form
|
||||
# pylint: disable=protected-access
|
||||
errors = form._errors.setdefault("password", ErrorList())
|
||||
for error in exc.messages:
|
||||
errors.append(error)
|
||||
return self.form_invalid(form)
|
||||
self.consume_invitation()
|
||||
messages.success(self.request, _("Successfully signed up!"))
|
||||
LOGGER.debug("Successfully signed up", email=form.cleaned_data.get("email"))
|
||||
return redirect(reverse("passbook_core:auth-login"))
|
||||
|
||||
def consume_invitation(self):
|
||||
"""Consume invitation if an invitation was used"""
|
||||
if self._invitation:
|
||||
invitation_used.send(
|
||||
sender=self,
|
||||
request=self.request,
|
||||
invitation=self._invitation,
|
||||
user=self._user,
|
||||
)
|
||||
self._invitation.delete()
|
||||
|
||||
@staticmethod
|
||||
def create_user(data: Dict, request: HttpRequest = None) -> User:
|
||||
"""Create user from data
|
||||
|
||||
Args:
|
||||
data: Dictionary as returned by SignUpForm's cleaned_data
|
||||
request: Optional current request.
|
||||
|
||||
Returns:
|
||||
The user created
|
||||
|
||||
Raises:
|
||||
PasswordPolicyInvalid: if any policy are not fulfilled.
|
||||
This also deletes the created user.
|
||||
"""
|
||||
# Create user
|
||||
new_user = User.objects.create(
|
||||
username=data.get("username"),
|
||||
email=data.get("email"),
|
||||
name=data.get("name"),
|
||||
)
|
||||
new_user.is_active = True
|
||||
try:
|
||||
new_user.set_password(data.get("password"))
|
||||
new_user.save()
|
||||
request.user = new_user
|
||||
# Send signal for other auth sources
|
||||
user_signed_up.send(sender=SignUpView, user=new_user, request=request)
|
||||
return new_user
|
||||
except PasswordPolicyInvalid as exc:
|
||||
new_user.delete()
|
||||
raise exc
|
||||
|
||||
|
||||
class SignUpConfirmView(View):
|
||||
"""Confirm registration from Nonce"""
|
||||
|
||||
def get(self, request, nonce):
|
||||
"""Verify UUID and activate user"""
|
||||
nonce = get_object_or_404(Nonce, uuid=nonce)
|
||||
nonce.user.is_active = True
|
||||
nonce.user.save()
|
||||
# Workaround: hardcoded reference to ModelBackend, needs testing
|
||||
nonce.user.backend = "django.contrib.auth.backends.ModelBackend"
|
||||
login(request, nonce.user)
|
||||
nonce.delete()
|
||||
messages.success(request, _("Successfully confirmed registration."))
|
||||
return redirect("passbook_core:overview")
|
||||
|
||||
|
||||
class PasswordResetView(View):
|
||||
"""Temporarily authenticate User and allow them to reset their password"""
|
||||
|
||||
def get(self, request, nonce):
|
||||
"""Authenticate user with nonce and redirect to password change view"""
|
||||
# 3. (Optional) Trap user in password change view
|
||||
nonce = get_object_or_404(Nonce, uuid=nonce)
|
||||
# Workaround: hardcoded reference to ModelBackend, needs testing
|
||||
nonce.user.backend = "django.contrib.auth.backends.ModelBackend"
|
||||
login(request, nonce.user)
|
||||
nonce.delete()
|
||||
messages.success(
|
||||
request, _(("Temporarily authenticated, please change your password")),
|
||||
)
|
||||
return redirect("passbook_core:user-change-password")
|
|
@ -1,17 +1,11 @@
|
|||
"""passbook core user views"""
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout, update_session_auth_hash
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.forms.utils import ErrorList
|
||||
from django.shortcuts import redirect, reverse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import DeleteView, FormView, UpdateView
|
||||
from django.views.generic import UpdateView
|
||||
|
||||
from passbook.core.forms.users import PasswordChangeForm, UserDetailForm
|
||||
from passbook.factors.password.exceptions import PasswordPolicyInvalid
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook.core.forms.users import UserDetailForm
|
||||
|
||||
|
||||
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
|
||||
|
@ -25,48 +19,3 @@ class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
|
|||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
|
||||
class UserDeleteView(LoginRequiredMixin, DeleteView):
|
||||
"""Delete user account"""
|
||||
|
||||
template_name = "generic/delete.html"
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
def get_success_url(self):
|
||||
messages.success(self.request, _("Successfully deleted user."))
|
||||
logout(self.request)
|
||||
return reverse("passbook_core:auth-login")
|
||||
|
||||
|
||||
class UserChangePasswordView(LoginRequiredMixin, FormView):
|
||||
"""View for users to update their password"""
|
||||
|
||||
form_class = PasswordChangeForm
|
||||
template_name = "login/form_with_user.html"
|
||||
|
||||
def form_valid(self, form: PasswordChangeForm):
|
||||
try:
|
||||
# user.set_password checks against Policies so we don't need to manually do it here
|
||||
self.request.user.set_password(form.cleaned_data.get("password"))
|
||||
self.request.user.save()
|
||||
update_session_auth_hash(self.request, self.request.user)
|
||||
messages.success(self.request, _("Successfully changed password"))
|
||||
except PasswordPolicyInvalid as exc:
|
||||
# Manually inject error into form
|
||||
# pylint: disable=protected-access
|
||||
errors = form._errors.setdefault("password_repeat", ErrorList(""))
|
||||
# pylint: disable=protected-access
|
||||
errors = form._errors.setdefault("password", ErrorList())
|
||||
for error in exc.messages:
|
||||
errors.append(error)
|
||||
return self.form_invalid(form)
|
||||
return redirect("passbook_core:overview")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["config"] = CONFIG.y("passbook")
|
||||
kwargs["title"] = _("Change Password")
|
||||
kwargs["primary_action"] = _("Change")
|
||||
return super().get_context_data(**kwargs)
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
"""passbook multi-factor authentication engine"""
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.factors.view import AuthenticationView
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
|
||||
class AuthenticationFactor(TemplateView):
|
||||
"""Abstract Authentication factor, inherits TemplateView but can be combined with FormView"""
|
||||
|
||||
form: ModelForm = None
|
||||
required: bool = True
|
||||
authenticator: AuthenticationView
|
||||
pending_user: User
|
||||
request: HttpRequest = None
|
||||
template_name = "login/form_with_user.html"
|
||||
|
||||
def __init__(self, authenticator: AuthenticationView):
|
||||
self.authenticator = authenticator
|
||||
self.pending_user = None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["config"] = CONFIG.y("passbook")
|
||||
kwargs["title"] = _("Log in to your account")
|
||||
kwargs["primary_action"] = _("Log in")
|
||||
kwargs["user"] = self.pending_user
|
||||
return super().get_context_data(**kwargs)
|
|
@ -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,26 +0,0 @@
|
|||
"""passbook captcha factor"""
|
||||
|
||||
from django.views.generic import FormView
|
||||
|
||||
from passbook.factors.base import AuthenticationFactor
|
||||
from passbook.factors.captcha.forms import CaptchaForm
|
||||
|
||||
|
||||
class CaptchaFactor(FormView, AuthenticationFactor):
|
||||
"""Simple captcha checker, logic is handeled in django-captcha module"""
|
||||
|
||||
form_class = CaptchaForm
|
||||
|
||||
def form_valid(self, form):
|
||||
return self.authenticator.user_ok()
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = CaptchaForm(**self.get_form_kwargs())
|
||||
form.fields["captcha"].public_key = self.authenticator.current_factor.public_key
|
||||
form.fields[
|
||||
"captcha"
|
||||
].private_key = self.authenticator.current_factor.private_key
|
||||
form.fields["captcha"].widget.attrs["data-sitekey"] = form.fields[
|
||||
"captcha"
|
||||
].public_key
|
||||
return form
|
|
@ -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.factors.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,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.factors.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.authenticator.user_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.factors.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,38 +0,0 @@
|
|||
"""EmailFactor API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.factors.email.models import EmailFactor
|
||||
|
||||
|
||||
class EmailFactorSerializer(ModelSerializer):
|
||||
"""EmailFactor Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = EmailFactor
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"slug",
|
||||
"order",
|
||||
"enabled",
|
||||
"host",
|
||||
"port",
|
||||
"username",
|
||||
"password",
|
||||
"use_tls",
|
||||
"use_ssl",
|
||||
"timeout",
|
||||
"from_address",
|
||||
"ssl_keyfile",
|
||||
"ssl_certfile",
|
||||
]
|
||||
extra_kwargs = {"password": {"write_only": True}}
|
||||
|
||||
|
||||
class EmailFactorViewSet(ModelViewSet):
|
||||
"""EmailFactor Viewset"""
|
||||
|
||||
queryset = EmailFactor.objects.all()
|
||||
serializer_class = EmailFactorSerializer
|
|
@ -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,49 +0,0 @@
|
|||
"""passbook multi-factor authentication engine"""
|
||||
from django.contrib import messages
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect, reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Nonce
|
||||
from passbook.factors.base import AuthenticationFactor
|
||||
from passbook.factors.email.tasks import send_mails
|
||||
from passbook.factors.email.utils import TemplateEmailMessage
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class EmailFactorView(AuthenticationFactor):
|
||||
"""Dummy factor for testing with multiple factors"""
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["show_password_forget_notice"] = CONFIG.y(
|
||||
"passbook.password_reset.enabled"
|
||||
)
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
nonce = Nonce.objects.create(user=self.pending_user)
|
||||
# Send mail to user
|
||||
message = TemplateEmailMessage(
|
||||
subject=_("Forgotten password"),
|
||||
template_name="email/account_password_reset.html",
|
||||
to=[self.pending_user.email],
|
||||
template_context={
|
||||
"url": self.request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_core:auth-password-reset",
|
||||
kwargs={"nonce": nonce.uuid},
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
send_mails(self.authenticator.current_factor, message)
|
||||
self.authenticator.cleanup()
|
||||
messages.success(request, _("Check your E-Mails for a password reset link."))
|
||||
return redirect("passbook_core:auth-login")
|
||||
|
||||
def post(self, request: HttpRequest):
|
||||
"""Just redirect to next factor"""
|
||||
return self.authenticator.user_ok()
|
|
@ -1,48 +0,0 @@
|
|||
"""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.factors.forms import GENERAL_FIELDS
|
||||
|
||||
|
||||
class EmailFactorForm(forms.ModelForm):
|
||||
"""Form to create/edit Dummy Factor"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = EmailFactor
|
||||
fields = GENERAL_FIELDS + [
|
||||
"host",
|
||||
"port",
|
||||
"username",
|
||||
"password",
|
||||
"use_tls",
|
||||
"use_ssl",
|
||||
"timeout",
|
||||
"from_address",
|
||||
"ssl_keyfile",
|
||||
"ssl_certfile",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"order": forms.NumberInput(),
|
||||
"policies": FilteredSelectMultiple(_("policies"), False),
|
||||
"host": forms.TextInput(),
|
||||
"username": forms.TextInput(),
|
||||
"password": forms.TextInput(),
|
||||
"ssl_keyfile": forms.TextInput(),
|
||||
"ssl_certfile": forms.TextInput(),
|
||||
}
|
||||
labels = {
|
||||
"use_tls": _("Use TLS"),
|
||||
"use_ssl": _("Use SSL"),
|
||||
"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,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,44 +0,0 @@
|
|||
"""email factor tasks"""
|
||||
from smtplib import SMTPException
|
||||
from typing import Any, Dict, List
|
||||
|
||||
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
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def send_mails(factor: EmailFactor, *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__))
|
||||
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.
|
||||
Automatically retries if message couldn't be sent."""
|
||||
factor: EmailFactor = EmailFactor.objects.get(pk=email_factor_pk)
|
||||
backend = factor.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
|
||||
LOGGER.debug("Sending mail", to=message_object.to)
|
||||
try:
|
||||
num_sent = factor.backend.send_messages([message_object])
|
||||
except SMTPException as exc:
|
||||
raise self.retry(exc=exc)
|
||||
if num_sent != 1:
|
||||
raise self.retry()
|
|
@ -1,41 +0,0 @@
|
|||
"""email utils"""
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
|
||||
class TemplateEmailMessage(EmailMultiAlternatives):
|
||||
"""Wrapper around EmailMultiAlternatives with integrated template rendering"""
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
self,
|
||||
subject="",
|
||||
body=None,
|
||||
from_email=None,
|
||||
to=None,
|
||||
bcc=None,
|
||||
connection=None,
|
||||
attachments=None,
|
||||
headers=None,
|
||||
cc=None,
|
||||
reply_to=None,
|
||||
template_name=None,
|
||||
template_context=None,
|
||||
):
|
||||
html_content = render_to_string(template_name, template_context)
|
||||
if not body:
|
||||
body = strip_tags(html_content)
|
||||
super().__init__(
|
||||
subject=subject,
|
||||
body=body,
|
||||
from_email=from_email,
|
||||
to=to,
|
||||
bcc=bcc,
|
||||
connection=connection,
|
||||
attachments=attachments,
|
||||
headers=headers,
|
||||
cc=cc,
|
||||
reply_to=reply_to,
|
||||
)
|
||||
self.attach_alternative(html_content, "text/html")
|
|
@ -1,3 +0,0 @@
|
|||
"""factor forms"""
|
||||
|
||||
GENERAL_FIELDS = ["name", "slug", "order", "policies", "enabled"]
|
|
@ -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,48 +0,0 @@
|
|||
{% extends "user/base.html" %}
|
||||
|
||||
{% load utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block page %}
|
||||
<div class="pf-c-card__header pf-c-title pf-m-md">
|
||||
<h1>{% trans "One-Time Passwords" %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card-footer">
|
||||
<p>
|
||||
{% blocktrans with state=state|yesno:"Enabled,Disabled" %}
|
||||
Status: {{ state }}
|
||||
{% endblocktrans %}
|
||||
{% if state %}
|
||||
<i class="pf-icon pf-icon-ok"></i>
|
||||
{% else %}
|
||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
{% if not state %}
|
||||
<a href="{% url 'passbook_factors_otp:otp-enable' %}"
|
||||
class="btn btn-success btn-sm">{% trans "Enable OTP" %}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'passbook_factors_otp:otp-disable' %}"
|
||||
class="btn btn-danger btn-sm">{% trans "Disable OTP" %}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
{% trans "Your Backup tokens:" %}
|
||||
</div>
|
||||
<div class="card-block">
|
||||
<pre>{% for token in static_tokens %}{{ token.token }}
|
||||
{% empty %}{% trans 'N/A' %}{% endfor %}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -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,12 +0,0 @@
|
|||
"""passbook password policy exceptions"""
|
||||
from passbook.lib.sentry import SentryIgnoredException
|
||||
|
||||
|
||||
class PasswordPolicyInvalid(SentryIgnoredException):
|
||||
"""Exception raised when a Password Policy fails"""
|
||||
|
||||
messages = []
|
||||
|
||||
def __init__(self, *messages):
|
||||
super().__init__()
|
||||
self.messages = messages
|
|
@ -1,90 +0,0 @@
|
|||
"""passbook multi-factor authentication engine"""
|
||||
from inspect import Signature
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth import _clean_credentials
|
||||
from django.contrib.auth.signals import user_login_failed
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.forms.utils import ErrorList
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import FormView
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.factors.base import AuthenticationFactor
|
||||
from passbook.factors.password.forms import PasswordForm
|
||||
from passbook.factors.view import AuthenticationView
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook.lib.utils.reflection import path_to_class
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def authenticate(request, backends, **credentials) -> Optional[User]:
|
||||
"""If the given credentials are valid, return a User object.
|
||||
|
||||
Customized version of django's authenticate, which accepts a list of backends"""
|
||||
for backend_path in backends:
|
||||
backend = path_to_class(backend_path)()
|
||||
try:
|
||||
signature = Signature.from_callable(backend.authenticate)
|
||||
signature.bind(request, **credentials)
|
||||
except TypeError:
|
||||
LOGGER.warning("Backend doesn't accept our arguments", backend=backend)
|
||||
# This backend doesn't accept these credentials as arguments. Try the next one.
|
||||
continue
|
||||
LOGGER.debug("Attempting authentication...", backend=backend)
|
||||
try:
|
||||
user = backend.authenticate(request, **credentials)
|
||||
except PermissionDenied:
|
||||
LOGGER.debug("Backend threw PermissionDenied", backend=backend)
|
||||
# This backend says to stop in our tracks - this user should not be allowed in at all.
|
||||
break
|
||||
if user is None:
|
||||
continue
|
||||
# Annotate the user object with the path of the backend.
|
||||
user.backend = backend_path
|
||||
return user
|
||||
|
||||
# The credentials supplied are invalid to all backends, fire signal
|
||||
user_login_failed.send(
|
||||
sender=__name__, credentials=_clean_credentials(credentials), request=request
|
||||
)
|
||||
|
||||
|
||||
class PasswordFactor(FormView, AuthenticationFactor):
|
||||
"""Authentication factor which authenticates against django's AuthBackend"""
|
||||
|
||||
form_class = PasswordForm
|
||||
template_name = "login/factors/backend.html"
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Authenticate against django's authentication backend"""
|
||||
uid_fields = CONFIG.y("passbook.uid_fields")
|
||||
kwargs = {
|
||||
"password": form.cleaned_data.get("password"),
|
||||
}
|
||||
for uid_field in uid_fields:
|
||||
kwargs[uid_field] = getattr(self.authenticator.pending_user, uid_field)
|
||||
try:
|
||||
user = authenticate(
|
||||
self.request, self.authenticator.current_factor.backends, **kwargs
|
||||
)
|
||||
if user:
|
||||
# User instance returned from authenticate() has .backend property set
|
||||
self.authenticator.pending_user = user
|
||||
self.request.session[
|
||||
AuthenticationView.SESSION_USER_BACKEND
|
||||
] = user.backend
|
||||
return self.authenticator.user_ok()
|
||||
# No user was found -> invalid credentials
|
||||
LOGGER.debug("Invalid credentials")
|
||||
# Manually inject error into form
|
||||
# pylint: disable=protected-access
|
||||
errors = form._errors.setdefault("password", ErrorList())
|
||||
errors.append(_("Invalid password"))
|
||||
return self.form_invalid(form)
|
||||
except PermissionDenied:
|
||||
# User was found, but permission was denied (i.e. user is not active)
|
||||
LOGGER.debug("Denied access", **kwargs)
|
||||
return self.authenticator.user_invalid()
|
|
@ -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,46 +0,0 @@
|
|||
"""password factor 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.types import UIUserSettings
|
||||
|
||||
|
||||
class PasswordFactor(Factor):
|
||||
"""Password-based Django-backend Authentication Factor"""
|
||||
|
||||
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"
|
||||
|
||||
@property
|
||||
def ui_user_settings(self) -> UIUserSettings:
|
||||
return UIUserSettings(
|
||||
name="Change Password",
|
||||
icon="pficon-key",
|
||||
view_name="passbook_core:user-change-password",
|
||||
)
|
||||
|
||||
def password_passes(self, user: User) -> bool:
|
||||
"""Return true if user's password passes, otherwise False or raise Exception"""
|
||||
for policy in self.policies.all():
|
||||
if not policy.passes(user):
|
||||
return False
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
return "Password Factor %s" % self.slug
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Password Factor")
|
||||
verbose_name_plural = _("Password Factors")
|
|
@ -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,137 +0,0 @@
|
|||
"""passbook Core Authentication Test"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.factors.dummy.models import DummyFactor
|
||||
from passbook.factors.password.models import PasswordFactor
|
||||
from passbook.factors.view import AuthenticationView
|
||||
|
||||
|
||||
class TestFactorAuthentication(TestCase):
|
||||
"""passbook Core Authentication Test"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.password = "".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits)
|
||||
for _ in range(8)
|
||||
)
|
||||
self.factor, _ = PasswordFactor.objects.get_or_create(
|
||||
slug="password",
|
||||
defaults={
|
||||
"name": "password",
|
||||
"slug": "password",
|
||||
"order": 0,
|
||||
"backends": ["django.contrib.auth.backends.ModelBackend"],
|
||||
},
|
||||
)
|
||||
self.user = User.objects.create_user(
|
||||
username="test", email="test@test.test", password=self.password
|
||||
)
|
||||
|
||||
def test_unauthenticated_raw(self):
|
||||
"""test direct call to AuthenticationView"""
|
||||
response = self.client.get(reverse("passbook_core:auth-process"))
|
||||
# Response should be 400 since no pending user is set
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_unauthenticated_prepared(self):
|
||||
"""test direct call but with pending_uesr in session"""
|
||||
request = RequestFactory().get(reverse("passbook_core:auth-process"))
|
||||
request.user = AnonymousUser()
|
||||
request.session = {}
|
||||
request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk
|
||||
|
||||
response = AuthenticationView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_no_factors(self):
|
||||
"""Test with all factors disabled"""
|
||||
self.factor.enabled = False
|
||||
self.factor.save()
|
||||
request = RequestFactory().get(reverse("passbook_core:auth-process"))
|
||||
request.user = AnonymousUser()
|
||||
request.session = {}
|
||||
request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk
|
||||
|
||||
response = AuthenticationView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("passbook_core:auth-denied"))
|
||||
self.factor.enabled = True
|
||||
self.factor.save()
|
||||
|
||||
def test_authenticated(self):
|
||||
"""Test with already logged in user"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("passbook_core:auth-process"))
|
||||
# Response should be 400 since no pending user is set
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.client.logout()
|
||||
|
||||
def test_unauthenticated_post(self):
|
||||
"""Test post request as unauthenticated user"""
|
||||
request = RequestFactory().post(
|
||||
reverse("passbook_core:auth-process"), data={"password": self.password}
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
middleware = SessionMiddleware()
|
||||
middleware.process_request(request)
|
||||
request.session.save() # pylint: disable=no-member
|
||||
request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk
|
||||
|
||||
response = AuthenticationView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
||||
self.client.logout()
|
||||
|
||||
def test_unauthenticated_post_invalid(self):
|
||||
"""Test post request as unauthenticated user"""
|
||||
request = RequestFactory().post(
|
||||
reverse("passbook_core:auth-process"),
|
||||
data={"password": self.password + "a"},
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
middleware = SessionMiddleware()
|
||||
middleware.process_request(request)
|
||||
request.session.save() # pylint: disable=no-member
|
||||
request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk
|
||||
|
||||
response = AuthenticationView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.client.logout()
|
||||
|
||||
def test_multifactor(self):
|
||||
"""Test view with multiple active factors"""
|
||||
DummyFactor.objects.get_or_create(name="dummy", slug="dummy", order=1)
|
||||
request = RequestFactory().post(
|
||||
reverse("passbook_core:auth-process"), data={"password": self.password}
|
||||
)
|
||||
request.user = AnonymousUser()
|
||||
middleware = SessionMiddleware()
|
||||
middleware.process_request(request)
|
||||
request.session.save() # pylint: disable=no-member
|
||||
request.session[AuthenticationView.SESSION_PENDING_USER] = self.user.pk
|
||||
|
||||
response = AuthenticationView.as_view()(request)
|
||||
session_copy = request.session.items()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
# Verify view redirects to itself after auth
|
||||
self.assertEqual(response.url, reverse("passbook_core:auth-process"))
|
||||
|
||||
# Run another request with same session which should result in a logged in user
|
||||
request = RequestFactory().post(reverse("passbook_core:auth-process"))
|
||||
request.user = AnonymousUser()
|
||||
middleware = SessionMiddleware()
|
||||
middleware.process_request(request)
|
||||
for key, value in session_copy:
|
||||
request.session[key] = value
|
||||
request.session.save() # pylint: disable=no-member
|
||||
response = AuthenticationView.as_view()(request)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("passbook_core:overview"))
|
|
@ -1,220 +0,0 @@
|
|||
"""passbook multi-factor authentication engine"""
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.views.generic import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Factor, User
|
||||
from passbook.core.views.utils import PermissionDeniedView
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook.lib.utils.reflection import class_to_path, path_to_class
|
||||
from passbook.lib.utils.urls import is_url_absolute
|
||||
from passbook.lib.views import bad_request_message
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
|
||||
LOGGER = get_logger()
|
||||
# Argument used to redirect user after login
|
||||
NEXT_ARG_NAME = "next"
|
||||
|
||||
|
||||
def _redirect_with_qs(view, get_query_set=None):
|
||||
"""Wrapper to redirect whilst keeping GET Parameters"""
|
||||
target = reverse(view)
|
||||
if get_query_set:
|
||||
target += "?" + urlencode(get_query_set.items())
|
||||
return redirect(target)
|
||||
|
||||
|
||||
class AuthenticationView(UserPassesTestMixin, View):
|
||||
"""Wizard-like Multi-factor authenticator"""
|
||||
|
||||
SESSION_FACTOR = "passbook_factor"
|
||||
SESSION_PENDING_FACTORS = "passbook_pending_factors"
|
||||
SESSION_PENDING_USER = "passbook_pending_user"
|
||||
SESSION_USER_BACKEND = "passbook_user_backend"
|
||||
SESSION_IS_SSO_LOGIN = "passbook_sso_login"
|
||||
|
||||
pending_user: User
|
||||
pending_factors: List[Tuple[str, str]] = []
|
||||
|
||||
_current_factor_class: Factor
|
||||
|
||||
current_factor: Factor
|
||||
|
||||
# Allow only not authenticated users to login
|
||||
def test_func(self) -> bool:
|
||||
return AuthenticationView.SESSION_PENDING_USER in self.request.session
|
||||
|
||||
def _check_config_domain(self) -> Optional[HttpResponse]:
|
||||
"""Checks if current request's domain matches configured Domain, and
|
||||
adds a warning if not."""
|
||||
current_domain = self.request.get_host()
|
||||
if ":" in current_domain:
|
||||
current_domain, _ = current_domain.split(":")
|
||||
config_domain = CONFIG.y("domain")
|
||||
if current_domain != config_domain:
|
||||
message = (
|
||||
f"Current domain of '{current_domain}' doesn't "
|
||||
f"match configured domain of '{config_domain}'."
|
||||
)
|
||||
LOGGER.warning(message)
|
||||
return bad_request_message(self.request, message)
|
||||
return None
|
||||
|
||||
def handle_no_permission(self) -> HttpResponse:
|
||||
# Function from UserPassesTestMixin
|
||||
if NEXT_ARG_NAME in self.request.GET:
|
||||
return redirect(self.request.GET.get(NEXT_ARG_NAME))
|
||||
if self.request.user.is_authenticated:
|
||||
return _redirect_with_qs("passbook_core:overview", self.request.GET)
|
||||
return _redirect_with_qs("passbook_core:auth-login", self.request.GET)
|
||||
|
||||
def get_pending_factors(self) -> List[Tuple[str, str]]:
|
||||
"""Loading pending factors from Database or load from session variable"""
|
||||
# Write pending factors to session
|
||||
if AuthenticationView.SESSION_PENDING_FACTORS in self.request.session:
|
||||
return self.request.session[AuthenticationView.SESSION_PENDING_FACTORS]
|
||||
# Get an initial list of factors which are currently enabled
|
||||
# and apply to the current user. We check policies here and block the request
|
||||
_all_factors = (
|
||||
Factor.objects.filter(enabled=True).order_by("order").select_subclasses()
|
||||
)
|
||||
pending_factors = []
|
||||
for factor in _all_factors:
|
||||
factor: Factor
|
||||
LOGGER.debug(
|
||||
"Checking if factor applies to user",
|
||||
factor=factor,
|
||||
user=self.pending_user,
|
||||
)
|
||||
policy_engine = PolicyEngine(
|
||||
factor.policies.all(), self.pending_user, self.request
|
||||
)
|
||||
policy_engine.build()
|
||||
if policy_engine.passing:
|
||||
pending_factors.append((factor.uuid.hex, factor.type))
|
||||
LOGGER.debug("Factor applies", factor=factor, user=self.pending_user)
|
||||
return pending_factors
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
# Check if user passes test (i.e. SESSION_PENDING_USER is set)
|
||||
user_test_result = self.get_test_func()()
|
||||
if not user_test_result:
|
||||
incorrect_domain_message = self._check_config_domain()
|
||||
if incorrect_domain_message:
|
||||
return incorrect_domain_message
|
||||
return self.handle_no_permission()
|
||||
# Extract pending user from session (only remember uid)
|
||||
self.pending_user = get_object_or_404(
|
||||
User, id=self.request.session[AuthenticationView.SESSION_PENDING_USER]
|
||||
)
|
||||
self.pending_factors = self.get_pending_factors()
|
||||
# Read and instantiate factor from session
|
||||
factor_uuid, factor_class = None, None
|
||||
if AuthenticationView.SESSION_FACTOR not in request.session:
|
||||
# Case when no factors apply to user, return error denied
|
||||
if not self.pending_factors:
|
||||
# Case when user logged in from SSO provider and no more factors apply
|
||||
if AuthenticationView.SESSION_IS_SSO_LOGIN in request.session:
|
||||
LOGGER.debug("User authenticated with SSO, logging in...")
|
||||
return self._user_passed()
|
||||
return self.user_invalid()
|
||||
factor_uuid, factor_class = self.pending_factors[0]
|
||||
else:
|
||||
factor_uuid, factor_class = request.session[
|
||||
AuthenticationView.SESSION_FACTOR
|
||||
]
|
||||
# Lookup current factor object
|
||||
self.current_factor = (
|
||||
Factor.objects.filter(uuid=factor_uuid).select_subclasses().first()
|
||||
)
|
||||
# Instantiate Next Factor and pass request
|
||||
factor = path_to_class(factor_class)
|
||||
self._current_factor_class = factor(self)
|
||||
self._current_factor_class.pending_user = self.pending_user
|
||||
self._current_factor_class.request = request
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""pass get request to current factor"""
|
||||
LOGGER.debug(
|
||||
"Passing GET",
|
||||
view_class=class_to_path(self._current_factor_class.__class__),
|
||||
)
|
||||
return self._current_factor_class.get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""pass post request to current factor"""
|
||||
LOGGER.debug(
|
||||
"Passing POST",
|
||||
view_class=class_to_path(self._current_factor_class.__class__),
|
||||
)
|
||||
return self._current_factor_class.post(request, *args, **kwargs)
|
||||
|
||||
def user_ok(self) -> HttpResponse:
|
||||
"""Redirect to next Factor"""
|
||||
LOGGER.debug(
|
||||
"Factor passed",
|
||||
factor_class=class_to_path(self._current_factor_class.__class__),
|
||||
)
|
||||
# Remove passed factor from pending factors
|
||||
current_factor_tuple = (
|
||||
self.current_factor.uuid.hex,
|
||||
class_to_path(self._current_factor_class.__class__),
|
||||
)
|
||||
if current_factor_tuple in self.pending_factors:
|
||||
self.pending_factors.remove(current_factor_tuple)
|
||||
next_factor = None
|
||||
if self.pending_factors:
|
||||
next_factor = self.pending_factors.pop()
|
||||
# Save updated pening_factor list to session
|
||||
self.request.session[
|
||||
AuthenticationView.SESSION_PENDING_FACTORS
|
||||
] = self.pending_factors
|
||||
self.request.session[AuthenticationView.SESSION_FACTOR] = next_factor
|
||||
LOGGER.debug("Rendering Factor", next_factor=next_factor)
|
||||
return _redirect_with_qs("passbook_core:auth-process", self.request.GET)
|
||||
# User passed all factors
|
||||
LOGGER.debug("User passed all factors, logging in", user=self.pending_user)
|
||||
return self._user_passed()
|
||||
|
||||
def user_invalid(self) -> HttpResponse:
|
||||
"""Show error message, user cannot login.
|
||||
This should only be shown if user authenticated successfully, but is disabled/locked/etc"""
|
||||
LOGGER.debug("User invalid")
|
||||
self.cleanup()
|
||||
return _redirect_with_qs("passbook_core:auth-denied", self.request.GET)
|
||||
|
||||
def _user_passed(self) -> HttpResponse:
|
||||
"""User Successfully passed all factors"""
|
||||
backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND]
|
||||
login(self.request, self.pending_user, backend=backend)
|
||||
LOGGER.debug("Logged in", user=self.pending_user)
|
||||
# Cleanup
|
||||
self.cleanup()
|
||||
next_param = self.request.GET.get(NEXT_ARG_NAME, None)
|
||||
if next_param and not is_url_absolute(next_param):
|
||||
return redirect(next_param)
|
||||
return _redirect_with_qs("passbook_core:overview")
|
||||
|
||||
def cleanup(self):
|
||||
"""Remove temporary data from session"""
|
||||
session_keys = [
|
||||
self.SESSION_FACTOR,
|
||||
self.SESSION_PENDING_FACTORS,
|
||||
self.SESSION_PENDING_USER,
|
||||
self.SESSION_USER_BACKEND,
|
||||
]
|
||||
for key in session_keys:
|
||||
if key in self.request.session:
|
||||
del self.request.session[key]
|
||||
LOGGER.debug("Cleaned up sessions")
|
||||
|
||||
|
||||
class FactorPermissionDeniedView(PermissionDeniedView):
|
||||
"""User could not be authenticated"""
|
|
@ -0,0 +1,68 @@
|
|||
"""Flow API Views"""
|
||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from passbook.flows.models import Flow, FlowStageBinding, Stage
|
||||
|
||||
|
||||
class FlowSerializer(ModelSerializer):
|
||||
"""Flow Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Flow
|
||||
fields = ["pk", "name", "slug", "designation", "stages", "policies"]
|
||||
|
||||
|
||||
class FlowViewSet(ModelViewSet):
|
||||
"""Flow Viewset"""
|
||||
|
||||
queryset = Flow.objects.all()
|
||||
serializer_class = FlowSerializer
|
||||
|
||||
|
||||
class FlowStageBindingSerializer(ModelSerializer):
|
||||
"""FlowStageBinding Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = FlowStageBinding
|
||||
fields = ["pk", "flow", "stage", "re_evaluate_policies", "order", "policies"]
|
||||
|
||||
|
||||
class FlowStageBindingViewSet(ModelViewSet):
|
||||
"""FlowStageBinding Viewset"""
|
||||
|
||||
queryset = FlowStageBinding.objects.all()
|
||||
serializer_class = FlowStageBindingSerializer
|
||||
filterset_fields = "__all__"
|
||||
|
||||
|
||||
class StageSerializer(ModelSerializer):
|
||||
"""Stage Serializer"""
|
||||
|
||||
__type__ = SerializerMethodField(method_name="get_type")
|
||||
verbose_name = SerializerMethodField(method_name="get_verbose_name")
|
||||
|
||||
def get_type(self, obj: Stage) -> str:
|
||||
"""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", "")
|
||||
|
||||
def get_verbose_name(self, obj: Stage) -> str:
|
||||
"""Get verbose name for UI"""
|
||||
return obj._meta.verbose_name
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Stage
|
||||
fields = ["pk", "name", "__type__", "verbose_name"]
|
||||
|
||||
|
||||
class StageViewSet(ReadOnlyModelViewSet):
|
||||
"""Stage Viewset"""
|
||||
|
||||
queryset = Stage.objects.all()
|
||||
serializer_class = StageSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Stage.objects.select_subclasses()
|
|
@ -0,0 +1,11 @@
|
|||
"""passbook flows app config"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookFlowsConfig(AppConfig):
|
||||
"""passbook flows app config"""
|
||||
|
||||
name = "passbook.flows"
|
||||
label = "passbook_flows"
|
||||
mountpoint = "flows/"
|
||||
verbose_name = "passbook Flows"
|
|
@ -0,0 +1,9 @@
|
|||
"""flow exceptions"""
|
||||
|
||||
|
||||
class FlowNonApplicableException(BaseException):
|
||||
"""Exception raised when a Flow does not apply to a user."""
|
||||
|
||||
|
||||
class EmptyFlowException(BaseException):
|
||||
"""Exception raised when a Flow Plan is empty"""
|
|
@ -0,0 +1,56 @@
|
|||
"""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, FlowStageBinding
|
||||
|
||||
|
||||
class FlowForm(forms.ModelForm):
|
||||
"""Flow Form"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Flow
|
||||
fields = [
|
||||
"name",
|
||||
"slug",
|
||||
"designation",
|
||||
"stages",
|
||||
"policies",
|
||||
]
|
||||
help_texts = {
|
||||
"name": _("Shown as the Title in Flow pages."),
|
||||
"slug": _("Visible in the URL."),
|
||||
"designation": _(
|
||||
(
|
||||
"Decides what this Flow is used for. For example, the Authentication flow "
|
||||
"is redirect to when an un-authenticated user visits passbook."
|
||||
)
|
||||
),
|
||||
}
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"stages": FilteredSelectMultiple(_("stages"), False),
|
||||
"policies": FilteredSelectMultiple(_("policies"), False),
|
||||
}
|
||||
|
||||
|
||||
class FlowStageBindingForm(forms.ModelForm):
|
||||
"""FlowStageBinding Form"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = FlowStageBinding
|
||||
fields = [
|
||||
"flow",
|
||||
"stage",
|
||||
"re_evaluate_policies",
|
||||
"order",
|
||||
"policies",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"policies": FilteredSelectMultiple(_("policies"), False),
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
# Generated by Django 3.0.3 on 2020-05-08 18:27
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_policies", "0003_auto_20200508_1642"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Flow",
|
||||
fields=[
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
("slug", models.SlugField(unique=True)),
|
||||
(
|
||||
"designation",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("AUTHENTICATION", "authentication"),
|
||||
("ENROLLMENT", "enrollment"),
|
||||
("RECOVERY", "recovery"),
|
||||
("PASSWORD_CHANGE", "password_change"),
|
||||
],
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"pbm",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
related_name="+",
|
||||
to="passbook_policies.PolicyBindingModel",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Flow", "verbose_name_plural": "Flows",},
|
||||
bases=("passbook_policies.policybindingmodel", models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
to="passbook_policies.PolicyBindingModel",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"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(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="passbook_flows.Flow",
|
||||
),
|
||||
),
|
||||
(
|
||||
"stage",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="passbook_flows.Stage",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"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="stages",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
through="passbook_flows.FlowStageBinding",
|
||||
to="passbook_flows.Stage",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,104 @@
|
|||
# Generated by Django 3.0.3 on 2020-05-08 14:30
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from passbook.flows.models import FlowDesignation
|
||||
from passbook.stages.identification.models import Templates, UserFields
|
||||
|
||||
|
||||
def create_default_authentication_flow(
|
||||
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
|
||||
):
|
||||
Flow = apps.get_model("passbook_flows", "Flow")
|
||||
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
|
||||
PasswordStage = apps.get_model("passbook_stages_password", "PasswordStage")
|
||||
UserLoginStage = apps.get_model("passbook_stages_user_login", "UserLoginStage")
|
||||
IdentificationStage = apps.get_model(
|
||||
"passbook_stages_identification", "IdentificationStage"
|
||||
)
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
if (
|
||||
Flow.objects.using(db_alias)
|
||||
.filter(designation=FlowDesignation.AUTHENTICATION)
|
||||
.exists()
|
||||
):
|
||||
# Only create default flow when none exist
|
||||
return
|
||||
|
||||
if not IdentificationStage.objects.using(db_alias).exists():
|
||||
IdentificationStage.objects.using(db_alias).create(
|
||||
name="identification",
|
||||
user_fields=[UserFields.E_MAIL],
|
||||
template=Templates.DEFAULT_LOGIN,
|
||||
)
|
||||
|
||||
if not PasswordStage.objects.using(db_alias).exists():
|
||||
PasswordStage.objects.using(db_alias).create(
|
||||
name="password", backends=["django.contrib.auth.backends.ModelBackend"],
|
||||
)
|
||||
|
||||
if not UserLoginStage.objects.using(db_alias).exists():
|
||||
UserLoginStage.objects.using(db_alias).create(name="authentication")
|
||||
|
||||
flow = Flow.objects.using(db_alias).create(
|
||||
name="default-authentication-flow",
|
||||
slug="default-authentication-flow",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
FlowStageBinding.objects.using(db_alias).create(
|
||||
flow=flow, stage=IdentificationStage.objects.using(db_alias).first(), order=0,
|
||||
)
|
||||
FlowStageBinding.objects.using(db_alias).create(
|
||||
flow=flow, stage=PasswordStage.objects.using(db_alias).first(), order=1,
|
||||
)
|
||||
FlowStageBinding.objects.using(db_alias).create(
|
||||
flow=flow, stage=UserLoginStage.objects.using(db_alias).first(), order=2,
|
||||
)
|
||||
|
||||
|
||||
def create_default_invalidation_flow(
|
||||
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
|
||||
):
|
||||
Flow = apps.get_model("passbook_flows", "Flow")
|
||||
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
|
||||
UserLogoutStage = apps.get_model("passbook_stages_user_logout", "UserLogoutStage")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
if (
|
||||
Flow.objects.using(db_alias)
|
||||
.filter(designation=FlowDesignation.INVALIDATION)
|
||||
.exists()
|
||||
):
|
||||
# Only create default flow when none exist
|
||||
return
|
||||
|
||||
if not UserLogoutStage.objects.using(db_alias).exists():
|
||||
UserLogoutStage.objects.using(db_alias).create(name="authentication")
|
||||
|
||||
flow = Flow.objects.using(db_alias).create(
|
||||
name="default-invalidation-flow",
|
||||
slug="default-invalidation-flow",
|
||||
designation=FlowDesignation.INVALIDATION,
|
||||
)
|
||||
FlowStageBinding.objects.using(db_alias).create(
|
||||
flow=flow, stage=UserLogoutStage.objects.using(db_alias).first(), order=0,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_flows", "0001_initial"),
|
||||
("passbook_stages_user_login", "0001_initial"),
|
||||
("passbook_stages_user_logout", "0001_initial"),
|
||||
("passbook_stages_password", "0001_initial"),
|
||||
("passbook_stages_identification", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_default_authentication_flow),
|
||||
migrations.RunPython(create_default_invalidation_flow),
|
||||
]
|
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 3.0.3 on 2020-05-09 12:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_flows", "0002_default_flows"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="flow",
|
||||
name="designation",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("authentication", "Authentication"),
|
||||
("enrollment", "Enrollment"),
|
||||
("recovery", "Recovery"),
|
||||
("password_change", "Password Change"),
|
||||
],
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 3.0.5 on 2020-05-10 23:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_flows", "0003_auto_20200509_1258"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="flow",
|
||||
name="designation",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("authentication", "Authentication"),
|
||||
("enrollment", "Enrollment"),
|
||||
("recovery", "Recovery"),
|
||||
("password_change", "Password Change"),
|
||||
("invalidation", "Invalidation"),
|
||||
],
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 3.0.5 on 2020-05-12 11:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_flows", "0004_auto_20200510_2310"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="flow",
|
||||
name="designation",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("authentication", "Authentication"),
|
||||
("invalidation", "Invalidation"),
|
||||
("enrollment", "Enrollment"),
|
||||
("unenrollment", "Unrenollment"),
|
||||
("recovery", "Recovery"),
|
||||
("password_change", "Password Change"),
|
||||
],
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,101 @@
|
|||
"""Flow models"""
|
||||
from typing import Optional
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from model_utils.managers import InheritanceManager
|
||||
|
||||
from passbook.core.types import UIUserSettings
|
||||
from passbook.lib.models import UUIDModel
|
||||
from passbook.policies.models import PolicyBindingModel
|
||||
|
||||
|
||||
class FlowDesignation(models.TextChoices):
|
||||
"""Designation of what a Flow should be used for. At a later point, this
|
||||
should be replaced by a database entry."""
|
||||
|
||||
AUTHENTICATION = "authentication"
|
||||
INVALIDATION = "invalidation"
|
||||
ENROLLMENT = "enrollment"
|
||||
UNRENOLLMENT = "unenrollment"
|
||||
RECOVERY = "recovery"
|
||||
PASSWORD_CHANGE = "password_change" # nosec # noqa
|
||||
|
||||
|
||||
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 Stages should be executed to authenticate/enroll/recover
|
||||
a user. Additionally, policies can be applied, to specify which users
|
||||
have access to this flow."""
|
||||
|
||||
name = models.TextField()
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
designation = models.CharField(max_length=100, choices=FlowDesignation.choices)
|
||||
|
||||
stages = models.ManyToManyField(Stage, through="FlowStageBinding", blank=True)
|
||||
|
||||
pbm = models.OneToOneField(
|
||||
PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+"
|
||||
)
|
||||
|
||||
def related_flow(self, designation: str) -> Optional["Flow"]:
|
||||
"""Get a related flow with `designation`. Currently this only queries
|
||||
Flows by `designation`, but will eventually use `self` for related lookups."""
|
||||
return Flow.objects.filter(designation=designation).first()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Flow {self.name} ({self.slug})"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Flow")
|
||||
verbose_name_plural = _("Flows")
|
||||
|
||||
|
||||
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)
|
||||
stage = models.ForeignKey(Stage, on_delete=models.CASCADE)
|
||||
|
||||
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()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Flow Stage Binding #{self.order} {self.flow} -> {self.stage}"
|
||||
|
||||
class Meta:
|
||||
|
||||
ordering = ["order", "flow"]
|
||||
|
||||
verbose_name = _("Flow Stage Binding")
|
||||
verbose_name_plural = _("Flow Stage Bindings")
|
||||
unique_together = (("flow", "stage", "order"),)
|
|
@ -0,0 +1,96 @@
|
|||
"""Flows Planner"""
|
||||
from dataclasses import dataclass, field
|
||||
from time import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpRequest
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from passbook.flows.models import Flow, Stage
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
||||
PLAN_CONTEXT_SSO = "is_sso"
|
||||
|
||||
|
||||
def cache_key(flow: Flow, user: Optional[User] = None) -> str:
|
||||
"""Generate Cache key for flow"""
|
||||
prefix = f"flow_{flow.pk}"
|
||||
if user:
|
||||
prefix += f"#{user.pk}"
|
||||
return prefix
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowPlan:
|
||||
"""This data-class is the output of a FlowPlanner. It holds a flat list
|
||||
of all Stages that should be run."""
|
||||
|
||||
flow_pk: str
|
||||
stages: List[Stage] = field(default_factory=list)
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def next(self) -> Stage:
|
||||
"""Return next pending stage from the bottom of the list"""
|
||||
return self.stages[0]
|
||||
|
||||
|
||||
class FlowPlanner:
|
||||
"""Execute all policies to plan out a flat list of all Stages
|
||||
that should be applied."""
|
||||
|
||||
use_cache: bool
|
||||
flow: Flow
|
||||
|
||||
def __init__(self, flow: Flow):
|
||||
self.use_cache = True
|
||||
self.flow = flow
|
||||
|
||||
def _check_flow_root_policies(self, request: HttpRequest) -> Tuple[bool, List[str]]:
|
||||
engine = PolicyEngine(self.flow.policies.all(), request.user, request)
|
||||
engine.build()
|
||||
return engine.result
|
||||
|
||||
def plan(self, request: HttpRequest) -> FlowPlan:
|
||||
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
|
||||
and return ordered list"""
|
||||
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
|
||||
# First off, check the flow's direct policy bindings
|
||||
# to make sure the user even has access to the flow
|
||||
root_passing, root_passing_messages = self._check_flow_root_policies(request)
|
||||
if not root_passing:
|
||||
raise FlowNonApplicableException(root_passing_messages)
|
||||
cached_plan = cache.get(cache_key(self.flow, request.user), None)
|
||||
if cached_plan and self.use_cache:
|
||||
LOGGER.debug("f(plan): Taking plan from cache", flow=self.flow)
|
||||
return cached_plan
|
||||
start_time = time()
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex)
|
||||
# Check Flow policies
|
||||
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("f(plan): Stage passing", stage=stage, flow=self.flow)
|
||||
plan.stages.append(stage)
|
||||
end_time = time()
|
||||
LOGGER.debug(
|
||||
"f(plan): Finished planning",
|
||||
flow=self.flow,
|
||||
duration_s=end_time - start_time,
|
||||
)
|
||||
cache.set(cache_key(self.flow, request.user), plan)
|
||||
if not plan.stages:
|
||||
raise EmptyFlowException()
|
||||
return plan
|
|
@ -0,0 +1,33 @@
|
|||
"""passbook stage Base view"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from passbook.flows.views import FlowExecutorView
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
|
||||
class AuthenticationStage(TemplateView):
|
||||
"""Abstract Authentication stage, inherits TemplateView but can be combined with FormView"""
|
||||
|
||||
form: ModelForm = None
|
||||
|
||||
executor: FlowExecutorView
|
||||
|
||||
request: HttpRequest = None
|
||||
template_name = "login/form_with_user.html"
|
||||
|
||||
def __init__(self, executor: FlowExecutorView):
|
||||
self.executor = executor
|
||||
|
||||
def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||
kwargs["config"] = CONFIG.y("passbook")
|
||||
kwargs["title"] = self.executor.flow.name
|
||||
kwargs["primary_action"] = _("Log in")
|
||||
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
|
||||
kwargs["user"] = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||
return super().get_context_data(**kwargs)
|
|
@ -0,0 +1,25 @@
|
|||
"""miscellaneous flow tests"""
|
||||
from django.test import TestCase
|
||||
|
||||
from passbook.flows.api import StageSerializer, StageViewSet
|
||||
from passbook.flows.models import Stage
|
||||
from passbook.stages.dummy.models import DummyStage
|
||||
|
||||
|
||||
class TestFlowsMisc(TestCase):
|
||||
"""miscellaneous tests"""
|
||||
|
||||
def test_models(self):
|
||||
"""Test that ui_user_settings returns none"""
|
||||
self.assertIsNone(Stage().ui_user_settings)
|
||||
|
||||
def test_api_serializer(self):
|
||||
"""Test that stage serializer returns the correct type"""
|
||||
obj = DummyStage()
|
||||
self.assertEqual(StageSerializer().get_type(obj), "dummy")
|
||||
self.assertEqual(StageSerializer().get_verbose_name(obj), "Dummy Stage")
|
||||
|
||||
def test_api_viewset(self):
|
||||
"""Test that stage serializer returns the correct type"""
|
||||
dummy = DummyStage.objects.create()
|
||||
self.assertIn(dummy, StageViewSet().get_queryset())
|
|
@ -0,0 +1,82 @@
|
|||
"""flow planner tests"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from django.test import RequestFactory, TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||
from passbook.flows.planner import FlowPlanner
|
||||
from passbook.stages.dummy.models import DummyStage
|
||||
|
||||
POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],))
|
||||
TIME_NOW_MOCK = MagicMock(return_value=3)
|
||||
|
||||
|
||||
class TestFlowPlanner(TestCase):
|
||||
"""Test planner logic"""
|
||||
|
||||
def setUp(self):
|
||||
self.request_factory = RequestFactory()
|
||||
|
||||
def test_empty_plan(self):
|
||||
"""Test that empty plan raises exception"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-empty",
|
||||
slug="test-empty",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
request = self.request_factory.get(
|
||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
request.user = get_anonymous_user()
|
||||
|
||||
with self.assertRaises(EmptyFlowException):
|
||||
planner = FlowPlanner(flow)
|
||||
planner.plan(request)
|
||||
|
||||
@patch(
|
||||
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
|
||||
POLICY_RESULT_MOCK,
|
||||
)
|
||||
def test_non_applicable_plan(self):
|
||||
"""Test that empty plan raises exception"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-empty",
|
||||
slug="test-empty",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
request = self.request_factory.get(
|
||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
request.user = get_anonymous_user()
|
||||
|
||||
with self.assertRaises(FlowNonApplicableException):
|
||||
planner = FlowPlanner(flow)
|
||||
planner.plan(request)
|
||||
|
||||
@patch("passbook.flows.planner.time", TIME_NOW_MOCK)
|
||||
def test_planner_cache(self):
|
||||
"""Test planner cache"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-cache",
|
||||
slug="test-cache",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
FlowStageBinding.objects.create(
|
||||
flow=flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
||||
)
|
||||
request = self.request_factory.get(
|
||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
request.user = get_anonymous_user()
|
||||
|
||||
planner = FlowPlanner(flow)
|
||||
planner.plan(request)
|
||||
self.assertEqual(TIME_NOW_MOCK.call_count, 2) # Start and end
|
||||
planner = FlowPlanner(flow)
|
||||
planner.plan(request)
|
||||
self.assertEqual(
|
||||
TIME_NOW_MOCK.call_count, 2
|
||||
) # When taking from cache, time is not measured
|
|
@ -0,0 +1,144 @@
|
|||
"""flow views tests"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||
from passbook.flows.planner import FlowPlan
|
||||
from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook.stages.dummy.models import DummyStage
|
||||
|
||||
POLICY_RESULT_MOCK = MagicMock(return_value=(False, [""],))
|
||||
|
||||
|
||||
class TestFlowExecutor(TestCase):
|
||||
"""Test views logic"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
|
||||
def test_invalid_domain(self):
|
||||
"""Check that an invalid domain triggers the correct message"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-empty",
|
||||
slug="test-empty",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
wrong_domain = CONFIG.y("domain") + "-invalid:8000"
|
||||
response = self.client.get(
|
||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
HTTP_HOST=wrong_domain,
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("match", response.rendered_content)
|
||||
self.assertIn(CONFIG.y("domain"), response.rendered_content)
|
||||
self.assertIn(wrong_domain.split(":")[0], response.rendered_content)
|
||||
|
||||
def test_existing_plan_diff_flow(self):
|
||||
"""Check that a plan for a different flow cancels the current plan"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-existing-plan-diff",
|
||||
slug="test-existing-plan-diff",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
stage = DummyStage.objects.create(name="dummy")
|
||||
plan = FlowPlan(flow_pk=flow.pk.hex + "a", stages=[stage])
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
cancel_mock = MagicMock()
|
||||
with patch("passbook.flows.views.FlowExecutorView.cancel", cancel_mock):
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(cancel_mock.call_count, 1)
|
||||
|
||||
@patch(
|
||||
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
|
||||
POLICY_RESULT_MOCK,
|
||||
)
|
||||
def test_invalid_non_applicable_flow(self):
|
||||
"""Tests that a non-applicable flow returns the correct error message"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-non-applicable",
|
||||
slug="test-non-applicable",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
CONFIG.update_from_dict({"domain": "testserver"})
|
||||
response = self.client.get(
|
||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content)
|
||||
|
||||
def test_invalid_empty_flow(self):
|
||||
"""Tests that an empty flow returns the correct error message"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-empty",
|
||||
slug="test-empty",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
CONFIG.update_from_dict({"domain": "testserver"})
|
||||
response = self.client.get(
|
||||
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content)
|
||||
|
||||
def test_invalid_flow_redirect(self):
|
||||
"""Tests that an invalid flow still redirects"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-empty",
|
||||
slug="test-empty",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
CONFIG.update_from_dict({"domain": "testserver"})
|
||||
dest = "/unique-string"
|
||||
url = reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, dest)
|
||||
|
||||
def test_multi_stage_flow(self):
|
||||
"""Test a full flow with multiple stages"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-full",
|
||||
slug="test-full",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
FlowStageBinding.objects.create(
|
||||
flow=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||
)
|
||||
FlowStageBinding.objects.create(
|
||||
flow=flow, stage=DummyStage.objects.create(name="dummy2"), order=1
|
||||
)
|
||||
|
||||
exec_url = reverse(
|
||||
"passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
|
||||
)
|
||||
# First Request, start planning, renders form
|
||||
response = self.client.get(exec_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Check that two stages are in plan
|
||||
session = self.client.session
|
||||
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
||||
self.assertEqual(len(plan.stages), 2)
|
||||
# Second request, submit form, one stage left
|
||||
response = self.client.post(exec_url)
|
||||
# Second request redirects to the same URL
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, exec_url)
|
||||
# Check that two stages are in plan
|
||||
session = self.client.session
|
||||
plan: FlowPlan = session[SESSION_KEY_PLAN]
|
||||
self.assertEqual(len(plan.stages), 1)
|
|
@ -0,0 +1,39 @@
|
|||
"""flow views tests"""
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from passbook.flows.models import Flow, FlowDesignation
|
||||
from passbook.flows.planner import FlowPlan
|
||||
from passbook.flows.views import SESSION_KEY_PLAN
|
||||
|
||||
|
||||
class TestHelperView(TestCase):
|
||||
"""Test helper views logic"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
|
||||
def test_default_view(self):
|
||||
"""Test that ToDefaultFlow returns the expected URL"""
|
||||
flow = Flow.objects.filter(designation=FlowDesignation.INVALIDATION,).first()
|
||||
response = self.client.get(reverse("passbook_flows:default-invalidation"),)
|
||||
expected_url = reverse(
|
||||
"passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, expected_url)
|
||||
|
||||
def test_default_view_invalid_plan(self):
|
||||
"""Test that ToDefaultFlow returns the expected URL (with an invalid plan)"""
|
||||
flow = Flow.objects.filter(designation=FlowDesignation.INVALIDATION,).first()
|
||||
plan = FlowPlan(flow_pk=flow.pk.hex + "aa", stages=[])
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
response = self.client.get(reverse("passbook_flows:default-invalidation"),)
|
||||
expected_url = reverse(
|
||||
"passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, expected_url)
|
|
@ -0,0 +1,44 @@
|
|||
"""flow urls"""
|
||||
from django.urls import path
|
||||
|
||||
from passbook.flows.models import FlowDesignation
|
||||
from passbook.flows.views import (
|
||||
FlowExecutorView,
|
||||
FlowPermissionDeniedView,
|
||||
ToDefaultFlow,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("-/denied/", FlowPermissionDeniedView.as_view(), name="denied"),
|
||||
path(
|
||||
"-/default/authentication/",
|
||||
ToDefaultFlow.as_view(designation=FlowDesignation.AUTHENTICATION),
|
||||
name="default-authentication",
|
||||
),
|
||||
path(
|
||||
"-/default/invalidation/",
|
||||
ToDefaultFlow.as_view(designation=FlowDesignation.INVALIDATION),
|
||||
name="default-invalidation",
|
||||
),
|
||||
path(
|
||||
"-/default/recovery/",
|
||||
ToDefaultFlow.as_view(designation=FlowDesignation.RECOVERY),
|
||||
name="default-recovery",
|
||||
),
|
||||
path(
|
||||
"-/default/enrollment/",
|
||||
ToDefaultFlow.as_view(designation=FlowDesignation.ENROLLMENT),
|
||||
name="default-enrollment",
|
||||
),
|
||||
path(
|
||||
"-/default/unenrollment/",
|
||||
ToDefaultFlow.as_view(designation=FlowDesignation.UNRENOLLMENT),
|
||||
name="default-unenrollment",
|
||||
),
|
||||
path(
|
||||
"-/default/password_change/",
|
||||
ToDefaultFlow.as_view(designation=FlowDesignation.PASSWORD_CHANGE),
|
||||
name="default-password-change",
|
||||
),
|
||||
path("<slug:flow_slug>/", FlowExecutorView.as_view(), name="flow-executor"),
|
||||
]
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue