stages/password: Migrate to SPA
This commit is contained in:
		| @ -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() | ||||
|  | ||||
| @ -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""" | ||||
|  | ||||
|  | ||||
| @ -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[ | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -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`<i class="pf-icon pf-icon-arrow" title="${source.name}"></i>`; | ||||
|         if (source.icon_url) { | ||||
|  | ||||
							
								
								
									
										69
									
								
								web/src/elements/stages/password/PasswordStage.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								web/src/elements/stages/password/PasswordStage.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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`<ak-loading-state></ak-loading-state>`; | ||||
|         } | ||||
|         return html`<header class="pf-c-login__main-header"> | ||||
|                 <h1 class="pf-c-title pf-m-3xl"> | ||||
|                     ${this.challenge.title} | ||||
|                 </h1> | ||||
|             </header> | ||||
|             <div class="pf-c-login__main-body"> | ||||
|                 <form class="pf-c-form" @submit=${(e: Event) => {this.submit(e);}}> | ||||
|                     <div class="pf-c-form__group"> | ||||
|                         <div class="form-control-static"> | ||||
|                             <div class="left"> | ||||
|                                 <img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}"> | ||||
|                                 ${this.challenge.pending_user} | ||||
|                             </div> | ||||
|                             <div class="right"> | ||||
|                                 <a href="/-/cancel/">${gettext("Not you?")}</a> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <ak-form-element | ||||
|                         label="${gettext("Password")}" | ||||
|                         ?required="${true}" | ||||
|                         class="pf-c-form__group" | ||||
|                         .errors=${(this.challenge?.response_errors || {})["password"]}> | ||||
|                         <input type="password" name="password" placeholder="${gettext("Please enter your password")}" autofocus autocomplete="current-password" class="pf-c-form-control" required=""> | ||||
|                     </ak-form-element> | ||||
|  | ||||
|                     <div class="pf-c-form__group pf-m-action"> | ||||
|                         <button type="submit" class="pf-c-button pf-m-primary pf-m-block"> | ||||
|                             ${gettext("Continue")} | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </form> | ||||
|             </div> | ||||
|             <footer class="pf-c-login__main-footer"> | ||||
|                 <ul class="pf-c-login__main-footer-links"> | ||||
|                 </ul> | ||||
|             </footer>`; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @ -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`<ak-stage-identification .host=${this} .challenge=${this.challenge as IdentificationChallenge}></ak-stage-identification>`; | ||||
|                 case "ak-stage-password": | ||||
|                     return html`<ak-stage-password .host=${this} .challenge=${this.challenge as PasswordChallenge}></ak-stage-password>`; | ||||
|                 default: | ||||
|                     break; | ||||
|             } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer