stages/password: Migrate to SPA

This commit is contained in:
Jens Langhammer 2021-02-21 00:14:18 +01:00
parent 1c8d101fc3
commit c1e6786ea1
8 changed files with 124 additions and 58 deletions

View File

@ -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()

View File

@ -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"""

View File

@ -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[

View File

@ -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

View File

@ -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);
}
} }

View File

@ -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) {

View 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>`;
}
}

View File

@ -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;
} }