stages/password: Migrate to SPA
This commit is contained in:
		| @ -118,12 +118,12 @@ class IdentificationStageView(ChallengeStageView): | |||||||
|         return challenge |         return challenge | ||||||
|  |  | ||||||
|     def challenge_valid( |     def challenge_valid( | ||||||
|         self, challenge: IdentificationChallengeResponse |         self, response: IdentificationChallengeResponse | ||||||
|     ) -> HttpResponse: |     ) -> 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 |         current_stage: IdentificationStage = self.executor.current_stage | ||||||
|         if not current_stage.show_matched_user: |         if not current_stage.show_matched_user: | ||||||
|             self.executor.plan.context[ |             self.executor.plan.context[ | ||||||
|                 PLAN_CONTEXT_PENDING_USER_IDENTIFIER |                 PLAN_CONTEXT_PENDING_USER_IDENTIFIER | ||||||
|             ] = challenge.validated_data.get("uid_field") |             ] = response.validated_data.get("uid_field") | ||||||
|         return self.executor.stage_ok() |         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): | class PasswordStageForm(forms.ModelForm): | ||||||
|     """Form to create/edit Password Stages""" |     """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.contrib.auth.signals import user_login_failed | ||||||
| from django.core.exceptions import PermissionDenied | from django.core.exceptions import PermissionDenied | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
|  | from django.urls import reverse | ||||||
| from django.utils.translation import gettext as _ | 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 structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
|  | from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes | ||||||
| from authentik.flows.models import Flow, FlowDesignation | from authentik.flows.models import Flow, FlowDesignation | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | 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.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 | from authentik.stages.password.models import PasswordStage | ||||||
|  |  | ||||||
| LOGGER = get_logger() | 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""" |     """Authentication stage which authenticates against django's AuthBackend""" | ||||||
|  |  | ||||||
|     form_class = PasswordForm |     response_class = PasswordChallengeResponse | ||||||
|     template_name = "stages/password/flow-form.html" |  | ||||||
|  |  | ||||||
|     def get_form(self, form_class=None) -> PasswordForm: |  | ||||||
|         form = super().get_form(form_class=form_class) |  | ||||||
|  |  | ||||||
|  |     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 |         # If there's a pending user, update the `username` field | ||||||
|         # this field is only used by password managers. |         # this field is only used by password managers. | ||||||
|         # If there's no user set, an error is raised later. |         # If there's no user set, an error is raised later. | ||||||
|         if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: |         if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context: | ||||||
|             pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] |             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) |         recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY) | ||||||
|         if recovery_flow.exists(): |         if recovery_flow.exists(): | ||||||
|             kwargs["recovery_flow"] = recovery_flow.first() |             challenge.initial_data["recovery_url"] = reverse( | ||||||
|         return kwargs |                 "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: |         if SESSION_INVALID_TRIES not in self.request.session: | ||||||
|             self.request.session[SESSION_INVALID_TRIES] = 0 |             self.request.session[SESSION_INVALID_TRIES] = 0 | ||||||
|         self.request.session[SESSION_INVALID_TRIES] += 1 |         self.request.session[SESSION_INVALID_TRIES] += 1 | ||||||
| @ -88,9 +108,9 @@ class PasswordStageView(FormView, StageView): | |||||||
|             LOGGER.debug("User has exceeded maximum tries") |             LOGGER.debug("User has exceeded maximum tries") | ||||||
|             del self.request.session[SESSION_INVALID_TRIES] |             del self.request.session[SESSION_INVALID_TRIES] | ||||||
|             return self.executor.stage_invalid() |             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""" |         """Authenticate against django's authentication backend""" | ||||||
|         if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: |         if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: | ||||||
|             return self.executor.stage_invalid() |             return self.executor.stage_invalid() | ||||||
| @ -98,7 +118,7 @@ class PasswordStageView(FormView, StageView): | |||||||
|         # an Identifier by most authentication backends |         # an Identifier by most authentication backends | ||||||
|         pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] |         pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] | ||||||
|         auth_kwargs = { |         auth_kwargs = { | ||||||
|             "password": form.cleaned_data.get("password", None), |             "password": response.validated_data.get("password", None), | ||||||
|             "username": pending_user.username, |             "username": pending_user.username, | ||||||
|         } |         } | ||||||
|         try: |         try: | ||||||
| @ -115,8 +135,9 @@ class PasswordStageView(FormView, StageView): | |||||||
|                 # No user was found -> invalid credentials |                 # No user was found -> invalid credentials | ||||||
|                 LOGGER.debug("Invalid credentials") |                 LOGGER.debug("Invalid credentials") | ||||||
|                 # Manually inject error into form |                 # Manually inject error into form | ||||||
|                 form.add_error("password", _("Invalid password")) |                 response._errors.setdefault("password", []) | ||||||
|                 return self.form_invalid(form) |                 response._errors["password"].append(ErrorDetail(_("Invalid password"), "invalid")) | ||||||
|  |                 return self.challenge_invalid(response) | ||||||
|             # User instance returned from authenticate() has .backend property set |             # User instance returned from authenticate() has .backend property set | ||||||
|             self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user |             self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user | ||||||
|             self.executor.plan.context[ |             self.executor.plan.context[ | ||||||
|  | |||||||
| @ -11297,7 +11297,6 @@ definitions: | |||||||
|     required: |     required: | ||||||
|       - name |       - name | ||||||
|       - user_fields |       - user_fields | ||||||
|       - template |  | ||||||
|     type: object |     type: object | ||||||
|     properties: |     properties: | ||||||
|       pk: |       pk: | ||||||
| @ -11345,12 +11344,6 @@ definitions: | |||||||
|           is enabled, the user's username and avatar will be shown. Otherwise, the |           is enabled, the user's username and avatar will be shown. Otherwise, the | ||||||
|           text that the user entered will be shown |           text that the user entered will be shown | ||||||
|         type: boolean |         type: boolean | ||||||
|       template: |  | ||||||
|         title: Template |  | ||||||
|         type: string |  | ||||||
|         enum: |  | ||||||
|           - stages/identification/login.html |  | ||||||
|           - stages/identification/recovery.html |  | ||||||
|       enrollment_flow: |       enrollment_flow: | ||||||
|         title: Enrollment flow |         title: Enrollment flow | ||||||
|         description: Optional enrollment flow, which is linked at the bottom of the |         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 { | export class BaseStage extends LitElement { | ||||||
|  |  | ||||||
|     // submit() |  | ||||||
|  |  | ||||||
|     host?: FlowExecutor; |     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; |         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 { |     renderSource(source: UILoginButton): TemplateResult { | ||||||
|         let icon = html`<i class="pf-icon pf-icon-arrow" title="${source.name}"></i>`; |         let icon = html`<i class="pf-icon pf-icon-arrow" title="${source.name}"></i>`; | ||||||
|         if (source.icon_url) { |         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 { unsafeHTML } from "lit-html/directives/unsafe-html"; | ||||||
| import { getCookie } from "../../utils"; | import { getCookie } from "../../utils"; | ||||||
| import "../../elements/stages/identification/IdentificationStage"; | import "../../elements/stages/identification/IdentificationStage"; | ||||||
|  | import "../../elements/stages/password/PasswordStage"; | ||||||
| import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows"; | import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows"; | ||||||
| import { DefaultClient } from "../../api/Client"; | import { DefaultClient } from "../../api/Client"; | ||||||
| import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage"; | import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage"; | ||||||
|  | import { PasswordChallenge } from "../../elements/stages/password/PasswordStage"; | ||||||
|  |  | ||||||
| @customElement("ak-flow-executor") | @customElement("ak-flow-executor") | ||||||
| export class FlowExecutor extends LitElement { | export class FlowExecutor extends LitElement { | ||||||
| @ -104,6 +106,8 @@ export class FlowExecutor extends LitElement { | |||||||
|             switch (this.challenge.component) { |             switch (this.challenge.component) { | ||||||
|                 case "ak-stage-identification": |                 case "ak-stage-identification": | ||||||
|                     return html`<ak-stage-identification .host=${this} .challenge=${this.challenge as IdentificationChallenge}></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: |                 default: | ||||||
|                     break; |                     break; | ||||||
|             } |             } | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer