stages/captcha: Run interactive captcha in Frame (#11857)

* initial turnstile frame

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add interactive flag

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add interactive support for all

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing migration

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* don't hide in identification stage if interactive

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fixup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* require less hacky css

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2024-11-11 13:20:49 +01:00
committed by GitHub
parent 10d50481c9
commit 4f1ddc5779
15 changed files with 365 additions and 147 deletions

View File

@ -2,6 +2,7 @@ import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
@ -80,6 +81,15 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
)}
</p>
</ak-form-element-horizontal>
<ak-switch-input
name="interactive"
label=${msg("Interactive")}
?checked="${this.instance?.interactive}"
help=${msg(
"Enable this flag if the configured captcha requires User-interaction. Required for reCAPTCHA v2, hCaptcha and Cloudflare Turnstile.",
)}
>
</ak-switch-input>
<ak-number-input
label=${msg("Score minimum threshold")}
required

View File

@ -10,10 +10,14 @@ export const DOM_PURIFY_STRICT: DOMPurify.Config = {
ALLOWED_TAGS: ["#text"],
};
export async function renderStatic(input: TemplateResult): Promise<string> {
return await collectResult(render(input));
}
export function purify(input: TemplateResult): TemplateResult {
return html`${until(
(async () => {
const rendered = await collectResult(render(input));
const rendered = await renderStatic(input);
const purified = DOMPurify.sanitize(rendered);
return html`${unsafeHTML(purified)}`;
})(),

View File

@ -107,7 +107,7 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
?loading="${this.authenticating}"
header=${this.authenticating
? msg("Authenticating...")
: this.errorMessage || msg("Failed to authenticate")}
: this.errorMessage || msg("Loading")}
icon="fa-times"
>
</ak-empty-state>

View File

@ -10,7 +10,7 @@ import "../../../stories/flow-interface";
import "./CaptchaStage";
export default {
title: "Flow / Stages / CaptchaStage",
title: "Flow / Stages / Captcha",
};
export const LoadingNoChallenge = () => {
@ -25,92 +25,60 @@ export const LoadingNoChallenge = () => {
</ak-storybook-interface>`;
};
export const ChallengeGoogleReCaptcha: StoryObj = {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface theme=${theme}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
</div>
</div></div
></ak-storybook-interface>`;
},
args: {
theme: "automatic",
challenge: {
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://www.google.com/recaptcha/api.js",
siteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
} as CaptchaChallenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
function captchaFactory(challenge: CaptchaChallenge): StoryObj {
return {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface theme=${theme}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
</div>
</div></div
></ak-storybook-interface>`;
},
args: {
theme: "automatic",
challenge: challenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
},
};
};
}
export const ChallengeHCaptcha: StoryObj = {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface theme=${theme}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
</div>
</div></div
></ak-storybook-interface>`;
},
args: {
theme: "automatic",
challenge: {
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://js.hcaptcha.com/1/api.js",
siteKey: "10000000-ffff-ffff-ffff-000000000001",
} as CaptchaChallenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
};
export const ChallengeHCaptcha = captchaFactory({
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://js.hcaptcha.com/1/api.js",
siteKey: "10000000-ffff-ffff-ffff-000000000001",
interactive: true,
} as CaptchaChallenge);
export const ChallengeTurnstile: StoryObj = {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface theme=${theme}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
</div>
</div></div
></ak-storybook-interface>`;
},
args: {
theme: "automatic",
challenge: {
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
siteKey: "1x00000000000000000000BB",
} as CaptchaChallenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
};
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
export const ChallengeTurnstileVisible = captchaFactory({
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
siteKey: "1x00000000000000000000AA",
interactive: true,
} as CaptchaChallenge);
export const ChallengeTurnstileInvisible = captchaFactory({
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
siteKey: "1x00000000000000000000BB",
interactive: true,
} as CaptchaChallenge);
export const ChallengeTurnstileForce = captchaFactory({
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
siteKey: "3x00000000000000000000FF",
interactive: true,
} as CaptchaChallenge);

View File

@ -1,16 +1,17 @@
///<reference types="@hcaptcha/types"/>
import { renderStatic } from "@goauthentik/common/purify";
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement";
import { randomId } from "@goauthentik/elements/utils/randomId";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import type { TurnstileObject } from "turnstile-types";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, html } from "lit";
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
@ -24,12 +25,22 @@ interface TurnstileWindow extends Window {
}
type TokenHandler = (token: string) => void;
const captchaContainerID = "captcha-container";
@customElement("ak-stage-captcha")
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
return [
PFBase,
PFLogin,
PFForm,
PFFormControl,
PFTitle,
css`
iframe {
width: 100%;
height: 73px; /* tmp */
}
`,
];
}
handlers = [this.handleGReCaptcha, this.handleHCaptcha, this.handleTurnstile];
@ -38,14 +49,17 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
error?: string;
@state()
captchaInteractive: boolean = true;
captchaFrame: HTMLIFrameElement;
@state()
captchaContainer: HTMLDivElement;
captchaDocumentContainer: HTMLDivElement;
@state()
scriptElement?: HTMLScriptElement;
@property({ type: Boolean })
embedded = false;
@property()
onTokenChange: TokenHandler = (token: string) => {
this.host.submit({ component: "ak-stage-captcha", token });
@ -53,8 +67,70 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
constructor() {
super();
this.captchaContainer = document.createElement("div");
this.captchaContainer.id = captchaContainerID;
this.captchaFrame = document.createElement("iframe");
this.captchaFrame.src = "about:blank";
this.captchaFrame.id = `ak-captcha-${randomId()}`;
this.captchaDocumentContainer = document.createElement("div");
this.captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
this.messageCallback = this.messageCallback.bind(this);
}
connectedCallback(): void {
super.connectedCallback();
window.addEventListener("message", this.messageCallback);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("message", this.messageCallback);
if (!this.challenge.interactive) {
document.removeChild(this.captchaDocumentContainer);
}
}
messageCallback(
ev: MessageEvent<{
source?: string;
context?: string;
message: string;
token: string;
}>,
) {
const msg = ev.data;
if (msg.source !== "goauthentik.io" || msg.context !== "flow-executor") {
return;
}
if (msg.message !== "captcha") {
return;
}
this.onTokenChange(msg.token);
}
async renderFrame(captchaElement: TemplateResult) {
this.captchaFrame.contentWindow?.document.open();
this.captchaFrame.contentWindow?.document.write(
await renderStatic(
html`<!doctype html>
<html>
<body style="display:flex;flex-direction:row;justify-content:center;">
${captchaElement}
<script src=${this.challenge.jsUrl}></script>
<script>
function callback(token) {
window.parent.postMessage({
message: "captcha",
source: "goauthentik.io",
context: "flow-executor",
token: token,
});
}
</script>
</body>
</html>`,
),
);
this.captchaFrame.contentWindow?.document.close();
}
updated(changedProperties: PropertyValues<this>) {
@ -64,15 +140,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
this.scriptElement.async = true;
this.scriptElement.defer = true;
this.scriptElement.dataset.akCaptchaScript = "true";
this.scriptElement.onload = () => {
this.scriptElement.onload = async () => {
console.debug("authentik/stages/captcha: script loaded");
let found = false;
let lastError = undefined;
this.handlers.forEach((handler) => {
this.handlers.forEach(async (handler) => {
let handlerFound = false;
try {
console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`);
handlerFound = handler.apply(this);
handlerFound = await handler.apply(this);
if (handlerFound) {
console.debug(
`authentik/stages/captcha[${handler.name}]: handler succeeded`,
@ -96,51 +172,79 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
.querySelectorAll("[data-ak-captcha-script=true]")
.forEach((el) => el.remove());
document.head.appendChild(this.scriptElement);
if (!this.challenge.interactive) {
document.appendChild(this.captchaDocumentContainer);
}
}
}
handleGReCaptcha(): boolean {
async handleGReCaptcha(): Promise<boolean> {
if (!Object.hasOwn(window, "grecaptcha")) {
return false;
}
this.captchaInteractive = false;
document.body.appendChild(this.captchaContainer);
grecaptcha.ready(() => {
const captchaId = grecaptcha.render(this.captchaContainer, {
if (this.challenge.interactive) {
this.renderFrame(
html`<div
class="g-recaptcha"
data-sitekey="${this.challenge.siteKey}"
data-callback="callback"
></div>`,
);
} 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<boolean> {
if (!Object.hasOwn(window, "hcaptcha")) {
return false;
}
if (this.challenge.interactive) {
this.renderFrame(
html`<div
class="h-captcha"
data-sitekey="${this.challenge.siteKey}"
data-theme="${this.activeTheme ? this.activeTheme : "light"}"
data-callback="callback"
></div> `,
);
} else {
const captchaId = hcaptcha.render(this.captchaDocumentContainer, {
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
size: "invisible",
});
grecaptcha.execute(captchaId);
});
return true;
}
handleHCaptcha(): boolean {
if (!Object.hasOwn(window, "hcaptcha")) {
return false;
hcaptcha.execute(captchaId);
}
this.captchaInteractive = false;
document.body.appendChild(this.captchaContainer);
const captchaId = hcaptcha.render(this.captchaContainer, {
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
size: "invisible",
});
hcaptcha.execute(captchaId);
return true;
}
handleTurnstile(): boolean {
async handleTurnstile(): Promise<boolean> {
if (!Object.hasOwn(window, "turnstile")) {
return false;
}
this.captchaInteractive = false;
document.body.appendChild(this.captchaContainer);
(window as unknown as TurnstileWindow).turnstile.render(`#${captchaContainerID}`, {
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
});
if (this.challenge.interactive) {
this.renderFrame(
html`<div
class="cf-turnstile"
data-sitekey="${this.challenge.siteKey}"
data-callback="callback"
></div>`,
);
} else {
(window as unknown as TurnstileWindow).turnstile.render(this.captchaDocumentContainer, {
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
});
}
return true;
}
@ -148,13 +252,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
if (this.error) {
return html`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`;
}
if (this.captchaInteractive) {
return html`${this.captchaContainer}`;
if (this.challenge.interactive) {
return html`${this.captchaFrame}`;
}
return html`<ak-empty-state loading header=${msg("Verifying...")}></ak-empty-state>`;
}
render() {
if (this.embedded) {
if (!this.challenge.interactive) {
return html``;
}
return this.renderBody();
}
if (!this.challenge) {
return html`<ak-empty-state loading> </ak-empty-state>`;
}

View File

@ -0,0 +1,87 @@
import type { StoryObj } from "@storybook/web-components";
import { html } from "lit";
import "@patternfly/patternfly/components/Login/login.css";
import { FlowDesignationEnum, IdentificationChallenge, UiThemeEnum } from "@goauthentik/api";
import "../../../stories/flow-interface";
import "./IdentificationStage";
export default {
title: "Flow / Stages / Identification",
};
export const LoadingNoChallenge = () => {
return html`<ak-storybook-interface theme=${UiThemeEnum.Dark}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-identification></ak-stage-identification>
</div>
</div>
</div>
</ak-storybook-interface>`;
};
function identificationFactory(challenge: IdentificationChallenge): StoryObj {
return {
render: ({ theme, challenge }) => {
return html`<ak-storybook-interface theme=${theme}>
<div class="pf-c-login">
<div class="pf-c-login__container">
<div class="pf-c-login__main">
<ak-stage-identification
.challenge=${challenge}
></ak-stage-identification>
</div>
</div></div
></ak-storybook-interface>`;
},
args: {
theme: "automatic",
challenge: challenge,
},
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
};
}
export const ChallengeDefault = identificationFactory({
userFields: ["username"],
passwordFields: false,
flowDesignation: FlowDesignationEnum.Authentication,
primaryAction: "Login",
showSourceLabels: false,
// jsUrl: "https://js.hcaptcha.com/1/api.js",
// siteKey: "10000000-ffff-ffff-ffff-000000000001",
// interactive: true,
});
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
export const ChallengeCaptchaTurnstileVisible = identificationFactory({
userFields: ["username"],
passwordFields: false,
flowDesignation: FlowDesignationEnum.Authentication,
primaryAction: "Login",
showSourceLabels: false,
flowInfo: {
layout: "stacked",
cancelUrl: "",
title: "Foo",
},
captchaStage: {
pendingUser: "",
pendingUserAvatar: "",
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
siteKey: "1x00000000000000000000AA",
interactive: true,
},
});

View File

@ -282,11 +282,11 @@ export class IdentificationStage extends BaseStage<
? html`
<input name="captchaToken" type="hidden" .value="${this.captchaToken}" />
<ak-stage-captcha
style="visibility: hidden; position:absolute;"
.challenge=${this.challenge.captchaStage}
.onTokenChange=${(token: string) => {
this.captchaToken = token;
}}
embedded
></ak-stage-captcha>
`
: nothing}