mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
web: Improvements to ReCaptcha resizing (#16171)
* web: Remove comments from serialized HTML. * web: Apply color theme to iframe. * web: Fix issues surrounding reCaptcha resize events not propagating.
This commit is contained in:
@@ -121,7 +121,9 @@ export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string {
|
||||
|
||||
render(untrustedHTML, container);
|
||||
|
||||
const result = container.innerHTML;
|
||||
|
||||
const result = container.innerHTML
|
||||
// Remove all comments as they can interfere with the styles.
|
||||
.replaceAll("<!---->", "")
|
||||
.replaceAll(/<!--\?lit\$\d+\$-->/g, "");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ListenerController } from "#elements/utils/listenerController";
|
||||
import { randomId } from "#elements/utils/randomId";
|
||||
|
||||
import { BaseStage } from "#flow/stages/base";
|
||||
import { CaptchaHandler, iframeTemplate } from "#flow/stages/captcha/shared";
|
||||
import { CaptchaHandler, CaptchaProvider, iframeTemplate } from "#flow/stages/captcha/shared";
|
||||
|
||||
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
|
||||
|
||||
@@ -109,7 +109,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
//#region State
|
||||
|
||||
@state()
|
||||
protected activeHandler: CaptchaHandler | null = null;
|
||||
protected activeHandler: CaptchaProvider | null = null;
|
||||
|
||||
@state()
|
||||
protected error: string | null = null;
|
||||
@@ -265,7 +265,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
|
||||
//#endregion
|
||||
|
||||
#handlers = new Map<string, CaptchaHandler>([
|
||||
/**
|
||||
* Mapping of captcha provider names to their respective JS API global.
|
||||
*
|
||||
* Note that this is a `Map` to ensure the preferred order of discovering provider globals.
|
||||
*/
|
||||
#handlers = new Map<CaptchaProvider, CaptchaHandler>([
|
||||
[
|
||||
"grecaptcha",
|
||||
{
|
||||
@@ -415,7 +420,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Listeners
|
||||
//#region Resizing
|
||||
|
||||
#loadListener = () => {
|
||||
const iframe = this.#iframeRef.value;
|
||||
@@ -423,17 +428,73 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
|
||||
if (!iframe || !contentDocument) return;
|
||||
|
||||
const resizeListener: ResizeObserverCallback = () => {
|
||||
if (!this.#iframeRef) return;
|
||||
let synchronizeHeight: () => void;
|
||||
|
||||
const target = contentDocument.getElementById("ak-container");
|
||||
if (this.activeHandler === CaptchaProvider.reCAPTCHA) {
|
||||
// reCAPTCHA's use of nested iframes prevents their internal resize observer from
|
||||
// reporting the correct height back to our iframe, so we have to do it ourselves.
|
||||
|
||||
if (!target) return;
|
||||
synchronizeHeight = () => {
|
||||
if (!this.#iframeRef) return;
|
||||
|
||||
this.iframeHeight = Math.round(target.clientHeight);
|
||||
};
|
||||
const target = contentDocument.getElementById("ak-container");
|
||||
|
||||
const resizeObserver = new ResizeObserver(resizeListener);
|
||||
if (!target) return;
|
||||
|
||||
const innerIFrame = contentDocument.querySelector<HTMLIFrameElement>(
|
||||
'iframe[style~="height:"]',
|
||||
);
|
||||
|
||||
const innerBottom = innerIFrame?.getBoundingClientRect().bottom ?? 0;
|
||||
|
||||
const actualHeight = Math.max(innerBottom, target.clientHeight);
|
||||
|
||||
this.iframeHeight = Math.round(actualHeight * 1.1);
|
||||
|
||||
if (innerIFrame?.parentElement) {
|
||||
innerIFrame.parentElement.style.height = `${actualHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
// We watch for any newly inserted iframes, as they may alter the height
|
||||
// of the parent iframe...
|
||||
const mutationObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type !== "childList") continue;
|
||||
|
||||
for (const node of mutation.addedNodes as NodeListOf<HTMLElement>) {
|
||||
if (node.tagName !== "IFRAME") continue;
|
||||
|
||||
// And then resize the iframe to match the new size.
|
||||
//
|
||||
// This doesn't fix the issue entirely since the challenge frame
|
||||
// doesn't yet know the correct height, but at least the user can
|
||||
// try to load the challenge again with the correct height.
|
||||
|
||||
resizeObserver.observe(node as HTMLIFrameElement);
|
||||
|
||||
requestAnimationFrame(synchronizeHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mutationObserver.observe(contentDocument.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
} else {
|
||||
synchronizeHeight = () => {
|
||||
if (!this.#iframeRef) return;
|
||||
|
||||
const target = contentDocument.getElementById("ak-container");
|
||||
|
||||
if (!target) return;
|
||||
|
||||
this.iframeHeight = Math.round(target.clientHeight);
|
||||
};
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(synchronizeHeight);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
resizeObserver.observe(contentDocument.body);
|
||||
@@ -442,22 +503,26 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
});
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Loading
|
||||
|
||||
#scriptLoadListener = async (): Promise<void> => {
|
||||
console.debug("authentik/stages/captcha: script loaded");
|
||||
|
||||
this.error = null;
|
||||
this.#iframeLoaded = false;
|
||||
|
||||
for (const [name, handler] of this.#handlers) {
|
||||
for (const name of this.#handlers.keys()) {
|
||||
if (!Object.hasOwn(window, name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#run(handler);
|
||||
await this.#run(name);
|
||||
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
|
||||
|
||||
this.activeHandler = handler;
|
||||
this.activeHandler = name;
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -469,7 +534,9 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
}
|
||||
};
|
||||
|
||||
async #run(handler: CaptchaHandler) {
|
||||
async #run(captchaProvider: CaptchaProvider) {
|
||||
const handler = this.#handlers.get(captchaProvider)!;
|
||||
|
||||
if (this.challenge.interactive) {
|
||||
const iframe = this.#iframeRef.value;
|
||||
|
||||
@@ -478,18 +545,44 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
return;
|
||||
}
|
||||
|
||||
const { contentDocument } = iframe;
|
||||
|
||||
if (!contentDocument) {
|
||||
console.debug(
|
||||
`authentik/stages/captcha: No iframe content window found, skipping.`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(`authentik/stages/captcha: Rendering interactive.`);
|
||||
|
||||
const captchaElement = handler.interactive();
|
||||
const template = iframeTemplate(captchaElement, this.challenge.jsUrl);
|
||||
const template = iframeTemplate(captchaElement, {
|
||||
challengeURL: this.challenge.jsUrl,
|
||||
theme: this.activeTheme,
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(this.#iframeSource);
|
||||
if (captchaProvider === CaptchaProvider.reCAPTCHA) {
|
||||
// reCAPTCHA's domain verification can't seem to penetrate the true origin
|
||||
// of the page when loaded from a blob URL, likely due to their double-nested
|
||||
// iframe structure.
|
||||
// We fallback to the deprecated `document.write` to get around this.
|
||||
this.#iframeSource = "about:blank";
|
||||
contentDocument.open();
|
||||
contentDocument.write(template);
|
||||
contentDocument.close();
|
||||
|
||||
const url = URL.createObjectURL(new Blob([template], { type: "text/html" }));
|
||||
// this.#loadListener();
|
||||
} else {
|
||||
URL.revokeObjectURL(this.#iframeSource);
|
||||
|
||||
this.#iframeSource = url;
|
||||
const url = URL.createObjectURL(new Blob([template], { type: "text/html" }));
|
||||
|
||||
iframe.src = url;
|
||||
this.#iframeSource = url;
|
||||
|
||||
iframe.src = url;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { ResolvedUITheme } from "#common/theme";
|
||||
|
||||
import { createDocumentTemplate } from "#elements/utils/iframe";
|
||||
|
||||
import { html, TemplateResult } from "lit";
|
||||
|
||||
/**
|
||||
* Mapping of captcha provider names to their respective JS API global.
|
||||
*/
|
||||
export const CaptchaProvider = {
|
||||
reCAPTCHA: "grecaptcha",
|
||||
hCaptcha: "hcaptcha",
|
||||
Turnstile: "turnstile",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
export type CaptchaProvider = (typeof CaptchaProvider)[keyof typeof CaptchaProvider];
|
||||
|
||||
export interface CaptchaHandler {
|
||||
interactive(): TemplateResult;
|
||||
execute(): Promise<void>;
|
||||
@@ -9,6 +22,29 @@ export interface CaptchaHandler {
|
||||
refresh(): Promise<void>;
|
||||
}
|
||||
|
||||
const ThemeColor = {
|
||||
dark: "#18191a",
|
||||
light: "#ffffff",
|
||||
} as const satisfies Record<ResolvedUITheme, string>;
|
||||
|
||||
export function themeMeta(theme: ResolvedUITheme) {
|
||||
switch (theme) {
|
||||
case "dark":
|
||||
return html`
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content=${ThemeColor.dark} />
|
||||
`;
|
||||
case "light":
|
||||
return html` <meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content=${ThemeColor.light} />`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IFrameTemplateInit {
|
||||
challengeURL: string;
|
||||
theme: ResolvedUITheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* A container iframe for a hosted Captcha, with an event emitter to monitor
|
||||
* when the Captcha forces a resize.
|
||||
@@ -17,10 +53,17 @@ export interface CaptchaHandler {
|
||||
* margin, adding 2rem of height to our container adds padding and prevents scrollbars
|
||||
* or hidden rendering.
|
||||
*/
|
||||
export function iframeTemplate(children: TemplateResult, challengeURL: string): string {
|
||||
export function iframeTemplate(
|
||||
children: TemplateResult,
|
||||
{ challengeURL, theme }: IFrameTemplateInit,
|
||||
) {
|
||||
return createDocumentTemplate({
|
||||
head: html`<meta charset="UTF-8" />
|
||||
head: html`
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
${themeMeta(theme)}
|
||||
`,
|
||||
body: html`
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
@@ -43,6 +86,11 @@ export function iframeTemplate(children: TemplateResult, challengeURL: string):
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
background: ${ThemeColor[theme]};
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -58,8 +106,9 @@ export function iframeTemplate(children: TemplateResult, challengeURL: string):
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>`,
|
||||
body: html`${children}
|
||||
<script onload="loadListener()" src="${challengeURL}"></script> `,
|
||||
</style>
|
||||
${children}
|
||||
<script onload="loadListener()" src="${challengeURL}"></script>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user