From c1e6786ea11eab0c19f4937a4aaa910afcf85cd0 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 21 Feb 2021 00:14:18 +0100 Subject: [PATCH] stages/password: Migrate to SPA --- authentik/stages/identification/stage.py | 6 +- authentik/stages/password/forms.py | 18 ----- authentik/stages/password/stage.py | 65 +++++++++++------ swagger.yaml | 7 -- web/src/elements/stages/base.ts | 7 +- .../identification/IdentificationStage.ts | 6 -- .../elements/stages/password/PasswordStage.ts | 69 +++++++++++++++++++ web/src/pages/generic/FlowExecutor.ts | 4 ++ 8 files changed, 124 insertions(+), 58 deletions(-) create mode 100644 web/src/elements/stages/password/PasswordStage.ts diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index bb7406a9e..4dbdfe138 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -118,12 +118,12 @@ class IdentificationStageView(ChallengeStageView): return challenge def challenge_valid( - self, challenge: IdentificationChallengeResponse + self, response: IdentificationChallengeResponse ) -> HttpResponse: - self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = challenge.pre_user + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = response.pre_user current_stage: IdentificationStage = self.executor.current_stage if not current_stage.show_matched_user: self.executor.plan.context[ PLAN_CONTEXT_PENDING_USER_IDENTIFIER - ] = challenge.validated_data.get("uid_field") + ] = response.validated_data.get("uid_field") return self.executor.stage_ok() diff --git a/authentik/stages/password/forms.py b/authentik/stages/password/forms.py index b29591c2d..5fba58db9 100644 --- a/authentik/stages/password/forms.py +++ b/authentik/stages/password/forms.py @@ -20,24 +20,6 @@ def get_authentication_backends(): ] -class PasswordForm(forms.Form): - """Password authentication form""" - - username = forms.CharField( - widget=forms.HiddenInput(attrs={"autocomplete": "username"}), required=False - ) - password = forms.CharField( - label=_("Please enter your password."), - widget=forms.PasswordInput( - attrs={ - "placeholder": _("Password"), - "autofocus": "autofocus", - "autocomplete": "current-password", - } - ), - ) - - class PasswordStageForm(forms.ModelForm): """Form to create/edit Password Stages""" diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py index 10cdd3e68..7995e6cb6 100644 --- a/authentik/stages/password/stage.py +++ b/authentik/stages/password/stage.py @@ -6,16 +6,20 @@ from django.contrib.auth.backends import BaseBackend from django.contrib.auth.signals import user_login_failed from django.core.exceptions import PermissionDenied from django.http import HttpRequest, HttpResponse +from django.urls import reverse from django.utils.translation import gettext as _ -from django.views.generic import FormView +from rest_framework.exceptions import ErrorDetail +from rest_framework.fields import CharField +from rest_framework.serializers import ValidationError from structlog.stdlib import get_logger from authentik.core.models import User +from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.models import Flow, FlowDesignation from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER -from authentik.flows.stage import StageView +from authentik.flows.stage import ChallengeStageView from authentik.lib.utils.reflection import path_to_class -from authentik.stages.password.forms import PasswordForm +from authentik.lib.templatetags.authentik_utils import avatar from authentik.stages.password.models import PasswordStage LOGGER = get_logger() @@ -51,32 +55,48 @@ def authenticate( ) -class PasswordStageView(FormView, StageView): +class PasswordChallenge(Challenge): + """Password challenge UI fields""" + + pending_user = CharField() + pending_user_avatar = CharField() + recovery_url = CharField(required=False) + + +class PasswordChallengeResponse(ChallengeResponse): + """Password challenge response""" + + password = CharField() + +class PasswordStageView(ChallengeStageView): """Authentication stage which authenticates against django's AuthBackend""" - form_class = PasswordForm - template_name = "stages/password/flow-form.html" - - def get_form(self, form_class=None) -> PasswordForm: - form = super().get_form(form_class=form_class) + response_class = PasswordChallengeResponse + def get_challenge(self) -> Challenge: + challenge = PasswordChallenge( + data={ + "type": ChallengeTypes.native, + "component": "ak-stage-password", + } + ) # If there's a pending user, update the `username` field # this field is only used by password managers. # If there's no user set, an error is raised later. if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] - form.fields["username"].initial = pending_user.username + challenge.initial_data["pending_user"] = pending_user.username + challenge.initial_data["pending_user_avatar"] = avatar(pending_user) - return form - - def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY) if recovery_flow.exists(): - kwargs["recovery_flow"] = recovery_flow.first() - return kwargs + challenge.initial_data["recovery_url"] = reverse( + "authentik_flows:flow-executor-shell", + kwargs={"flow_slug": recovery_flow.first().slug}, + ) + return challenge - def form_invalid(self, form: PasswordForm) -> HttpResponse: + def challenge_invalid(self, response: PasswordChallengeResponse) -> HttpResponse: if SESSION_INVALID_TRIES not in self.request.session: self.request.session[SESSION_INVALID_TRIES] = 0 self.request.session[SESSION_INVALID_TRIES] += 1 @@ -88,9 +108,9 @@ class PasswordStageView(FormView, StageView): LOGGER.debug("User has exceeded maximum tries") del self.request.session[SESSION_INVALID_TRIES] return self.executor.stage_invalid() - return super().form_invalid(form) + return super().challenge_invalid(response) - def form_valid(self, form: PasswordForm) -> HttpResponse: + def challenge_valid(self, response: PasswordChallengeResponse) -> HttpResponse: """Authenticate against django's authentication backend""" if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: return self.executor.stage_invalid() @@ -98,7 +118,7 @@ class PasswordStageView(FormView, StageView): # an Identifier by most authentication backends pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] auth_kwargs = { - "password": form.cleaned_data.get("password", None), + "password": response.validated_data.get("password", None), "username": pending_user.username, } try: @@ -115,8 +135,9 @@ class PasswordStageView(FormView, StageView): # No user was found -> invalid credentials LOGGER.debug("Invalid credentials") # Manually inject error into form - form.add_error("password", _("Invalid password")) - return self.form_invalid(form) + response._errors.setdefault("password", []) + response._errors["password"].append(ErrorDetail(_("Invalid password"), "invalid")) + return self.challenge_invalid(response) # User instance returned from authenticate() has .backend property set self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user self.executor.plan.context[ diff --git a/swagger.yaml b/swagger.yaml index 0aba5c0e2..b2942e888 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -11297,7 +11297,6 @@ definitions: required: - name - user_fields - - template type: object properties: pk: @@ -11345,12 +11344,6 @@ definitions: is enabled, the user's username and avatar will be shown. Otherwise, the text that the user entered will be shown type: boolean - template: - title: Template - type: string - enum: - - stages/identification/login.html - - stages/identification/recovery.html enrollment_flow: title: Enrollment flow description: Optional enrollment flow, which is linked at the bottom of the diff --git a/web/src/elements/stages/base.ts b/web/src/elements/stages/base.ts index d2cf89668..fe7ec20e0 100644 --- a/web/src/elements/stages/base.ts +++ b/web/src/elements/stages/base.ts @@ -3,8 +3,11 @@ import { FlowExecutor } from "../../pages/generic/FlowExecutor"; export class BaseStage extends LitElement { - // submit() - host?: FlowExecutor; + submit(e: Event): void { + e.preventDefault(); + const form = new FormData(this.shadowRoot?.querySelector("form") || undefined); + this.host?.submit(form); + } } diff --git a/web/src/elements/stages/identification/IdentificationStage.ts b/web/src/elements/stages/identification/IdentificationStage.ts index 1f7fb7ace..06c0f3fa8 100644 --- a/web/src/elements/stages/identification/IdentificationStage.ts +++ b/web/src/elements/stages/identification/IdentificationStage.ts @@ -64,12 +64,6 @@ export class IdentificationStage extends BaseStage { return COMMON_STYLES; } - submit(e: Event): void { - e.preventDefault(); - const form = new FormData(this.shadowRoot?.querySelector("form") || undefined); - this.host?.submit(form); - } - renderSource(source: UILoginButton): TemplateResult { let icon = html``; if (source.icon_url) { diff --git a/web/src/elements/stages/password/PasswordStage.ts b/web/src/elements/stages/password/PasswordStage.ts new file mode 100644 index 000000000..e0e218d64 --- /dev/null +++ b/web/src/elements/stages/password/PasswordStage.ts @@ -0,0 +1,69 @@ +import { gettext } from "django"; +import { CSSResult, customElement, html, property, TemplateResult } from "lit-element"; +import { Challenge } from "../../../api/Flows"; +import { COMMON_STYLES } from "../../../common/styles"; +import { BaseStage } from "../base"; + +export interface PasswordChallenge extends Challenge { + + pending_user: string; + pending_user_avatar: string; + recovery_url?: string; + +} + +@customElement("ak-stage-password") +export class PasswordStage extends BaseStage { + + @property({attribute: false}) + challenge?: PasswordChallenge; + + static get styles(): CSSResult[] { + return COMMON_STYLES; + } + + render(): TemplateResult { + if (!this.challenge) { + return html``; + } + return html`
+

+ ${this.challenge.title} +

+
+
+
{this.submit(e);}}> +
+
+
+ ${gettext( + ${this.challenge.pending_user} +
+ +
+
+ + + + + +
+ +
+
+
+ `; + } + +} diff --git a/web/src/pages/generic/FlowExecutor.ts b/web/src/pages/generic/FlowExecutor.ts index 11db623e7..d7cb9b093 100644 --- a/web/src/pages/generic/FlowExecutor.ts +++ b/web/src/pages/generic/FlowExecutor.ts @@ -3,9 +3,11 @@ import { LitElement, html, customElement, property, TemplateResult } from "lit-e import { unsafeHTML } from "lit-html/directives/unsafe-html"; import { getCookie } from "../../utils"; import "../../elements/stages/identification/IdentificationStage"; +import "../../elements/stages/password/PasswordStage"; import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows"; import { DefaultClient } from "../../api/Client"; import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage"; +import { PasswordChallenge } from "../../elements/stages/password/PasswordStage"; @customElement("ak-flow-executor") export class FlowExecutor extends LitElement { @@ -104,6 +106,8 @@ export class FlowExecutor extends LitElement { switch (this.challenge.component) { case "ak-stage-identification": return html``; + case "ak-stage-password": + return html``; default: break; }