diff --git a/authentik/stages/authenticator_static/apps.py b/authentik/stages/authenticator_static/apps.py index e79ea32db..1edd2b873 100644 --- a/authentik/stages/authenticator_static/apps.py +++ b/authentik/stages/authenticator_static/apps.py @@ -1,4 +1,6 @@ """Authenticator Static stage""" +from importlib import import_module + from django.apps import AppConfig @@ -8,4 +10,6 @@ class AuthentikStageAuthenticatorStaticConfig(AppConfig): name = "authentik.stages.authenticator_static" label = "authentik_stages_authenticator_static" verbose_name = "authentik Stages.Authenticator.Static" - mountpoint = "-/user/authenticator/static/" + + def ready(self): + import_module("authentik.stages.authenticator_static.signals") diff --git a/authentik/stages/authenticator_static/models.py b/authentik/stages/authenticator_static/models.py index 4f8a5d47e..d0497b65a 100644 --- a/authentik/stages/authenticator_static/models.py +++ b/authentik/stages/authenticator_static/models.py @@ -3,12 +3,11 @@ from typing import Optional, Type from django.db import models from django.forms import ModelForm -from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer -from authentik.flows.challenge import Challenge, ChallengeTypes +from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, Stage @@ -42,15 +41,11 @@ class AuthenticatorStaticStage(ConfigurableStage, Stage): return AuthenticatorStaticStageForm @property - def ui_user_settings(self) -> Optional[Challenge]: - return Challenge( + def ui_user_settings(self) -> Optional[UserSettingSerializer]: + return UserSettingSerializer( data={ - "type": ChallengeTypes.shell.value, "title": str(self._meta.verbose_name), - "component": reverse( - "authentik_stages_authenticator_static:user-settings", - kwargs={"stage_uuid": self.stage_uuid}, - ), + "component": "ak-user-settings-authenticator-static", } ) diff --git a/authentik/stages/authenticator_static/signals.py b/authentik/stages/authenticator_static/signals.py new file mode 100644 index 000000000..f8aefba12 --- /dev/null +++ b/authentik/stages/authenticator_static/signals.py @@ -0,0 +1,17 @@ +"""totp authenticator signals""" +from django.db.models.signals import pre_delete +from django.dispatch import receiver +from django_otp.plugins.otp_static.models import StaticDevice + +from authentik.events.models import Event + + +@receiver(pre_delete, sender=StaticDevice) +# pylint: disable=unused-argument +def pre_delete_event(sender, instance: StaticDevice, **_): + # Create event with email notification + event = Event.new( + "static_authenticator_disable", message="User disabled Static OTP Tokens." + ) + event.set_user(instance.user) + event.save() diff --git a/authentik/stages/authenticator_static/templates/stages/authenticator_static/user_settings.html b/authentik/stages/authenticator_static/templates/stages/authenticator_static/user_settings.html deleted file mode 100644 index 81a40e04c..000000000 --- a/authentik/stages/authenticator_static/templates/stages/authenticator_static/user_settings.html +++ /dev/null @@ -1,31 +0,0 @@ -{% load i18n %} - -
-
- {% trans "Static One-Time Passwords" %} -
-
-

- {% blocktrans with state=state|yesno:"Enabled,Disabled" %} - Status: {{ state }} - {% endblocktrans %} - {% if state %} - - {% else %} - - {% endif %} -

- - {% if not state %} - {% if stage.configure_flow %} - {% trans "Enable Static Tokens" %} - {% endif %} - {% else %} - {% trans "Disable Static Tokens" %} - {% endif %} -
-
diff --git a/authentik/stages/authenticator_static/urls.py b/authentik/stages/authenticator_static/urls.py deleted file mode 100644 index 505473312..000000000 --- a/authentik/stages/authenticator_static/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Static Authenticator urls""" -from django.urls import path - -from authentik.stages.authenticator_static.views import DisableView, UserSettingsView - -urlpatterns = [ - path( - "/settings/", UserSettingsView.as_view(), name="user-settings" - ), - path("/disable/", DisableView.as_view(), name="disable"), -] diff --git a/authentik/stages/authenticator_static/views.py b/authentik/stages/authenticator_static/views.py deleted file mode 100644 index ab9775765..000000000 --- a/authentik/stages/authenticator_static/views.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Static Authenticator view Tokens""" -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect -from django.views import View -from django.views.generic import TemplateView -from django_otp.plugins.otp_static.models import StaticDevice, StaticToken - -from authentik.events.models import Event -from authentik.stages.authenticator_static.models import AuthenticatorStaticStage - - -class UserSettingsView(LoginRequiredMixin, TemplateView): - """View for user settings to control OTP""" - - template_name = "stages/authenticator_static/user_settings.html" - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - stage = get_object_or_404( - AuthenticatorStaticStage, pk=self.kwargs["stage_uuid"] - ) - kwargs["stage"] = stage - static_devices = StaticDevice.objects.filter( - user=self.request.user, confirmed=True - ) - kwargs["state"] = static_devices.exists() - if static_devices.exists(): - kwargs["tokens"] = StaticToken.objects.filter(device=static_devices.first()) - return kwargs - - -class DisableView(LoginRequiredMixin, View): - """Disable Static Tokens for user""" - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, **kwargs) -> HttpResponse: - """Delete all the devices for user""" - devices = StaticDevice.objects.filter(user=request.user, confirmed=True) - devices.delete() - messages.success(request, "Successfully disabled Static OTP Tokens") - # Create event with email notification - Event.new( - "static_otp_disable", message="User disabled Static OTP Tokens." - ).from_http(request) - return redirect("/") diff --git a/authentik/stages/authenticator_totp/apps.py b/authentik/stages/authenticator_totp/apps.py index 008351c29..9535e9bd3 100644 --- a/authentik/stages/authenticator_totp/apps.py +++ b/authentik/stages/authenticator_totp/apps.py @@ -1,4 +1,6 @@ -"""OTP Time""" +"""TOTP""" +from importlib import import_module + from django.apps import AppConfig @@ -8,4 +10,6 @@ class AuthentikStageAuthenticatorTOTPConfig(AppConfig): name = "authentik.stages.authenticator_totp" label = "authentik_stages_authenticator_totp" verbose_name = "authentik Stages.Authenticator.TOTP" - mountpoint = "-/user/authenticator/totp/" + + def ready(self): + import_module("authentik.stages.authenticator_totp.signals") diff --git a/authentik/stages/authenticator_totp/models.py b/authentik/stages/authenticator_totp/models.py index dc09de43c..80f5044f4 100644 --- a/authentik/stages/authenticator_totp/models.py +++ b/authentik/stages/authenticator_totp/models.py @@ -3,12 +3,11 @@ from typing import Optional, Type from django.db import models from django.forms import ModelForm -from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views import View from rest_framework.serializers import BaseSerializer -from authentik.flows.challenge import Challenge, ChallengeTypes +from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, Stage @@ -45,15 +44,11 @@ class AuthenticatorTOTPStage(ConfigurableStage, Stage): return AuthenticatorTOTPStageForm @property - def ui_user_settings(self) -> Optional[Challenge]: - return Challenge( + def ui_user_settings(self) -> Optional[UserSettingSerializer]: + return UserSettingSerializer( data={ - "type": ChallengeTypes.shell.value, "title": str(self._meta.verbose_name), - "component": reverse( - "authentik_stages_authenticator_totp:user-settings", - kwargs={"stage_uuid": self.stage_uuid}, - ), + "component": "ak-user-settings-authenticator-totp", } ) diff --git a/authentik/stages/authenticator_totp/signals.py b/authentik/stages/authenticator_totp/signals.py new file mode 100644 index 000000000..971de1adc --- /dev/null +++ b/authentik/stages/authenticator_totp/signals.py @@ -0,0 +1,15 @@ +"""totp authenticator signals""" +from django.db.models.signals import pre_delete +from django.dispatch import receiver +from django_otp.plugins.otp_totp.models import TOTPDevice + +from authentik.events.models import Event + + +@receiver(pre_delete, sender=TOTPDevice) +# pylint: disable=unused-argument +def pre_delete_event(sender, instance: TOTPDevice, **_): + # Create event with email notification + event = Event.new("totp_disable", message="User disabled Time-based OTP.") + event.set_user(instance.user) + event.save() diff --git a/authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html b/authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html deleted file mode 100644 index a64044eb5..000000000 --- a/authentik/stages/authenticator_totp/templates/stages/authenticator_totp/user_settings.html +++ /dev/null @@ -1,28 +0,0 @@ -{% load i18n %} - -
-
- {% trans "Time-based One-Time Passwords" %} -
-
-

