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:
		| @ -17,6 +17,7 @@ class CaptchaStageSerializer(StageSerializer): | ||||
|             "private_key", | ||||
|             "js_url", | ||||
|             "api_url", | ||||
|             "interactive", | ||||
|             "score_min_threshold", | ||||
|             "score_max_threshold", | ||||
|             "error_on_invalid_score", | ||||
|  | ||||
| @ -0,0 +1,18 @@ | ||||
| # Generated by Django 5.0.9 on 2024-10-30 14:28 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_stages_captcha", "0003_captchastage_error_on_invalid_score_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="captchastage", | ||||
|             name="interactive", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @ -9,11 +9,13 @@ from authentik.flows.models import Stage | ||||
|  | ||||
|  | ||||
| class CaptchaStage(Stage): | ||||
|     """Verify the user is human using Google's reCaptcha.""" | ||||
|     """Verify the user is human using Google's reCaptcha/other compatible CAPTCHA solutions.""" | ||||
|  | ||||
|     public_key = models.TextField(help_text=_("Public key, acquired your captcha Provider.")) | ||||
|     private_key = models.TextField(help_text=_("Private key, acquired your captcha Provider.")) | ||||
|  | ||||
|     interactive = models.BooleanField(default=False) | ||||
|  | ||||
|     score_min_threshold = models.FloatField(default=0.5)  # Default values for reCaptcha | ||||
|     score_max_threshold = models.FloatField(default=1.0)  # Default values for reCaptcha | ||||
|  | ||||
|  | ||||
| @ -3,7 +3,7 @@ | ||||
| from django.http.response import HttpResponse | ||||
| from django.utils.translation import gettext as _ | ||||
| from requests import RequestException | ||||
| from rest_framework.fields import CharField | ||||
| from rest_framework.fields import BooleanField, CharField | ||||
| from rest_framework.serializers import ValidationError | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| @ -24,10 +24,12 @@ PLAN_CONTEXT_CAPTCHA = "captcha" | ||||
| class CaptchaChallenge(WithUserInfoChallenge): | ||||
|     """Site public key""" | ||||
|  | ||||
|     site_key = CharField() | ||||
|     js_url = CharField() | ||||
|     component = CharField(default="ak-stage-captcha") | ||||
|  | ||||
|     site_key = CharField(required=True) | ||||
|     js_url = CharField(required=True) | ||||
|     interactive = BooleanField(required=True) | ||||
|  | ||||
|  | ||||
| def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str): | ||||
|     """Validate captcha token""" | ||||
| @ -103,6 +105,7 @@ class CaptchaStageView(ChallengeStageView): | ||||
|             data={ | ||||
|                 "js_url": self.executor.current_stage.js_url, | ||||
|                 "site_key": self.executor.current_stage.public_key, | ||||
|                 "interactive": self.executor.current_stage.interactive, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @ -223,6 +223,7 @@ class IdentificationStageView(ChallengeStageView): | ||||
|                     { | ||||
|                         "js_url": current_stage.captcha_stage.js_url, | ||||
|                         "site_key": current_stage.captcha_stage.public_key, | ||||
|                         "interactive": current_stage.captcha_stage.interactive, | ||||
|                     } | ||||
|                     if current_stage.captcha_stage | ||||
|                     else None | ||||
|  | ||||
| @ -9781,6 +9781,10 @@ | ||||
|                     "minLength": 1, | ||||
|                     "title": "Api url" | ||||
|                 }, | ||||
|                 "interactive": { | ||||
|                     "type": "boolean", | ||||
|                     "title": "Interactive" | ||||
|                 }, | ||||
|                 "score_min_threshold": { | ||||
|                     "type": "number", | ||||
|                     "title": "Score min threshold" | ||||
|  | ||||
| @ -39220,7 +39220,10 @@ components: | ||||
|           type: string | ||||
|         js_url: | ||||
|           type: string | ||||
|         interactive: | ||||
|           type: boolean | ||||
|       required: | ||||
|       - interactive | ||||
|       - js_url | ||||
|       - pending_user | ||||
|       - pending_user_avatar | ||||
| @ -39276,6 +39279,8 @@ components: | ||||
|           type: string | ||||
|         api_url: | ||||
|           type: string | ||||
|         interactive: | ||||
|           type: boolean | ||||
|         score_min_threshold: | ||||
|           type: number | ||||
|           format: double | ||||
| @ -39322,6 +39327,8 @@ components: | ||||
|         api_url: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|         interactive: | ||||
|           type: boolean | ||||
|         score_min_threshold: | ||||
|           type: number | ||||
|           format: double | ||||
| @ -47732,6 +47739,8 @@ components: | ||||
|         api_url: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|         interactive: | ||||
|           type: boolean | ||||
|         score_min_threshold: | ||||
|           type: number | ||||
|           format: double | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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)}`; | ||||
|         })(), | ||||
|  | ||||
| @ -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> | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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>`; | ||||
|         } | ||||
|  | ||||
| @ -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, | ||||
|     }, | ||||
| }); | ||||
| @ -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} | ||||
|  | ||||
| @ -2,15 +2,17 @@ | ||||
| title: Captcha stage | ||||
| --- | ||||
|  | ||||
| This stage adds a form of verification using [Google's ReCaptcha](https://www.google.com/recaptcha/intro/v3.html) or compatible services. Currently supported implementations: | ||||
| This stage adds a form of verification using [Google's reCAPTCHA](https://www.google.com/recaptcha/intro/v3.html) or compatible services. | ||||
|  | ||||
| -   ReCaptcha | ||||
| -   hCaptcha | ||||
| -   Turnstile | ||||
| Currently supported implementations: | ||||
|  | ||||
| -   [Google reCAPTCHA](#google-recaptcha) | ||||
| -   [hCaptcha](#hcaptcha) | ||||
| -   [Cloudflare Turnstile](#cloudflare-turnstile) | ||||
|  | ||||
| ## Captcha provider configuration | ||||
|  | ||||
| ### Google ReCaptcha | ||||
| ### Google reCAPTCHA | ||||
|  | ||||
| This stage has two required fields: Public key and private key. These can both be acquired at https://www.google.com/recaptcha/admin. | ||||
|  | ||||
| @ -18,10 +20,11 @@ This stage has two required fields: Public key and private key. These can both b | ||||
|  | ||||
| #### Configuration options | ||||
|  | ||||
| -   JS URL: `https://www.recaptcha.net/recaptcha/api.js` | ||||
| -   API URL: `https://www.recaptcha.net/recaptcha/api/siteverify` | ||||
| -   Interactive: Enabled when using reCAPTCHA v3 | ||||
| -   Score minimum threshold: `0.5` | ||||
| -   Score maximum threshold: `1` | ||||
| -   JS URL: `https://www.recaptcha.net/recaptcha/api.js` | ||||
| -   API URL: `https://www.recaptcha.net/recaptcha/api/siteverify` | ||||
|  | ||||
| ### hCaptcha | ||||
|  | ||||
| @ -29,6 +32,7 @@ See https://docs.hcaptcha.com/switch | ||||
|  | ||||
| #### Configuration options | ||||
|  | ||||
| -   Interactive: Enabled | ||||
| -   JS URL: `https://js.hcaptcha.com/1/api.js` | ||||
| -   API URL: `https://api.hcaptcha.com/siteverify` | ||||
|  | ||||
| @ -37,16 +41,13 @@ See https://docs.hcaptcha.com/switch | ||||
| -   Score minimum threshold: `0` | ||||
| -   Score maximum threshold: `0.5` | ||||
|  | ||||
| ### Turnstile | ||||
| ### Cloudflare Turnstile | ||||
|  | ||||
| See https://developers.cloudflare.com/turnstile/get-started/migrating-from-recaptcha | ||||
|  | ||||
| :::warning | ||||
| To use Cloudflare Turnstile, the site must be configured to use the "Invisible" mode, otherwise the widget will be rendered incorrectly. | ||||
| ::: | ||||
|  | ||||
| #### Configuration options | ||||
|  | ||||
| -   Interactive: Enabled if the Turnstile instance is configured as visible or managed | ||||
| -   JS URL: `https://challenges.cloudflare.com/turnstile/v0/api.js` | ||||
| -   API URL: `https://challenges.cloudflare.com/turnstile/v0/siteverify` | ||||
|  | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens L.
					Jens L.