diff --git a/authentik/stages/captcha/api.py b/authentik/stages/captcha/api.py index bd83c59f74d5..cb33ff4d2c01 100644 --- a/authentik/stages/captcha/api.py +++ b/authentik/stages/captcha/api.py @@ -17,6 +17,7 @@ class Meta: "private_key", "js_url", "api_url", + "interactive", "score_min_threshold", "score_max_threshold", "error_on_invalid_score", diff --git a/authentik/stages/captcha/migrations/0004_captchastage_interactive.py b/authentik/stages/captcha/migrations/0004_captchastage_interactive.py new file mode 100644 index 000000000000..7c5ce762086b --- /dev/null +++ b/authentik/stages/captcha/migrations/0004_captchastage_interactive.py @@ -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), + ), + ] diff --git a/authentik/stages/captcha/models.py b/authentik/stages/captcha/models.py index 02f20882f12c..fb5a6dac2893 100644 --- a/authentik/stages/captcha/models.py +++ b/authentik/stages/captcha/models.py @@ -9,11 +9,13 @@ 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 diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py index 73bcff5dec15..bb7a8543ca05 100644 --- a/authentik/stages/captcha/stage.py +++ b/authentik/stages/captcha/stage.py @@ -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 @@ 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 @@ def get_challenge(self, *args, **kwargs) -> Challenge: data={ "js_url": self.executor.current_stage.js_url, "site_key": self.executor.current_stage.public_key, + "interactive": self.executor.current_stage.interactive, } ) diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 1d2dfe8cab4f..6531f971dfed 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -223,6 +223,7 @@ def get_challenge(self) -> Challenge: { "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 diff --git a/blueprints/schema.json b/blueprints/schema.json index e95a4b3c6438..9c73b1f5fc35 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -9781,6 +9781,10 @@ "minLength": 1, "title": "Api url" }, + "interactive": { + "type": "boolean", + "title": "Interactive" + }, "score_min_threshold": { "type": "number", "title": "Score min threshold" diff --git a/schema.yml b/schema.yml index 5129168e334c..6ba28e7c2b19 100644 --- a/schema.yml +++ b/schema.yml @@ -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 diff --git a/web/src/admin/stages/captcha/CaptchaStageForm.ts b/web/src/admin/stages/captcha/CaptchaStageForm.ts index 5bf58079e190..fff23929b6f3 100644 --- a/web/src/admin/stages/captcha/CaptchaStageForm.ts +++ b/web/src/admin/stages/captcha/CaptchaStageForm.ts @@ -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 { )}

+ + { + 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)}`; })(), diff --git a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn.ts b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn.ts index 3bbb2e9892a7..5277f45d72e2 100644 --- a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn.ts +++ b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn.ts @@ -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" > diff --git a/web/src/flow/stages/captcha/CaptchaStage.stories.ts b/web/src/flow/stages/captcha/CaptchaStage.stories.ts index a6196d976604..bd4eb77916a3 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.stories.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.stories.ts @@ -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 = () => { `; }; -export const ChallengeGoogleReCaptcha: StoryObj = { - render: ({ theme, challenge }) => { - return html` - `; - }, - 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` + `; }, - }, -}; - -export const ChallengeHCaptcha: StoryObj = { - render: ({ theme, challenge }) => { - return html` - `; - }, - 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", - }, + args: { + theme: "automatic", + challenge: challenge, }, - }, -}; - -export const ChallengeTurnstile: StoryObj = { - render: ({ theme, challenge }) => { - return html` - `; - }, - 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", + 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); + +// 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); diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index af37ab383c64..7e19e95ef464 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -1,16 +1,17 @@ /// +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 { 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 { this.host.submit({ component: "ak-stage-captcha", token }); @@ -53,8 +67,70 @@ export class CaptchaStage extends BaseStage, + ) { + 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` + + + ${captchaElement} + + + + `, + ), + ); + this.captchaFrame.contentWindow?.document.close(); } updated(changedProperties: PropertyValues) { @@ -64,15 +140,15 @@ export class CaptchaStage extends BaseStage { + 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 el.remove()); document.head.appendChild(this.scriptElement); + if (!this.challenge.interactive) { + document.appendChild(this.captchaDocumentContainer); + } } } - handleGReCaptcha(): boolean { + async handleGReCaptcha(): Promise { if (!Object.hasOwn(window, "grecaptcha")) { return false; } - this.captchaInteractive = false; - document.body.appendChild(this.captchaContainer); - grecaptcha.ready(() => { - const captchaId = grecaptcha.render(this.captchaContainer, { - sitekey: this.challenge.siteKey, - callback: this.onTokenChange, - size: "invisible", + if (this.challenge.interactive) { + this.renderFrame( + html`
`, + ); + } else { + grecaptcha.ready(() => { + const captchaId = grecaptcha.render(this.captchaDocumentContainer, { + sitekey: this.challenge.siteKey, + callback: this.onTokenChange, + size: "invisible", + }); + grecaptcha.execute(captchaId); }); - grecaptcha.execute(captchaId); - }); + } return true; } - handleHCaptcha(): boolean { + async handleHCaptcha(): Promise { if (!Object.hasOwn(window, "hcaptcha")) { return false; } - 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); + if (this.challenge.interactive) { + this.renderFrame( + html`
`, + ); + } else { + const captchaId = hcaptcha.render(this.captchaDocumentContainer, { + sitekey: this.challenge.siteKey, + callback: this.onTokenChange, + size: "invisible", + }); + hcaptcha.execute(captchaId); + } return true; } - handleTurnstile(): boolean { + async handleTurnstile(): Promise { 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`
`, + ); + } 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 `; } - if (this.captchaInteractive) { - return html`${this.captchaContainer}`; + if (this.challenge.interactive) { + return html`${this.captchaFrame}`; } return html``; } render() { + if (this.embedded) { + if (!this.challenge.interactive) { + return html``; + } + return this.renderBody(); + } if (!this.challenge) { return html` `; } diff --git a/web/src/flow/stages/identification/IdentificationStage.stories.ts b/web/src/flow/stages/identification/IdentificationStage.stories.ts new file mode 100644 index 000000000000..af34e5b2ad4b --- /dev/null +++ b/web/src/flow/stages/identification/IdentificationStage.stories.ts @@ -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` + + `; +}; + +function identificationFactory(challenge: IdentificationChallenge): StoryObj { + return { + render: ({ theme, challenge }) => { + return html` + `; + }, + 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, + }, +}); diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index 0983928eaad3..8dd5cdaa434b 100644 --- a/web/src/flow/stages/identification/IdentificationStage.ts +++ b/web/src/flow/stages/identification/IdentificationStage.ts @@ -282,11 +282,11 @@ export class IdentificationStage extends BaseStage< ? html` { this.captchaToken = token; }} + embedded > ` : nothing} diff --git a/website/docs/add-secure-apps/flows-stages/stages/captcha/index.md b/website/docs/add-secure-apps/flows-stages/stages/captcha/index.md index ed75f9119804..acc3d1755dab 100644 --- a/website/docs/add-secure-apps/flows-stages/stages/captcha/index.md +++ b/website/docs/add-secure-apps/flows-stages/stages/captcha/index.md @@ -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`