Merge pull request #8 from BeryJu/flows-stage1

Flows Stage 1
This commit is contained in:
Jens L 2020-05-14 16:07:22 +02:00 committed by GitHub
commit 8cfd3f9a2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
284 changed files with 12407 additions and 2682 deletions

20
.fossa.yml Executable file
View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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">

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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">

View File

@ -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 %}

View File

@ -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(),

View File

@ -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)

View File

@ -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")

View File

@ -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))

View File

@ -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)

View File

@ -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"""

View File

@ -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)

View File

@ -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"""

View File

@ -1,4 +1,4 @@
"""passbook Factor administration"""
"""passbook Stage administration"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
@ -11,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):

View File

@ -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})

View File

@ -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"),
)

View File

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

View File

@ -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

View File

@ -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")

View File

@ -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(),
}

View File

@ -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")

View File

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

View File

@ -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",),
]

View File

@ -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",),
]

View File

@ -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"""

View File

@ -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>

View File

@ -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 %}

View File

@ -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&rsquo;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>

View File

@ -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>

View File

@ -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>

View File

@ -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 }}

View File

@ -18,16 +18,16 @@
</li>
</ul>
</section>
{% user_factors as user_factors_loc %}
{% if user_factors_loc %}
{% user_stages as user_stages_loc %}
{% if user_stages_loc %}
<section class="pf-c-nav__section">
<h2 class="pf-c-nav__section-title">{% trans 'Factors' %}</h2>
<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 %}

View File

@ -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 %}

View File

@ -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:

View File

@ -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'))

View File

@ -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"))

View File

@ -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"""

View File

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

View File

@ -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"),
]

View File

@ -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")

View File

@ -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)

View File

@ -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)

View File

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

View File

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

View File

@ -1,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

View File

@ -1,35 +0,0 @@
"""passbook captcha factor forms"""
from captcha.fields import ReCaptchaField
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
from passbook.factors.captcha.models import CaptchaFactor
from passbook.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."
)
}

View File

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

View File

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

View File

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

View File

@ -1,12 +0,0 @@
"""passbook multi-factor authentication engine"""
from django.http import HttpRequest
from passbook.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()

View File

@ -1,21 +0,0 @@
"""passbook administration forms"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.factors.dummy.models import DummyFactor
from passbook.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),
}

View File

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

View File

@ -1,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

View File

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

View File

@ -1,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()

View File

@ -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."
)
}

View File

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

View File

@ -1,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()

View File

@ -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")

View File

@ -1,3 +0,0 @@
"""factor forms"""
GENERAL_FIELDS = ["name", "slug", "order", "policies", "enabled"]

View File

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

View File

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

View File

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

View File

@ -1,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 %}

View File

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

View File

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

View File

@ -1,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

View File

@ -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()

View File

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

View File

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

View File

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

View File

@ -1,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")

View File

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

View File

@ -1,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"))

View File

@ -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"""

68
passbook/flows/api.py Normal file
View File

@ -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()

11
passbook/flows/apps.py Normal file
View File

@ -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"

View File

@ -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"""

56
passbook/flows/forms.py Normal file
View File

@ -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),
}

View File

@ -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",
),
),
]

View File

@ -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),
]

View File

@ -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,
),
),
]

View File

@ -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,
),
),
]

View File

@ -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,
),
),
]

101
passbook/flows/models.py Normal file
View File

@ -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"),)

96
passbook/flows/planner.py Normal file
View File

@ -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

33
passbook/flows/stage.py Normal file
View File

@ -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)

View File

@ -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())

View File

@ -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

View File

@ -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)

View File

@ -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)

44
passbook/flows/urls.py Normal file
View File

@ -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