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", |             "private_key", | ||||||
|             "js_url", |             "js_url", | ||||||
|             "api_url", |             "api_url", | ||||||
|  |             "interactive", | ||||||
|             "score_min_threshold", |             "score_min_threshold", | ||||||
|             "score_max_threshold", |             "score_max_threshold", | ||||||
|             "error_on_invalid_score", |             "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): | 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.")) |     public_key = models.TextField(help_text=_("Public key, acquired your captcha Provider.")) | ||||||
|     private_key = models.TextField(help_text=_("Private 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_min_threshold = models.FloatField(default=0.5)  # Default values for reCaptcha | ||||||
|     score_max_threshold = models.FloatField(default=1.0)  # 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.http.response import HttpResponse | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from requests import RequestException | from requests import RequestException | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import BooleanField, CharField | ||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -24,10 +24,12 @@ PLAN_CONTEXT_CAPTCHA = "captcha" | |||||||
| class CaptchaChallenge(WithUserInfoChallenge): | class CaptchaChallenge(WithUserInfoChallenge): | ||||||
|     """Site public key""" |     """Site public key""" | ||||||
|  |  | ||||||
|     site_key = CharField() |  | ||||||
|     js_url = CharField() |  | ||||||
|     component = CharField(default="ak-stage-captcha") |     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): | def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str): | ||||||
|     """Validate captcha token""" |     """Validate captcha token""" | ||||||
| @ -103,6 +105,7 @@ class CaptchaStageView(ChallengeStageView): | |||||||
|             data={ |             data={ | ||||||
|                 "js_url": self.executor.current_stage.js_url, |                 "js_url": self.executor.current_stage.js_url, | ||||||
|                 "site_key": self.executor.current_stage.public_key, |                 "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, |                         "js_url": current_stage.captcha_stage.js_url, | ||||||
|                         "site_key": current_stage.captcha_stage.public_key, |                         "site_key": current_stage.captcha_stage.public_key, | ||||||
|  |                         "interactive": current_stage.captcha_stage.interactive, | ||||||
|                     } |                     } | ||||||
|                     if current_stage.captcha_stage |                     if current_stage.captcha_stage | ||||||
|                     else None |                     else None | ||||||
|  | |||||||
| @ -9781,6 +9781,10 @@ | |||||||
|                     "minLength": 1, |                     "minLength": 1, | ||||||
|                     "title": "Api url" |                     "title": "Api url" | ||||||
|                 }, |                 }, | ||||||
|  |                 "interactive": { | ||||||
|  |                     "type": "boolean", | ||||||
|  |                     "title": "Interactive" | ||||||
|  |                 }, | ||||||
|                 "score_min_threshold": { |                 "score_min_threshold": { | ||||||
|                     "type": "number", |                     "type": "number", | ||||||
|                     "title": "Score min threshold" |                     "title": "Score min threshold" | ||||||
|  | |||||||
| @ -39220,7 +39220,10 @@ components: | |||||||
|           type: string |           type: string | ||||||
|         js_url: |         js_url: | ||||||
|           type: string |           type: string | ||||||
|  |         interactive: | ||||||
|  |           type: boolean | ||||||
|       required: |       required: | ||||||
|  |       - interactive | ||||||
|       - js_url |       - js_url | ||||||
|       - pending_user |       - pending_user | ||||||
|       - pending_user_avatar |       - pending_user_avatar | ||||||
| @ -39276,6 +39279,8 @@ components: | |||||||
|           type: string |           type: string | ||||||
|         api_url: |         api_url: | ||||||
|           type: string |           type: string | ||||||
|  |         interactive: | ||||||
|  |           type: boolean | ||||||
|         score_min_threshold: |         score_min_threshold: | ||||||
|           type: number |           type: number | ||||||
|           format: double |           format: double | ||||||
| @ -39322,6 +39327,8 @@ components: | |||||||
|         api_url: |         api_url: | ||||||
|           type: string |           type: string | ||||||
|           minLength: 1 |           minLength: 1 | ||||||
|  |         interactive: | ||||||
|  |           type: boolean | ||||||
|         score_min_threshold: |         score_min_threshold: | ||||||
|           type: number |           type: number | ||||||
|           format: double |           format: double | ||||||
| @ -47732,6 +47739,8 @@ components: | |||||||
|         api_url: |         api_url: | ||||||
|           type: string |           type: string | ||||||
|           minLength: 1 |           minLength: 1 | ||||||
|  |         interactive: | ||||||
|  |           type: boolean | ||||||
|         score_min_threshold: |         score_min_threshold: | ||||||
|           type: number |           type: number | ||||||
|           format: double |           format: double | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; | |||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| import { first } from "@goauthentik/common/utils"; | import { first } from "@goauthentik/common/utils"; | ||||||
| import "@goauthentik/components/ak-number-input"; | import "@goauthentik/components/ak-number-input"; | ||||||
|  | import "@goauthentik/components/ak-switch-input"; | ||||||
| import "@goauthentik/elements/forms/FormGroup"; | import "@goauthentik/elements/forms/FormGroup"; | ||||||
| import "@goauthentik/elements/forms/HorizontalFormElement"; | import "@goauthentik/elements/forms/HorizontalFormElement"; | ||||||
|  |  | ||||||
| @ -80,6 +81,15 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> { | |||||||
|                             )} |                             )} | ||||||
|                         </p> |                         </p> | ||||||
|                     </ak-form-element-horizontal> |                     </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 |                     <ak-number-input | ||||||
|                         label=${msg("Score minimum threshold")} |                         label=${msg("Score minimum threshold")} | ||||||
|                         required |                         required | ||||||
|  | |||||||
| @ -10,10 +10,14 @@ export const DOM_PURIFY_STRICT: DOMPurify.Config = { | |||||||
|     ALLOWED_TAGS: ["#text"], |     ALLOWED_TAGS: ["#text"], | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export async function renderStatic(input: TemplateResult): Promise<string> { | ||||||
|  |     return await collectResult(render(input)); | ||||||
|  | } | ||||||
|  |  | ||||||
| export function purify(input: TemplateResult): TemplateResult { | export function purify(input: TemplateResult): TemplateResult { | ||||||
|     return html`${until( |     return html`${until( | ||||||
|         (async () => { |         (async () => { | ||||||
|             const rendered = await collectResult(render(input)); |             const rendered = await renderStatic(input); | ||||||
|             const purified = DOMPurify.sanitize(rendered); |             const purified = DOMPurify.sanitize(rendered); | ||||||
|             return html`${unsafeHTML(purified)}`; |             return html`${unsafeHTML(purified)}`; | ||||||
|         })(), |         })(), | ||||||
|  | |||||||
| @ -107,7 +107,7 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage< | |||||||
|                     ?loading="${this.authenticating}" |                     ?loading="${this.authenticating}" | ||||||
|                     header=${this.authenticating |                     header=${this.authenticating | ||||||
|                         ? msg("Authenticating...") |                         ? msg("Authenticating...") | ||||||
|                         : this.errorMessage || msg("Failed to authenticate")} |                         : this.errorMessage || msg("Loading")} | ||||||
|                     icon="fa-times" |                     icon="fa-times" | ||||||
|                 > |                 > | ||||||
|                 </ak-empty-state> |                 </ak-empty-state> | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ import "../../../stories/flow-interface"; | |||||||
| import "./CaptchaStage"; | import "./CaptchaStage"; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|     title: "Flow / Stages / CaptchaStage", |     title: "Flow / Stages / Captcha", | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const LoadingNoChallenge = () => { | export const LoadingNoChallenge = () => { | ||||||
| @ -25,7 +25,8 @@ export const LoadingNoChallenge = () => { | |||||||
|     </ak-storybook-interface>`; |     </ak-storybook-interface>`; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const ChallengeGoogleReCaptcha: StoryObj = { | function captchaFactory(challenge: CaptchaChallenge): StoryObj { | ||||||
|  |     return { | ||||||
|         render: ({ theme, challenge }) => { |         render: ({ theme, challenge }) => { | ||||||
|             return html`<ak-storybook-interface theme=${theme}> |             return html`<ak-storybook-interface theme=${theme}> | ||||||
|                 <div class="pf-c-login"> |                 <div class="pf-c-login"> | ||||||
| @ -38,12 +39,7 @@ export const ChallengeGoogleReCaptcha: StoryObj = { | |||||||
|         }, |         }, | ||||||
|         args: { |         args: { | ||||||
|             theme: "automatic", |             theme: "automatic", | ||||||
|         challenge: { |             challenge: challenge, | ||||||
|             pendingUser: "foo", |  | ||||||
|             pendingUserAvatar: "https://picsum.photos/64", |  | ||||||
|             jsUrl: "https://www.google.com/recaptcha/api.js", |  | ||||||
|             siteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI", |  | ||||||
|         } as CaptchaChallenge, |  | ||||||
|         }, |         }, | ||||||
|         argTypes: { |         argTypes: { | ||||||
|             theme: { |             theme: { | ||||||
| @ -54,63 +50,35 @@ export const ChallengeGoogleReCaptcha: StoryObj = { | |||||||
|             }, |             }, | ||||||
|         }, |         }, | ||||||
|     }; |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
| export const ChallengeHCaptcha: StoryObj = { | export const ChallengeHCaptcha = captchaFactory({ | ||||||
|     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", |     pendingUser: "foo", | ||||||
|     pendingUserAvatar: "https://picsum.photos/64", |     pendingUserAvatar: "https://picsum.photos/64", | ||||||
|     jsUrl: "https://js.hcaptcha.com/1/api.js", |     jsUrl: "https://js.hcaptcha.com/1/api.js", | ||||||
|     siteKey: "10000000-ffff-ffff-ffff-000000000001", |     siteKey: "10000000-ffff-ffff-ffff-000000000001", | ||||||
|         } as CaptchaChallenge, |     interactive: true, | ||||||
|     }, | } as CaptchaChallenge); | ||||||
|     argTypes: { |  | ||||||
|         theme: { |  | ||||||
|             options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], |  | ||||||
|             control: { |  | ||||||
|                 type: "select", |  | ||||||
|             }, |  | ||||||
|         }, |  | ||||||
|     }, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const ChallengeTurnstile: StoryObj = { | // https://developers.cloudflare.com/turnstile/troubleshooting/testing/ | ||||||
|     render: ({ theme, challenge }) => { | export const ChallengeTurnstileVisible = captchaFactory({ | ||||||
|         return html`<ak-storybook-interface theme=${theme}> |     pendingUser: "foo", | ||||||
|             <div class="pf-c-login"> |     pendingUserAvatar: "https://picsum.photos/64", | ||||||
|                 <div class="pf-c-login__container"> |     jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", | ||||||
|                     <div class="pf-c-login__main"> |     siteKey: "1x00000000000000000000AA", | ||||||
|                         <ak-stage-captcha .challenge=${challenge}></ak-stage-captcha> |     interactive: true, | ||||||
|                     </div> | } as CaptchaChallenge); | ||||||
|                 </div></div | export const ChallengeTurnstileInvisible = captchaFactory({ | ||||||
|         ></ak-storybook-interface>`; |  | ||||||
|     }, |  | ||||||
|     args: { |  | ||||||
|         theme: "automatic", |  | ||||||
|         challenge: { |  | ||||||
|     pendingUser: "foo", |     pendingUser: "foo", | ||||||
|     pendingUserAvatar: "https://picsum.photos/64", |     pendingUserAvatar: "https://picsum.photos/64", | ||||||
|     jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", |     jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", | ||||||
|     siteKey: "1x00000000000000000000BB", |     siteKey: "1x00000000000000000000BB", | ||||||
|         } as CaptchaChallenge, |     interactive: true, | ||||||
|     }, | } as CaptchaChallenge); | ||||||
|     argTypes: { | export const ChallengeTurnstileForce = captchaFactory({ | ||||||
|         theme: { |     pendingUser: "foo", | ||||||
|             options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark], |     pendingUserAvatar: "https://picsum.photos/64", | ||||||
|             control: { |     jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js", | ||||||
|                 type: "select", |     siteKey: "3x00000000000000000000FF", | ||||||
|             }, |     interactive: true, | ||||||
|         }, | } as CaptchaChallenge); | ||||||
|     }, |  | ||||||
| }; |  | ||||||
|  | |||||||
| @ -1,16 +1,17 @@ | |||||||
| ///<reference types="@hcaptcha/types"/> | ///<reference types="@hcaptcha/types"/> | ||||||
|  | import { renderStatic } from "@goauthentik/common/purify"; | ||||||
| import "@goauthentik/elements/EmptyState"; | import "@goauthentik/elements/EmptyState"; | ||||||
| import "@goauthentik/elements/forms/FormElement"; | import "@goauthentik/elements/forms/FormElement"; | ||||||
|  | import { randomId } from "@goauthentik/elements/utils/randomId"; | ||||||
| import "@goauthentik/flow/FormStatic"; | import "@goauthentik/flow/FormStatic"; | ||||||
| import { BaseStage } from "@goauthentik/flow/stages/base"; | import { BaseStage } from "@goauthentik/flow/stages/base"; | ||||||
| import type { TurnstileObject } from "turnstile-types"; | import type { TurnstileObject } from "turnstile-types"; | ||||||
|  |  | ||||||
| import { msg } from "@lit/localize"; | 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 { customElement, property, state } from "lit/decorators.js"; | ||||||
| import { ifDefined } from "lit/directives/if-defined.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 PFForm from "@patternfly/patternfly/components/Form/form.css"; | ||||||
| import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; | import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; | ||||||
| import PFLogin from "@patternfly/patternfly/components/Login/login.css"; | import PFLogin from "@patternfly/patternfly/components/Login/login.css"; | ||||||
| @ -24,12 +25,22 @@ interface TurnstileWindow extends Window { | |||||||
| } | } | ||||||
| type TokenHandler = (token: string) => void; | type TokenHandler = (token: string) => void; | ||||||
|  |  | ||||||
| const captchaContainerID = "captcha-container"; |  | ||||||
|  |  | ||||||
| @customElement("ak-stage-captcha") | @customElement("ak-stage-captcha") | ||||||
| export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> { | export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> { | ||||||
|     static get styles(): CSSResult[] { |     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]; |     handlers = [this.handleGReCaptcha, this.handleHCaptcha, this.handleTurnstile]; | ||||||
| @ -38,14 +49,17 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe | |||||||
|     error?: string; |     error?: string; | ||||||
|  |  | ||||||
|     @state() |     @state() | ||||||
|     captchaInteractive: boolean = true; |     captchaFrame: HTMLIFrameElement; | ||||||
|  |  | ||||||
|     @state() |     @state() | ||||||
|     captchaContainer: HTMLDivElement; |     captchaDocumentContainer: HTMLDivElement; | ||||||
|  |  | ||||||
|     @state() |     @state() | ||||||
|     scriptElement?: HTMLScriptElement; |     scriptElement?: HTMLScriptElement; | ||||||
|  |  | ||||||
|  |     @property({ type: Boolean }) | ||||||
|  |     embedded = false; | ||||||
|  |  | ||||||
|     @property() |     @property() | ||||||
|     onTokenChange: TokenHandler = (token: string) => { |     onTokenChange: TokenHandler = (token: string) => { | ||||||
|         this.host.submit({ component: "ak-stage-captcha", token }); |         this.host.submit({ component: "ak-stage-captcha", token }); | ||||||
| @ -53,8 +67,70 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe | |||||||
|  |  | ||||||
|     constructor() { |     constructor() { | ||||||
|         super(); |         super(); | ||||||
|         this.captchaContainer = document.createElement("div"); |         this.captchaFrame = document.createElement("iframe"); | ||||||
|         this.captchaContainer.id = captchaContainerID; |         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>) { |     updated(changedProperties: PropertyValues<this>) { | ||||||
| @ -64,15 +140,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe | |||||||
|             this.scriptElement.async = true; |             this.scriptElement.async = true; | ||||||
|             this.scriptElement.defer = true; |             this.scriptElement.defer = true; | ||||||
|             this.scriptElement.dataset.akCaptchaScript = "true"; |             this.scriptElement.dataset.akCaptchaScript = "true"; | ||||||
|             this.scriptElement.onload = () => { |             this.scriptElement.onload = async () => { | ||||||
|                 console.debug("authentik/stages/captcha: script loaded"); |                 console.debug("authentik/stages/captcha: script loaded"); | ||||||
|                 let found = false; |                 let found = false; | ||||||
|                 let lastError = undefined; |                 let lastError = undefined; | ||||||
|                 this.handlers.forEach((handler) => { |                 this.handlers.forEach(async (handler) => { | ||||||
|                     let handlerFound = false; |                     let handlerFound = false; | ||||||
|                     try { |                     try { | ||||||
|                         console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`); |                         console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`); | ||||||
|                         handlerFound = handler.apply(this); |                         handlerFound = await handler.apply(this); | ||||||
|                         if (handlerFound) { |                         if (handlerFound) { | ||||||
|                             console.debug( |                             console.debug( | ||||||
|                                 `authentik/stages/captcha[${handler.name}]: handler succeeded`, |                                 `authentik/stages/captcha[${handler.name}]: handler succeeded`, | ||||||
| @ -96,51 +172,79 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe | |||||||
|                 .querySelectorAll("[data-ak-captcha-script=true]") |                 .querySelectorAll("[data-ak-captcha-script=true]") | ||||||
|                 .forEach((el) => el.remove()); |                 .forEach((el) => el.remove()); | ||||||
|             document.head.appendChild(this.scriptElement); |             document.head.appendChild(this.scriptElement); | ||||||
|  |             if (!this.challenge.interactive) { | ||||||
|  |                 document.appendChild(this.captchaDocumentContainer); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     handleGReCaptcha(): boolean { |     async handleGReCaptcha(): Promise<boolean> { | ||||||
|         if (!Object.hasOwn(window, "grecaptcha")) { |         if (!Object.hasOwn(window, "grecaptcha")) { | ||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|         this.captchaInteractive = false; |         if (this.challenge.interactive) { | ||||||
|         document.body.appendChild(this.captchaContainer); |             this.renderFrame( | ||||||
|  |                 html`<div | ||||||
|  |                     class="g-recaptcha" | ||||||
|  |                     data-sitekey="${this.challenge.siteKey}" | ||||||
|  |                     data-callback="callback" | ||||||
|  |                 ></div>`, | ||||||
|  |             ); | ||||||
|  |         } else { | ||||||
|             grecaptcha.ready(() => { |             grecaptcha.ready(() => { | ||||||
|             const captchaId = grecaptcha.render(this.captchaContainer, { |                 const captchaId = grecaptcha.render(this.captchaDocumentContainer, { | ||||||
|                     sitekey: this.challenge.siteKey, |                     sitekey: this.challenge.siteKey, | ||||||
|                     callback: this.onTokenChange, |                     callback: this.onTokenChange, | ||||||
|                     size: "invisible", |                     size: "invisible", | ||||||
|                 }); |                 }); | ||||||
|                 grecaptcha.execute(captchaId); |                 grecaptcha.execute(captchaId); | ||||||
|             }); |             }); | ||||||
|  |         } | ||||||
|         return true; |         return true; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     handleHCaptcha(): boolean { |     async handleHCaptcha(): Promise<boolean> { | ||||||
|         if (!Object.hasOwn(window, "hcaptcha")) { |         if (!Object.hasOwn(window, "hcaptcha")) { | ||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|         this.captchaInteractive = false; |         if (this.challenge.interactive) { | ||||||
|         document.body.appendChild(this.captchaContainer); |             this.renderFrame( | ||||||
|         const captchaId = hcaptcha.render(this.captchaContainer, { |                 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, |                 sitekey: this.challenge.siteKey, | ||||||
|                 callback: this.onTokenChange, |                 callback: this.onTokenChange, | ||||||
|                 size: "invisible", |                 size: "invisible", | ||||||
|             }); |             }); | ||||||
|             hcaptcha.execute(captchaId); |             hcaptcha.execute(captchaId); | ||||||
|  |         } | ||||||
|         return true; |         return true; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     handleTurnstile(): boolean { |     async handleTurnstile(): Promise<boolean> { | ||||||
|         if (!Object.hasOwn(window, "turnstile")) { |         if (!Object.hasOwn(window, "turnstile")) { | ||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|         this.captchaInteractive = false; |         if (this.challenge.interactive) { | ||||||
|         document.body.appendChild(this.captchaContainer); |             this.renderFrame( | ||||||
|         (window as unknown as TurnstileWindow).turnstile.render(`#${captchaContainerID}`, { |                 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, |                 sitekey: this.challenge.siteKey, | ||||||
|                 callback: this.onTokenChange, |                 callback: this.onTokenChange, | ||||||
|             }); |             }); | ||||||
|  |         } | ||||||
|         return true; |         return true; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @ -148,13 +252,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe | |||||||
|         if (this.error) { |         if (this.error) { | ||||||
|             return html`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`; |             return html`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`; | ||||||
|         } |         } | ||||||
|         if (this.captchaInteractive) { |         if (this.challenge.interactive) { | ||||||
|             return html`${this.captchaContainer}`; |             return html`${this.captchaFrame}`; | ||||||
|         } |         } | ||||||
|         return html`<ak-empty-state loading header=${msg("Verifying...")}></ak-empty-state>`; |         return html`<ak-empty-state loading header=${msg("Verifying...")}></ak-empty-state>`; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     render() { |     render() { | ||||||
|  |         if (this.embedded) { | ||||||
|  |             if (!this.challenge.interactive) { | ||||||
|  |                 return html``; | ||||||
|  |             } | ||||||
|  |             return this.renderBody(); | ||||||
|  |         } | ||||||
|         if (!this.challenge) { |         if (!this.challenge) { | ||||||
|             return html`<ak-empty-state loading> </ak-empty-state>`; |             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` |                 ? html` | ||||||
|                       <input name="captchaToken" type="hidden" .value="${this.captchaToken}" /> |                       <input name="captchaToken" type="hidden" .value="${this.captchaToken}" /> | ||||||
|                       <ak-stage-captcha |                       <ak-stage-captcha | ||||||
|                           style="visibility: hidden; position:absolute;" |  | ||||||
|                           .challenge=${this.challenge.captchaStage} |                           .challenge=${this.challenge.captchaStage} | ||||||
|                           .onTokenChange=${(token: string) => { |                           .onTokenChange=${(token: string) => { | ||||||
|                               this.captchaToken = token; |                               this.captchaToken = token; | ||||||
|                           }} |                           }} | ||||||
|  |                           embedded | ||||||
|                       ></ak-stage-captcha> |                       ></ak-stage-captcha> | ||||||
|                   ` |                   ` | ||||||
|                 : nothing} |                 : nothing} | ||||||
|  | |||||||
| @ -2,15 +2,17 @@ | |||||||
| title: Captcha stage | 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 | Currently supported implementations: | ||||||
| -   hCaptcha |  | ||||||
| -   Turnstile | -   [Google reCAPTCHA](#google-recaptcha) | ||||||
|  | -   [hCaptcha](#hcaptcha) | ||||||
|  | -   [Cloudflare Turnstile](#cloudflare-turnstile) | ||||||
|  |  | ||||||
| ## Captcha provider configuration | ## 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. | 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 | #### Configuration options | ||||||
|  |  | ||||||
| -   JS URL: `https://www.recaptcha.net/recaptcha/api.js` | -   Interactive: Enabled when using reCAPTCHA v3 | ||||||
| -   API URL: `https://www.recaptcha.net/recaptcha/api/siteverify` |  | ||||||
| -   Score minimum threshold: `0.5` | -   Score minimum threshold: `0.5` | ||||||
| -   Score maximum threshold: `1` | -   Score maximum threshold: `1` | ||||||
|  | -   JS URL: `https://www.recaptcha.net/recaptcha/api.js` | ||||||
|  | -   API URL: `https://www.recaptcha.net/recaptcha/api/siteverify` | ||||||
|  |  | ||||||
| ### hCaptcha | ### hCaptcha | ||||||
|  |  | ||||||
| @ -29,6 +32,7 @@ See https://docs.hcaptcha.com/switch | |||||||
|  |  | ||||||
| #### Configuration options | #### Configuration options | ||||||
|  |  | ||||||
|  | -   Interactive: Enabled | ||||||
| -   JS URL: `https://js.hcaptcha.com/1/api.js` | -   JS URL: `https://js.hcaptcha.com/1/api.js` | ||||||
| -   API URL: `https://api.hcaptcha.com/siteverify` | -   API URL: `https://api.hcaptcha.com/siteverify` | ||||||
|  |  | ||||||
| @ -37,16 +41,13 @@ See https://docs.hcaptcha.com/switch | |||||||
| -   Score minimum threshold: `0` | -   Score minimum threshold: `0` | ||||||
| -   Score maximum threshold: `0.5` | -   Score maximum threshold: `0.5` | ||||||
|  |  | ||||||
| ### Turnstile | ### Cloudflare Turnstile | ||||||
|  |  | ||||||
| See https://developers.cloudflare.com/turnstile/get-started/migrating-from-recaptcha | 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 | #### Configuration options | ||||||
|  |  | ||||||
|  | -   Interactive: Enabled if the Turnstile instance is configured as visible or managed | ||||||
| -   JS URL: `https://challenges.cloudflare.com/turnstile/v0/api.js` | -   JS URL: `https://challenges.cloudflare.com/turnstile/v0/api.js` | ||||||
| -   API URL: `https://challenges.cloudflare.com/turnstile/v0/siteverify` | -   API URL: `https://challenges.cloudflare.com/turnstile/v0/siteverify` | ||||||
|  |  | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens L.
					Jens L.