From 110332abbcde199f8a5eaee07207e463b0e20442 Mon Sep 17 00:00:00 2001 From: Cyperghost Date: Thu, 12 Dec 2024 10:59:20 +0100 Subject: [PATCH] Add reCaptcha v3 handler Migrate JavaScript to TypeScript --- .../templates/shared_recaptcha.tpl | 233 +++--------------- package-lock.json | 7 + package.json | 1 + .../Core/Component/Captcha/Recaptcha.ts | 160 ++++++++++++ .../Core/Component/Captcha/Recaptcha.js | 138 +++++++++++ .../system/captcha/RecaptchaHandler.class.php | 23 +- wcfsetup/install/lang/de.xml | 4 +- wcfsetup/install/lang/en.xml | 3 + 8 files changed, 364 insertions(+), 205 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Component/Captcha/Recaptcha.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Captcha/Recaptcha.js diff --git a/com.woltlab.wcf/templates/shared_recaptcha.tpl b/com.woltlab.wcf/templates/shared_recaptcha.tpl index ed73e39c9e4..cc7d693b3bf 100644 --- a/com.woltlab.wcf/templates/shared_recaptcha.tpl +++ b/com.woltlab.wcf/templates/shared_recaptcha.tpl @@ -1,202 +1,49 @@ {if $recaptchaLegacyMode|empty} {include file='shared_captcha'} {else} - {if RECAPTCHA_PUBLICKEY && RECAPTCHA_PRIVATEKEY} - {if $supportsAsyncCaptcha|isset && $supportsAsyncCaptcha && RECAPTCHA_PUBLICKEY_INVISIBLE && RECAPTCHA_PRIVATEKEY_INVISIBLE} - {assign var="recaptchaBucketID" value=true|microtime|sha1} -
-
-
- -
- - {if (($errorType|isset && $errorType|is_array && $errorType[recaptchaString]|isset) || ($errorField|isset && $errorField == 'recaptchaString'))} - {if $errorType|is_array && $errorType[recaptchaString]|isset} - {assign var='__errorType' value=$errorType[recaptchaString]} - {else} - {assign var='__errorType' value=$errorType} - {/if} - - {if $__errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.captcha.recaptchaInvisible.error.recaptchaString.{$__errorType}{/lang} - {/if} - - {/if} -
-
- + {if RECAPTCHA_PUBLICKEY_V3 && RECAPTCHA_PRIVATEKEY_V3} + {assign var="recaptchaType" value="v3"} + {assign var="recaptchaPublicKey" value=RECAPTCHA_PUBLICKEY_V3} + {elseif RECAPTCHA_PUBLICKEY && RECAPTCHA_PRIVATEKEY} + {if RECAPTCHA_PUBLICKEY_INVISIBLE && RECAPTCHA_PRIVATEKEY_INVISIBLE} + {assign var="recaptchaType" value="invisible"} + {assign var="recaptchaPublicKey" value=RECAPTCHA_PUBLICKEY_INVISIBLE} {else} - {assign var="recaptchaBucketID" value=true|microtime|sha1} -
-
-
- -
- - {if (($errorType|isset && $errorType|is_array && $errorType[recaptchaString]|isset) || ($errorField|isset && $errorField == 'recaptchaString'))} - {if $errorType|is_array && $errorType[recaptchaString]|isset} - {assign var='__errorType' value=$errorType[recaptchaString]} + {assign var="recaptchaType" value="v2"} + {assign var="recaptchaPublicKey" value=RECAPTCHA_PUBLICKEY} + {/if} + {/if} + {if !$ajaxCaptcha|isset} + {assign var="ajaxCaptcha" value=false} + {/if} + + {if $recaptchaType|isset && $recaptchaPublicKey|isset} + {assign var="recaptchaBucketID" value=true|microtime|sha1} +
+
{if $recaptchaType !== "v3"}{/if}
+
+ +
+ {if (($errorType|isset && $errorType|is_array && $errorType[recaptchaString]|isset) || ($errorField|isset && $errorField == 'recaptchaString'))} + {if $errorType|is_array && $errorType[recaptchaString]|isset} + {assign var='__errorType' value=$errorType[recaptchaString]} + {else} + {assign var='__errorType' value=$errorType} + {/if} + + {if $__errorType == 'empty'} + {lang}wcf.global.form.error.empty{/lang} {else} - {assign var='__errorType' value=$errorType} + {lang}wcf.captcha.recaptcha{$recaptchaType|ucfirst}.error.recaptchaString.{$__errorType}{/lang} {/if} - - {if $__errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.captcha.recaptchaV2.error.recaptchaString.{$__errorType}{/lang} - {/if} - - {/if} -
-
- - {/if} + {/if} {/if} diff --git a/package-lock.json b/package-lock.json index fbf55411ba6..14fd3052ded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@googlemaps/markerclusterer": "2.5.3", "@types/facebook-js-sdk": "^3.3.12", "@types/google.maps": "^3.58.1", + "@types/grecaptcha": "^3.0.9", "@types/jquery": "^3.5.32", "@types/pica": "5.1.3", "@types/prismjs": "^1.26.5", @@ -1542,6 +1543,12 @@ "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", "license": "MIT" }, + "node_modules/@types/grecaptcha": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/grecaptcha/-/grecaptcha-3.0.9.tgz", + "integrity": "sha512-fFxMtjAvXXMYTzDFK5NpcVB7WHnrHVLl00QzEGpuFxSAC789io6M+vjcn+g5FTEamIJtJr/IHkCDsqvJxeWDyw==", + "license": "MIT" + }, "node_modules/@types/jquery": { "version": "3.5.32", "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.32.tgz", diff --git a/package.json b/package.json index 0a2abb0a206..d41c49e7a07 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@googlemaps/markerclusterer": "2.5.3", "@types/facebook-js-sdk": "^3.3.12", "@types/google.maps": "^3.58.1", + "@types/grecaptcha": "^3.0.9", "@types/jquery": "^3.5.32", "@types/pica": "5.1.3", "@types/prismjs": "^1.26.5", diff --git a/ts/WoltLabSuite/Core/Component/Captcha/Recaptcha.ts b/ts/WoltLabSuite/Core/Component/Captcha/Recaptcha.ts new file mode 100644 index 00000000000..a8d4121fd25 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Captcha/Recaptcha.ts @@ -0,0 +1,160 @@ +/** + * Handles Google reCaptcha. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +import { add as addCaptcha } from "WoltLabSuite/Core/Controller/Captcha"; + +let recaptchaPromise: Promise | undefined; + +function recaptchaLoaded(recaptchaType: ReCaptchaType, publicKey: string): Promise { + if (recaptchaPromise === undefined) { + recaptchaPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + if (recaptchaType === "v3") { + script.src = `https://www.google.com/recaptcha/api.js?render=${publicKey}`; + } else { + script.src = "https://www.google.com/recaptcha/api.js?render=explicit"; + } + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load ReCaptcha script")); + + document.head.appendChild(script); + }); + } + + return recaptchaPromise; +} + +type ReCaptchaType = "v3" | "v2" | "invisible"; + +type ReCaptchaPostParameters = { + "recaptcha-type": ReCaptchaType; + "g-recaptcha-response": string; +}; + +export class Recaptcha { + #widgetID: number; + readonly #container: HTMLElement; + readonly #publicKey: string; + readonly #captchaID?: string; + readonly #recaptchaType: ReCaptchaType; + readonly #tokenPromise: Promise; + #tokenReject!: () => void; + #tokenResolve!: (token: string) => void; + + constructor(recaptchaType: ReCaptchaType, publicKey: string, bucketID: string, captchaID?: string) { + this.#publicKey = publicKey; + this.#recaptchaType = recaptchaType; + this.#captchaID = captchaID; + this.#container = document.getElementById(bucketID)!; + if (!this.#container) { + throw new Error(`Container with ID ${bucketID} does not exist`); + } + + this.#tokenPromise = new Promise((resolve, reject) => { + this.#tokenResolve = resolve; + this.#tokenReject = reject; + }); + + this.ensureRecaptchaLoaded() + .then(() => this.#render()) + .catch((error) => { + console.error("Failed to load ReCaptcha script:", error); + }); + } + + public async ensureRecaptchaLoaded(): Promise { + await recaptchaLoaded(this.#recaptchaType, this.#publicKey); + + await new Promise((resolve) => { + window.grecaptcha.ready(resolve); + }); + } + + public async execute(): Promise { + switch (this.#recaptchaType) { + case "v3": + return window.grecaptcha.execute(this.#publicKey, { action: "submit" }); + case "invisible": + await window.grecaptcha.execute(this.#widgetID); + + return this.#tokenPromise; + case "v2": + return window.grecaptcha.getResponse(this.#widgetID); + } + } + + #render() { + if (this.#recaptchaType !== "v3") { + this.#widgetID = window.grecaptcha.render(this.#container, this.#getParameters()); + } + + if (this.#captchaID) { + addCaptcha(this.#captchaID, () => { + return this.#getPostParameters(); + }); + } else { + const form = this.#container.closest("form")!; + const submitButton = form.querySelector("input[type=submit]")!; + + const listener = (event: Event) => { + event.preventDefault(); + submitButton.disabled = true; + + void this.execute().then((token) => { + form.removeEventListener("submit", listener); + + // reCaptcha v3 does not render a visible widget or add an input field like v2 or invisible + if (this.#recaptchaType === "v3") { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "g-recaptcha-response"; + input.value = token; + form.appendChild(input); + } + + form.submit(); + }); + }; + + form.addEventListener("submit", listener); + } + } + + async #getPostParameters(): Promise { + return { + "recaptcha-type": this.#recaptchaType, + "g-recaptcha-response": await this.execute(), + }; + } + + #getParameters(): ReCaptchaV2.Parameters { + switch (this.#recaptchaType) { + case "v3": + return { + sitekey: this.#publicKey, + }; + case "v2": + return { + sitekey: this.#publicKey, + theme: document.documentElement.dataset.colorScheme === "dark" ? "dark" : "light", + }; + case "invisible": + return { + sitekey: this.#publicKey, + size: "invisible", + badge: "inline", + callback: this.#tokenResolve, + "error-callback": this.#tokenReject, + theme: document.documentElement.dataset.colorScheme === "dark" ? "dark" : "light", + }; + } + } +} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Captcha/Recaptcha.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Captcha/Recaptcha.js new file mode 100644 index 00000000000..8084f55cb6f --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Captcha/Recaptcha.js @@ -0,0 +1,138 @@ +/** + * Handles Google reCaptcha. + * + * @author Olaf Braun + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "WoltLabSuite/Core/Controller/Captcha"], function (require, exports, Captcha_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Recaptcha = void 0; + let recaptchaPromise; + function recaptchaLoaded(recaptchaType, publicKey) { + if (recaptchaPromise === undefined) { + recaptchaPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + if (recaptchaType === "v3") { + script.src = `https://www.google.com/recaptcha/api.js?render=${publicKey}`; + } + else { + script.src = "https://www.google.com/recaptcha/api.js?render=explicit"; + } + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load ReCaptcha script")); + document.head.appendChild(script); + }); + } + return recaptchaPromise; + } + class Recaptcha { + #widgetID; + #container; + #publicKey; + #captchaID; + #recaptchaType; + #tokenPromise; + #tokenReject; + #tokenResolve; + constructor(recaptchaType, publicKey, bucketID, captchaID) { + this.#publicKey = publicKey; + this.#recaptchaType = recaptchaType; + this.#captchaID = captchaID; + this.#container = document.getElementById(bucketID); + if (!this.#container) { + throw new Error(`Container with ID ${bucketID} does not exist`); + } + this.#tokenPromise = new Promise((resolve, reject) => { + this.#tokenResolve = resolve; + this.#tokenReject = reject; + }); + this.ensureRecaptchaLoaded() + .then(() => this.#render()) + .catch((error) => { + console.error("Failed to load ReCaptcha script:", error); + }); + } + async ensureRecaptchaLoaded() { + await recaptchaLoaded(this.#recaptchaType, this.#publicKey); + await new Promise((resolve) => { + window.grecaptcha.ready(resolve); + }); + } + async execute() { + switch (this.#recaptchaType) { + case "v3": + return window.grecaptcha.execute(this.#publicKey, { action: "submit" }); + case "invisible": + await window.grecaptcha.execute(this.#widgetID); + return this.#tokenPromise; + case "v2": + return window.grecaptcha.getResponse(this.#widgetID); + } + } + #render() { + if (this.#recaptchaType !== "v3") { + this.#widgetID = window.grecaptcha.render(this.#container, this.#getParameters()); + } + if (this.#captchaID) { + (0, Captcha_1.add)(this.#captchaID, () => { + return this.#getPostParameters(); + }); + } + else { + const form = this.#container.closest("form"); + const submitButton = form.querySelector("input[type=submit]"); + const listener = (event) => { + event.preventDefault(); + submitButton.disabled = true; + void this.execute().then((token) => { + form.removeEventListener("submit", listener); + // reCaptcha v3 does not render a visible widget or add an input field like v2 or invisible + if (this.#recaptchaType === "v3") { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "g-recaptcha-response"; + input.value = token; + form.appendChild(input); + } + form.submit(); + }); + }; + form.addEventListener("submit", listener); + } + } + async #getPostParameters() { + return { + "recaptcha-type": this.#recaptchaType, + "g-recaptcha-response": await this.execute(), + }; + } + #getParameters() { + switch (this.#recaptchaType) { + case "v3": + return { + sitekey: this.#publicKey, + }; + case "v2": + return { + sitekey: this.#publicKey, + theme: document.documentElement.dataset.colorScheme === "dark" ? "dark" : "light", + }; + case "invisible": + return { + sitekey: this.#publicKey, + size: "invisible", + badge: "inline", + callback: this.#tokenResolve, + "error-callback": this.#tokenReject, + theme: document.documentElement.dataset.colorScheme === "dark" ? "dark" : "light", + }; + } + } + } + exports.Recaptcha = Recaptcha; +}); diff --git a/wcfsetup/install/files/lib/system/captcha/RecaptchaHandler.class.php b/wcfsetup/install/files/lib/system/captcha/RecaptchaHandler.class.php index 080f6be6b54..abb32432618 100644 --- a/wcfsetup/install/files/lib/system/captcha/RecaptchaHandler.class.php +++ b/wcfsetup/install/files/lib/system/captcha/RecaptchaHandler.class.php @@ -111,16 +111,14 @@ public function validate() throw new UserInputException('recaptchaString', 'false'); } - $type = $this->challenge ?: 'v2'; - - if ($type === 'v2') { - $key = RECAPTCHA_PRIVATEKEY; - } elseif ($type === 'invisible') { - $key = RECAPTCHA_PRIVATEKEY_INVISIBLE; - } else { - // The bot modified the `recaptcha-type` form field. - throw new UserInputException('recaptchaString', 'false'); - } + $type = $this->challenge ?: 'v3'; + + $key = match ($type) { + 'v3' => RECAPTCHA_PRIVATEKEY_V3, + 'v2' => RECAPTCHA_PRIVATEKEY, + 'invisible' => RECAPTCHA_PRIVATEKEY_INVISIBLE, + default => throw new UserInputException('recaptchaString', 'false'), + }; $request = new Request( 'GET', @@ -139,6 +137,11 @@ public function validate() $data = JSON::decode((string)$response->getBody()); if ($data['success']) { + // reCaptcha v3 score ranges from 1.0(very likely a good interaction) and 0.0(very likely a bot), + // with 0.5 as the threshold for passing + if ($type === 'v3' && $data['score'] < 0.5) { + throw new UserInputException('recaptchaString', 'false'); + } // yeah } else { throw new UserInputException('recaptchaString', 'false'); diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 0fe300bf5c4..10e1c620717 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -3406,8 +3406,8 @@ Erlaubte Dateiendungen: {', '|implode:$attachmentHandler->getFormattedAllowedExt - - + + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 156f0190c3b..4d20a83314b 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -3334,6 +3334,9 @@ Allowed extensions: {', '|implode:$attachmentHandler->getFormattedAllowedExtensi + + +