- {% blocktrans with state=state|yesno:"Enabled,Disabled" %} - Status: {{ state }} - {% endblocktrans %} - {% if state %} - - {% else %} - - {% endif %} -

-

- {% if not state %} - {% if stage.configure_flow %} - {% trans "Enable Time-based OTP" %} - {% endif %} - {% else %} - {% trans "Disable Time-based OTP" %} - {% endif %} -

-
-
diff --git a/authentik/stages/authenticator_totp/urls.py b/authentik/stages/authenticator_totp/urls.py deleted file mode 100644 index f5ba850e8..000000000 --- a/authentik/stages/authenticator_totp/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -"""OTP Time urls""" -from django.urls import path - -from authentik.stages.authenticator_totp.views import DisableView, UserSettingsView - -urlpatterns = [ - path( - "/settings/", UserSettingsView.as_view(), name="user-settings" - ), - path("/disable/", DisableView.as_view(), name="disable"), -] diff --git a/authentik/stages/authenticator_totp/views.py b/authentik/stages/authenticator_totp/views.py deleted file mode 100644 index a518f3cfd..000000000 --- a/authentik/stages/authenticator_totp/views.py +++ /dev/null @@ -1,42 +0,0 @@ -"""otp time-based view""" -from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect -from django.views import View -from django.views.generic import TemplateView -from django_otp.plugins.otp_totp.models import TOTPDevice - -from authentik.events.models import Event -from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage - - -class UserSettingsView(LoginRequiredMixin, TemplateView): - """View for user settings to control OTP""" - - template_name = "stages/authenticator_totp/user_settings.html" - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - stage = get_object_or_404(AuthenticatorTOTPStage, pk=self.kwargs["stage_uuid"]) - kwargs["stage"] = stage - - totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True) - kwargs["state"] = totp_devices.exists() - return kwargs - - -class DisableView(LoginRequiredMixin, View): - """Disable TOTP for user""" - - # pylint: disable=unused-argument - def get(self, request: HttpRequest, **kwargs) -> HttpResponse: - """Delete all the devices for user""" - totp = TOTPDevice.objects.filter(user=request.user, confirmed=True) - totp.delete() - messages.success(request, "Successfully disabled Time-based OTP") - # Create event with email notification - Event.new("totp_disable", message="User disabled Time-based OTP.").from_http( - request - ) - return redirect("/") diff --git a/web/src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts b/web/src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts index deeee208d..e79237d7d 100644 --- a/web/src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts +++ b/web/src/flows/stages/authenticator_static/AuthenticatorStaticStage.ts @@ -13,6 +13,21 @@ import "../../../elements/forms/FormElement"; import "../../../elements/EmptyState"; import "../../FormStatic"; +export const STATIC_TOKEN_STYLE = css` +/* Static OTP Tokens */ +.ak-otp-tokens { + list-style: circle; + columns: 2; + -webkit-columns: 2; + -moz-columns: 2; + margin-left: var(--pf-global--spacer--xs); +} +.ak-otp-tokens li { + font-size: var(--pf-global--FontSize--2xl); + font-family: monospace; +} +`; + export interface AuthenticatorStaticChallenge extends WithUserInfoChallenge { codes: number[]; } @@ -24,20 +39,7 @@ export class AuthenticatorStaticStage extends BaseStage { challenge?: AuthenticatorStaticChallenge; static get styles(): CSSResult[] { - return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal].concat(css` - /* Static OTP Tokens */ - .ak-otp-tokens { - list-style: circle; - columns: 2; - -webkit-columns: 2; - -moz-columns: 2; - margin-left: var(--pf-global--spacer--xs); - } - .ak-otp-tokens li { - font-size: var(--pf-global--FontSize--2xl); - font-family: monospace; - } - `); + return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal, STATIC_TOKEN_STYLE]; } render(): TemplateResult { diff --git a/web/src/pages/users/UserSettingsPage.ts b/web/src/pages/users/UserSettingsPage.ts index 4c2180a2d..e38c98ab0 100644 --- a/web/src/pages/users/UserSettingsPage.ts +++ b/web/src/pages/users/UserSettingsPage.ts @@ -20,8 +20,10 @@ import { ifDefined } from "lit-html/directives/if-defined"; import "../../elements/Tabs"; import "../tokens/UserTokenList"; import "../generic/SiteShell"; -import "./settings/AuthenticatorWebAuthnDevices"; -import "./settings/Password"; +import "./settings/UserSettingsAuthenticatorTOTP"; +import "./settings/UserSettingsAuthenticatorStatic"; +import "./settings/UserSettingsAuthenticatorWebAuthnDevices"; +import "./settings/UserSettingsPassword"; @customElement("ak-user-settings") export class UserSettingsPage extends LitElement { @@ -38,6 +40,12 @@ export class UserSettingsPage extends LitElement { case "ak-user-settings-password": return html` `; + case "ak-user-settings-authenticator-totp": + return html` + `; + case "ak-user-settings-authenticator-static": + return html` + `; default: return html`
diff --git a/web/src/pages/users/settings/BaseUserSettings.ts b/web/src/pages/users/settings/BaseUserSettings.ts new file mode 100644 index 000000000..a4c6d5b5f --- /dev/null +++ b/web/src/pages/users/settings/BaseUserSettings.ts @@ -0,0 +1,16 @@ +import { CSSResult, LitElement, property } from "lit-element"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import AKGlobal from "../../../authentik.css"; + +export abstract class BaseUserSettings extends LitElement { + + @property() + objectId!: string; + + static get styles(): CSSResult[] { + return [PFBase, PFCard, PFButton, AKGlobal]; + } +} + diff --git a/web/src/pages/users/settings/Password.ts b/web/src/pages/users/settings/Password.ts deleted file mode 100644 index ddc4f7517..000000000 --- a/web/src/pages/users/settings/Password.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 PFButton from "@patternfly/patternfly/components/Button/button.css"; -import AKGlobal from "../../../authentik.css"; -import { gettext } from "django"; -import { FlowURLManager } from "../../../api/legacy"; - -@customElement("ak-user-settings-password") -export class UserSettingsPassword extends LitElement { - - @property() - stageId!: string; - - static get styles(): CSSResult[] { - return [PFBase, PFCard, PFButton, AKGlobal]; - } - - render(): TemplateResult { - // For this stage we don't need to check for a configureFlow, - // as the stage won't return any UI Elements if no configureFlow is set. - return html`
-
- ${gettext('Change your password')} -
- -
`; - } - -} diff --git a/web/src/pages/users/settings/UserSettingsAuthenticatorStatic.ts b/web/src/pages/users/settings/UserSettingsAuthenticatorStatic.ts new file mode 100644 index 000000000..cc92d121b --- /dev/null +++ b/web/src/pages/users/settings/UserSettingsAuthenticatorStatic.ts @@ -0,0 +1,69 @@ +import { AuthenticatorsApi, StagesApi } from "authentik-api"; +import { gettext } from "django"; +import { customElement, html, TemplateResult } from "lit-element"; +import { until } from "lit-html/directives/until"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { FlowURLManager } from "../../../api/legacy"; +import { BaseUserSettings } from "./BaseUserSettings"; + +@customElement("ak-user-settings-authenticator-static") +export class UserSettingsAuthenticatorStatic extends BaseUserSettings { + + renderEnabled(): TemplateResult { + return html`
+

+ ${gettext("Status: Enabled")} + +

+
+ `; + } + + renderDisabled(): TemplateResult { + return html` +
+

+ ${gettext("Status: Disabled")} + +

+
+ `; + } + + render(): TemplateResult { + return html`
+
+ ${gettext("Time-based One-Time Passwords")} +
+ ${until(new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsTotpList({}).then((devices) => { + return devices.results.length > 0 ? this.renderEnabled() : this.renderDisabled(); + }))} +
`; + } + +} diff --git a/web/src/pages/users/settings/UserSettingsAuthenticatorTOTP.ts b/web/src/pages/users/settings/UserSettingsAuthenticatorTOTP.ts new file mode 100644 index 000000000..07acc82b6 --- /dev/null +++ b/web/src/pages/users/settings/UserSettingsAuthenticatorTOTP.ts @@ -0,0 +1,84 @@ +import { AuthenticatorsApi, StagesApi } from "authentik-api"; +import { gettext } from "django"; +import { CSSResult, customElement, html, TemplateResult } from "lit-element"; +import { until } from "lit-html/directives/until"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { FlowURLManager } from "../../../api/legacy"; +import { STATIC_TOKEN_STYLE } from "../../../flows/stages/authenticator_static/AuthenticatorStaticStage"; +import { BaseUserSettings } from "./BaseUserSettings"; + +@customElement("ak-user-settings-authenticator-totp") +export class UserSettingsAuthenticatorTOTP extends BaseUserSettings { + + static get styles(): CSSResult[] { + return super.styles.concat(STATIC_TOKEN_STYLE); + } + + renderEnabled(): TemplateResult { + return html`
+

+ ${gettext("Status: Enabled")} + +

+
    + ${until(new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsStaticList({}).then((devices) => { + if (devices.results.length < 1) { + return; + } + return devices.results[0].tokenSet?.map((token) => { + return html`
  • ${token.token}
  • `; + }); + }))} +
+
+ `; + } + + renderDisabled(): TemplateResult { + return html` +
+

+ ${gettext("Status: Disabled")} + +

+
+ `; + } + + render(): TemplateResult { + return html`
+
+ ${gettext("Static Tokens")} +
+ ${until(new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsTotpList({}).then((devices) => { + return devices.results.length > 0 ? this.renderEnabled() : this.renderDisabled(); + }))} +
`; + } + +} diff --git a/web/src/pages/users/settings/AuthenticatorWebAuthnDevices.ts b/web/src/pages/users/settings/UserSettingsAuthenticatorWebAuthnDevices.ts similarity index 80% rename from web/src/pages/users/settings/AuthenticatorWebAuthnDevices.ts rename to web/src/pages/users/settings/UserSettingsAuthenticatorWebAuthnDevices.ts index a47704a6f..e4a306103 100644 --- a/web/src/pages/users/settings/AuthenticatorWebAuthnDevices.ts +++ b/web/src/pages/users/settings/UserSettingsAuthenticatorWebAuthnDevices.ts @@ -1,24 +1,16 @@ -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 { customElement, html, TemplateResult } from "lit-element"; 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"; +import { BaseUserSettings } from "./BaseUserSettings"; +import "../../../elements/buttons/ModalButton"; +import "../../../elements/buttons/SpinnerButton"; +import "../../../elements/forms/DeleteForm"; @customElement("ak-user-settings-authenticator-webauthn") -export class UserSettingsAuthenticatorWebAuthnDevices extends LitElement { - - @property() - stageId!: string; - - static get styles(): CSSResult[] { - return [PFBase, PFCard, PFButton, PFDataList, AKGlobal]; - } +export class UserSettingsAuthenticatorWebAuthnDevices extends BaseUserSettings { render(): TemplateResult { return html`
@@ -39,7 +31,7 @@ export class UserSettingsAuthenticatorWebAuthnDevices extends LitElement {
- ${gettext('Update')} + ${gettext("Update")}
@@ -64,9 +56,9 @@ export class UserSettingsAuthenticatorWebAuthnDevices extends LitElement {