From 334447699c691a3f1c72700d91027b739ee96ff5 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Tue, 3 Dec 2024 16:00:01 -0800 Subject: [PATCH] web: streamline CaptchaStage # What This commit: 1. Replaces the mass of `if () { if() { if() } }` with two state tables: - One for `render()` - One for `renderBody()` 2. Breaks each Captcha out into "interactive" and "executive" versions 3. Creates a handler table for each Captcha type 4. Replaces the `.forEach` expression with a `for` loop. 5. Move `updated` to the end of the class. 6. Make captchDocument and captchaFrame constructed-on-demand with a cache. 7. Remove a lot of `@state` handlers 8. Give IframeMessageEvent its own type. 9. Removed `this.scriptElement` 10. Replaced `window.removeEventListener` with an `AbortController()` # Why 1. **Replacing `if` trees with a state table.** The logic of the original was really hard to follow. With the state table, we can clearly see now that for the `render()` function, we care about the Boolean flags `[embedded, challenged, interactive]` and have appropriate effects for each. With `renderBody()`, we can see that we care about the Boolean flags `[hasError, challenged]`, and can see the appropriate effects for each one. 2. (and 3.) **Breaking each Captcha clause into separate handlers.** Again, the logic was convoluted, when what we really care about is "Does this captcha have a corresponding handler attached to `window`, and, if so, should we run the interactive or executive version of it?" By putting all of that into a table of `[name, challenge, execute]`, we can clearly see what's being handled when. 4. **Replacing `foreach()` with `for()`**: [You cannot use a `forEach()` with async expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach#:~:text=does%20not%20wait%20for%20promises). If you need asynchronous behavior in an ordered loop, `for()` is the safest way to handle it; if you need asynchronous behavior from multiple promises, `Promise.allSettled(handlers.map())` is the way to go. I tried to tell if this function *meant* to run every handler it found simultaneously, or if it meant to test them in order; I went with the second option, breaking and exiting the loop once a handler had run successfully. 5. **Reordered the code a bit**. We're trying to evolve a pattern in our source code: styles, properties, states, internal variables, constructor, getters & setters that are not `@property()` or `@state()`, DOM-related lifecycle handlers, event handlers, pre-render lifecycle handlers, renderers, and post-render lifecycle handlers. Helper methods (including subrenderers) go above the method(s) they help. 6. **Constructing Elements on demand with a cache**. It is not guaranteed that we will actually need either of those. Constructing them on demand with a cache is both performant and cleaner. Likewise, I removed these from the Lit object's `state()` table, since they're constructed once and never change over the lifetime of an instance of `ak-stage-captcha`. 9. **Remove `this.scriptElement`**: It was never referenced outside the one function where it was used. 10. **Remove `removeEventListener()`**: `AbortController()` is a bit more verbose for small event handler collections, but it's considered much more reliable and much cleaner. --- web/src/flow/stages/captcha/CaptchaStage.ts | 371 +++++++++++--------- 1 file changed, 204 insertions(+), 167 deletions(-) diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index 24a19a6dabd6..b696b8e9e5bd 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -1,14 +1,17 @@ /// import { renderStatic } from "@goauthentik/common/purify"; import "@goauthentik/elements/EmptyState"; +import { akEmptyState } from "@goauthentik/elements/EmptyState"; +import { bound } from "@goauthentik/elements/decorators/bound"; import "@goauthentik/elements/forms/FormElement"; import { randomId } from "@goauthentik/elements/utils/randomId"; import "@goauthentik/flow/FormStatic"; import { BaseStage } from "@goauthentik/flow/stages/base"; +import { P, match } from "ts-pattern"; import type { TurnstileObject } from "turnstile-types"; import { msg } from "@lit/localize"; -import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit"; +import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -23,8 +26,22 @@ import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/ interface TurnstileWindow extends Window { turnstile: TurnstileObject; } + type TokenHandler = (token: string) => void; +type IframeMessageEvent = MessageEvent<{ + source?: string; + context?: string; + message: string; + token: string; +}>; + +type CaptchaHandler = { + name: string; + interactive: () => Promise; + execute: () => Promise; +}; + @customElement("ak-stage-captcha") export class CaptchaStage extends BaseStage { static get styles(): CSSResult[] { @@ -43,19 +60,30 @@ export class CaptchaStage extends BaseStage, - ) { - const msg = ev.data; - if (msg.source !== "goauthentik.io" || msg.context !== "flow-executor") { - return; + get captchaDocumentContainer() { + if (this._captchaDocumentContainer) { + return this._captchaDocumentContainer; } - if (msg.message !== "captcha") { - return; + const cdc = document.createElement("div"); + cdc.id = `ak-captcha-${randomId()}`; + return (this._captchaDocumentContainer = cdc); + } + + get captchaFrame() { + if (this._captchaFrame) { + return this._captchaFrame; + } + const captchaFrame = document.createElement("iframe"); + captchaFrame.src = "about:blank"; + captchaFrame.id = `ak-captcha-${randomId()}`; + return (this._captchaFrame = captchaFrame); + } + + @bound + onIframeMessage({ data }: IframeMessageEvent) { + if ( + data.source === "goauthentik.io" && + data.context === "flow-executor" && + data.message === "captcha" + ) { + this.onTokenChange(data.token); } - this.onTokenChange(msg.token); + } + + async renderGReCaptchaFrame() { + this.renderFrame( + html`
`, + ); + } + + async executeGReCaptcha() { + return grecaptcha.ready(() => { + grecaptcha.execute( + grecaptcha.render(this.captchaDocumentContainer, { + sitekey: this.challenge.siteKey, + callback: this.onTokenChange, + size: "invisible", + }), + ); + }); + } + + async renderHCaptchaFrame() { + this.renderFrame( + html`
`, + ); + } + + async executeHCaptcha() { + return hcaptcha.execute( + hcaptcha.render(this.captchaDocumentContainer, { + sitekey: this.challenge.siteKey, + callback: this.onTokenChange, + size: "invisible", + }), + ); + } + + async renderTurnstileFrame() { + this.renderFrame( + html`
`, + ); + } + + async executeTurnstile() { + return (window as unknown as TurnstileWindow).turnstile.render( + this.captchaDocumentContainer, + { + sitekey: this.challenge.siteKey, + callback: this.onTokenChange, + }, + ); } async renderFrame(captchaElement: TemplateResult) { @@ -133,141 +239,16 @@ export class CaptchaStage extends BaseStage) { - if (changedProperties.has("challenge") && this.challenge !== undefined) { - this.scriptElement = document.createElement("script"); - this.scriptElement.src = this.challenge.jsUrl; - this.scriptElement.async = true; - this.scriptElement.defer = true; - this.scriptElement.dataset.akCaptchaScript = "true"; - this.scriptElement.onload = async () => { - console.debug("authentik/stages/captcha: script loaded"); - let found = false; - let lastError = undefined; - this.handlers.forEach(async (handler) => { - let handlerFound = false; - try { - console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`); - handlerFound = await handler.apply(this); - if (handlerFound) { - console.debug( - `authentik/stages/captcha[${handler.name}]: handler succeeded`, - ); - found = true; - } - } catch (exc) { - console.debug( - `authentik/stages/captcha[${handler.name}]: handler failed: ${exc}`, - ); - if (handlerFound) { - lastError = exc; - } - } - }); - if (!found && lastError) { - this.error = (lastError as Error).toString(); - } - }; - document.head - .querySelectorAll("[data-ak-captcha-script=true]") - .forEach((el) => el.remove()); - document.head.appendChild(this.scriptElement); - if (!this.challenge.interactive) { - document.body.appendChild(this.captchaDocumentContainer); - } - } - } - - async handleGReCaptcha(): Promise { - if (!Object.hasOwn(window, "grecaptcha")) { - return false; - } - if (this.challenge.interactive) { - this.renderFrame( - html`
`, - ); - } else { - grecaptcha.ready(() => { - const captchaId = grecaptcha.render(this.captchaDocumentContainer, { - sitekey: this.challenge.siteKey, - callback: this.onTokenChange, - size: "invisible", - }); - grecaptcha.execute(captchaId); - }); - } - return true; - } - - async handleHCaptcha(): Promise { - if (!Object.hasOwn(window, "hcaptcha")) { - return false; - } - if (this.challenge.interactive) { - this.renderFrame( - html`
`, - ); - } else { - const captchaId = hcaptcha.render(this.captchaDocumentContainer, { - sitekey: this.challenge.siteKey, - callback: this.onTokenChange, - size: "invisible", - }); - hcaptcha.execute(captchaId); - } - return true; - } - - async handleTurnstile(): Promise { - if (!Object.hasOwn(window, "turnstile")) { - return false; - } - if (this.challenge.interactive) { - this.renderFrame( - html`
`, - ); - } else { - (window as unknown as TurnstileWindow).turnstile.render(this.captchaDocumentContainer, { - sitekey: this.challenge.siteKey, - callback: this.onTokenChange, - }); - } - return true; - } - renderBody() { - if (this.error) { - return html` `; - } - if (this.challenge.interactive) { - return html`${this.captchaFrame}`; - } - return html``; + // prettier-ignore + return match([Boolean(this.error), Boolean(this.challenge?.interactive)]) + .with([true, P.any], () => akEmptyState({ icon: "fa-times", header: this.error })) + .with([false, true], () => html`${this.captchaFrame}`) + .with([false, false], () => akEmptyState({ loading: true, header: msg("Verifying...") })) + .exhaustive(); } - render() { - if (this.embedded) { - if (!this.challenge.interactive) { - return html``; - } - return this.renderBody(); - } - if (!this.challenge) { - return html` `; - } + renderMain() { return html` @@ -291,6 +272,62 @@ export class CaptchaStage extends BaseStage