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`