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