sources/authenticator_webauthn: rewrite to webcomponent

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-03-24 17:49:01 +01:00
parent ffd8c59c8e
commit ab5d6dbea1
8 changed files with 120 additions and 111 deletions

View File

@ -94,7 +94,7 @@ class SourceViewSet(
if not policy_engine.passing: if not policy_engine.passing:
continue continue
source_settings = source.ui_user_settings source_settings = source.ui_user_settings
source_settings["object_uid"] = str(source.pk) source_settings.initial_data["object_uid"] = str(source.pk)
if not source_settings.is_valid(): if not source_settings.is_valid():
LOGGER.warning(source_settings.errors) LOGGER.warning(source_settings.errors)
matching_sources.append(source_settings.validated_data) matching_sources.append(source_settings.validated_data)

View File

@ -1,17 +1,16 @@
"""WebAuthn stage""" """WebAuthn stage"""
from authentik.core.types import UserSettingSerializer
from typing import Optional, Type from typing import Optional, Type
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.forms import ModelForm from django.forms import ModelForm
from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from django_otp.models import Device from django_otp.models import Device
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer
from authentik.flows.challenge import Challenge, ChallengeTypes
from authentik.flows.models import ConfigurableStage, Stage from authentik.flows.models import ConfigurableStage, Stage
@ -43,15 +42,11 @@ class AuthenticateWebAuthnStage(ConfigurableStage, Stage):
return AuthenticateWebAuthnStageForm return AuthenticateWebAuthnStageForm
@property @property
def ui_user_settings(self) -> Optional[Challenge]: def ui_user_settings(self) -> Optional[UserSettingSerializer]:
return Challenge( return UserSettingSerializer(
data={ data={
"type": ChallengeTypes.shell.value,
"title": str(self._meta.verbose_name), "title": str(self._meta.verbose_name),
"component": reverse( "component": "ak-user-settings-authenticator-webauthn",
"authentik_stages_authenticator_webauthn:user-settings",
kwargs={"stage_uuid": self.stage_uuid},
),
} }
) )

View File

@ -1,47 +0,0 @@
{% load i18n %}
{% load humanize %}
<div class="pf-c-card">
<div class="pf-c-card__title">
{% trans "WebAuthn Devices" %}
</div>
<div class="pf-c-card__body">
<ul class="pf-c-data-list" role="list">
{% for device in devices %}
<li class="pf-c-data-list__item" aria-labelledby="data-list-basic-item-1">
<div class="pf-c-data-list__item-row">
<div class="pf-c-data-list__item-content">
<div class="pf-c-data-list__cell">{{ device.name|default:"-" }}</div>
<div class="pf-c-data-list__cell">
{% blocktrans with created_on=device.created_on|naturaltime %}
Created {{ created_on }}
{% endblocktrans %}
</div>
<div class="pf-c-data-list__cell">
<ak-modal-button href="{% url 'authentik_stages_authenticator_webauthn:device-update' pk=device.pk %}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
{% trans 'Update' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-modal-button href="{% url 'authentik_stages_authenticator_webauthn:device-delete' pk=device.pk %}">
<ak-spinner-button slot="trigger" class="pf-m-danger">
{% trans 'Delete' %}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</div>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
<div class="pf-c-card__footer">
{% if stage.configure_flow %}
<a href="{% url 'authentik_flows:configure' stage_uuid=stage.stage_uuid %}?next=/%23user"
class="ak-root-link pf-c-button pf-m-primary">{% trans "Configure WebAuthn" %}
</a>
{% endif %}
</div>
</div>

View File

@ -2,15 +2,9 @@
from django.urls import path from django.urls import path
from authentik.stages.authenticator_webauthn.views import ( from authentik.stages.authenticator_webauthn.views import (
DeviceDeleteView,
DeviceUpdateView, DeviceUpdateView,
UserSettingsView,
) )
urlpatterns = [ urlpatterns = [
path(
"<uuid:stage_uuid>/settings/", UserSettingsView.as_view(), name="user-settings"
),
path("devices/<int:pk>/delete/", DeviceDeleteView.as_view(), name="device-delete"),
path("devices/<int:pk>/update/", DeviceUpdateView.as_view(), name="device-update"), path("devices/<int:pk>/update/", DeviceUpdateView.as_view(), name="device-update"),
] ]

View File

@ -2,33 +2,14 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.http.response import Http404 from django.http.response import Http404
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import TemplateView, UpdateView from django.views.generic import UpdateView
from authentik.admin.views.utils import DeleteMessageView
from authentik.stages.authenticator_webauthn.forms import DeviceEditForm from authentik.stages.authenticator_webauthn.forms import DeviceEditForm
from authentik.stages.authenticator_webauthn.models import ( from authentik.stages.authenticator_webauthn.models import (
AuthenticateWebAuthnStage,
WebAuthnDevice, WebAuthnDevice,
) )
class UserSettingsView(LoginRequiredMixin, TemplateView):
"""View for user settings to control WebAuthn devices"""
template_name = "stages/authenticator_webauthn/user_settings.html"
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["devices"] = WebAuthnDevice.objects.filter(user=self.request.user)
stage = get_object_or_404(
AuthenticateWebAuthnStage, pk=self.kwargs["stage_uuid"]
)
kwargs["stage"] = stage
return kwargs
class DeviceUpdateView(SuccessMessageMixin, LoginRequiredMixin, UpdateView): class DeviceUpdateView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
"""Update device""" """Update device"""
@ -43,18 +24,3 @@ class DeviceUpdateView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
if device.user != self.request.user: if device.user != self.request.user:
raise Http404 raise Http404
return device return device
class DeviceDeleteView(LoginRequiredMixin, DeleteMessageView):
"""Delete device"""
model = WebAuthnDevice
template_name = "generic/delete.html"
success_url = "/"
success_message = _("Successfully deleted Device")
def get_object(self) -> WebAuthnDevice:
device: WebAuthnDevice = super().get_object()
if device.user != self.request.user:
raise Http404
return device

View File

@ -83,6 +83,10 @@ export class UserURLManager {
return `/-/user/tokens/${rest}`; return `/-/user/tokens/${rest}`;
} }
static authenticatorWebauthn(rest: string): string {
return `/-/user/authenticator/webauthn/${rest}`;
}
} }
export class AppURLManager { export class AppURLManager {
@ -95,3 +99,11 @@ export class AppURLManager {
} }
} }
export class FlowURLManager {
static configure(stageUuid: string, rest: string): string {
return `-/configure/${stageUuid}/${rest}`;
}
}

View File

@ -13,13 +13,14 @@ import AKGlobal from "../../authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import { SourcesApi, StagesApi } from "authentik-api"; import { SourcesApi, StagesApi, UserSetting } from "authentik-api";
import { DEFAULT_CONFIG } from "../../api/Config"; import { DEFAULT_CONFIG } from "../../api/Config";
import { until } from "lit-html/directives/until"; import { until } from "lit-html/directives/until";
import { ifDefined } from "lit-html/directives/if-defined";
import "../../elements/Tabs"; import "../../elements/Tabs";
import "../tokens/UserTokenList"; import "../tokens/UserTokenList";
import "../generic/SiteShell"; import "../generic/SiteShell";
import { ifDefined } from "lit-html/directives/if-defined"; import "./settings/AuthenticatorWebAuthnDevices";
@customElement("ak-user-settings") @customElement("ak-user-settings")
export class UserSettingsPage extends LitElement { export class UserSettingsPage extends LitElement {
@ -28,6 +29,22 @@ export class UserSettingsPage extends LitElement {
return [PFBase, PFPage, PFFlex, PFDisplay, PFGallery, PFContent, PFCard, PFDescriptionList, PFSizing, PFForm, PFFormControl, AKGlobal]; return [PFBase, PFPage, PFFlex, PFDisplay, PFGallery, PFContent, PFCard, PFDescriptionList, PFSizing, PFForm, PFFormControl, AKGlobal];
} }
renderStageSettings(stage: UserSetting): TemplateResult {
switch (stage.component) {
case "ak-user-settings-authenticator-webauthn":
return html`<ak-user-settings-authenticator-webauthn stageId=${stage.objectUid}>
</ak-user-settings-authenticator-webauthn>`;
default:
return html`<div class="pf-u-display-flex pf-u-justify-content-center">
<div class="pf-u-w-75">
<ak-site-shell url="${ifDefined(stage.component)}">
<div slot="body"></div>
</ak-site-shell>
</div>
</div>`;
}
}
render(): TemplateResult { render(): TemplateResult {
return html`<div class="pf-c-page"> return html`<div class="pf-c-page">
<main role="main" class="pf-c-page__main" tabindex="-1"> <main role="main" class="pf-c-page__main" tabindex="-1">
@ -55,22 +72,15 @@ export class UserSettingsPage extends LitElement {
</section> </section>
${until(new StagesApi(DEFAULT_CONFIG).stagesAllUserSettings({}).then((stages) => { ${until(new StagesApi(DEFAULT_CONFIG).stagesAllUserSettings({}).then((stages) => {
return stages.map((stage) => { return stages.map((stage) => {
// TODO: Check for non-shell stages return html`<section slot="page-${stage.objectUid}" data-tab-title="${ifDefined(stage.title)}" class="pf-c-page__main-section pf-m-no-padding-mobile">
return html`<section slot="page-${stage.title}" data-tab-title="${ifDefined(stage.title)}" class="pf-c-page__main-section pf-m-no-padding-mobile"> ${this.renderStageSettings(stage)}
<div class="pf-u-display-flex pf-u-justify-content-center">
<div class="pf-u-w-75">
<ak-site-shell url="${ifDefined(stage.component)}">
<div slot="body"></div>
</ak-site-shell>
</div>
</div>
</section>`; </section>`;
}); });
}))} }))}
${until(new SourcesApi(DEFAULT_CONFIG).sourcesAllUserSettings({}).then((sources) => { ${until(new SourcesApi(DEFAULT_CONFIG).sourcesAllUserSettings({}).then((sources) => {
return sources.map((source) => { return sources.map((source) => {
// TODO: Check for non-shell sources // TODO: Check for non-shell sources
return html`<section slot="page-${source.title}" data-tab-title="${ifDefined(source.title)}" class="pf-c-page__main-section pf-m-no-padding-mobile"> return html`<section slot="page-${source.objectUid}" data-tab-title="${ifDefined(source.title)}" class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-u-display-flex pf-u-justify-content-center"> <div class="pf-u-display-flex pf-u-justify-content-center">
<div class="pf-u-w-75"> <div class="pf-u-w-75">
<ak-site-shell url="${ifDefined(source.component)}"> <ak-site-shell url="${ifDefined(source.component)}">

View File

@ -0,0 +1,79 @@
import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDataList from "@patternfly/patternfly/components/DataList/data-list.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import AKGlobal from "../../../authentik.css";
import { gettext } from "django";
import { AuthenticatorsApi, StagesApi } from "authentik-api";
import { until } from "lit-html/directives/until";
import { FlowURLManager, UserURLManager } from "../../../api/legacy";
import { DEFAULT_CONFIG } from "../../../api/Config";
@customElement("ak-user-settings-authenticator-webauthn")
export class UserSettingsAuthenticatorWebAuthnDevices extends LitElement {
@property()
stageId!: string;
static get styles(): CSSResult[] {
return [PFBase, PFCard, PFButton, PFDataList, AKGlobal];
}
render(): TemplateResult {
return html`<div class="pf-c-card">
<div class="pf-c-card__title">
${gettext("WebAuthn Devices")}
</div>
<div class="pf-c-card__body">
<ul class="pf-c-data-list" role="list">
${until(new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsWebauthnList({}).then((devices) => {
return devices.results.map((device) => {
return html`<li class="pf-c-data-list__item">
<div class="pf-c-data-list__item-row">
<div class="pf-c-data-list__item-content">
<div class="pf-c-data-list__cell">${device.name || "-"}</div>
<div class="pf-c-data-list__cell">
${gettext(`Created ${device.createdOn?.toLocaleString()}`)}
</div>
<div class="pf-c-data-list__cell">
<ak-modal-button href="${UserURLManager.authenticatorWebauthn(`devices/${device.pk}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
${gettext('Update')}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-forms-delete
.obj=${device}
objectLabel=${gettext("Authenticator")}
.delete=${() => {
return new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsWebauthnDelete({
id: device.pk || 0
});
}}>
<button slot="trigger" class="pf-c-dropdown__menu-item">
${gettext("Delete")}
</button>
</ak-forms-delete>
</div>
</div>
</div>
</li>`;
});
}))}
</ul>
</div>
<div class="pf-c-card__footer">
${until(new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorWebauthnRead({stageUuid: this.stageId}).then((stage) => {
if (stage.configureFlow) {
return html`<a href="${FlowURLManager.configure(stage.pk || "", '?next=/%23user')}"
class="pf-c-button pf-m-primary">${gettext("Configure WebAuthn")}
</a>`;
}
return html``;
}))}
</div>
</div>`;
}
}