stages/captcha: Run interactive captcha in Frame (#11857) * initial turnstile frame * add interactive flag * add interactive support for all * fix missing migration * don't hide in identification stage if interactive * fixup * require less hacky css * update docs --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Jens L. <jens@goauthentik.io>
This commit is contained in:
committed by
GitHub
parent
f6526d1be9
commit
7ed268fef4
@ -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,92 +25,60 @@ export const LoadingNoChallenge = () => {
|
|||||||
</ak-storybook-interface>`;
|
</ak-storybook-interface>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChallengeGoogleReCaptcha: StoryObj = {
|
function captchaFactory(challenge: CaptchaChallenge): StoryObj {
|
||||||
render: ({ theme, challenge }) => {
|
return {
|
||||||
return html`<ak-storybook-interface theme=${theme}>
|
render: ({ theme, challenge }) => {
|
||||||
<div class="pf-c-login">
|
return html`<ak-storybook-interface theme=${theme}>
|
||||||
<div class="pf-c-login__container">
|
<div class="pf-c-login">
|
||||||
<div class="pf-c-login__main">
|
<div class="pf-c-login__container">
|
||||||
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
|
<div class="pf-c-login__main">
|
||||||
</div>
|
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
|
||||||
</div></div
|
</div>
|
||||||
></ak-storybook-interface>`;
|
</div></div
|
||||||
},
|
></ak-storybook-interface>`;
|
||||||
args: {
|
},
|
||||||
theme: "automatic",
|
args: {
|
||||||
challenge: {
|
theme: "automatic",
|
||||||
pendingUser: "foo",
|
challenge: challenge,
|
||||||
pendingUserAvatar: "https://picsum.photos/64",
|
},
|
||||||
jsUrl: "https://www.google.com/recaptcha/api.js",
|
argTypes: {
|
||||||
siteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
|
theme: {
|
||||||
} as CaptchaChallenge,
|
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
|
||||||
},
|
control: {
|
||||||
argTypes: {
|
type: "select",
|
||||||
theme: {
|
},
|
||||||
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
|
|
||||||
control: {
|
|
||||||
type: "select",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export const ChallengeHCaptcha: StoryObj = {
|
export const ChallengeHCaptcha = captchaFactory({
|
||||||
render: ({ theme, challenge }) => {
|
pendingUser: "foo",
|
||||||
return html`<ak-storybook-interface theme=${theme}>
|
pendingUserAvatar: "https://picsum.photos/64",
|
||||||
<div class="pf-c-login">
|
jsUrl: "https://js.hcaptcha.com/1/api.js",
|
||||||
<div class="pf-c-login__container">
|
siteKey: "10000000-ffff-ffff-ffff-000000000001",
|
||||||
<div class="pf-c-login__main">
|
interactive: true,
|
||||||
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
|
} as CaptchaChallenge);
|
||||||
</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 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>`;
|
pendingUser: "foo",
|
||||||
},
|
pendingUserAvatar: "https://picsum.photos/64",
|
||||||
args: {
|
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||||
theme: "automatic",
|
siteKey: "1x00000000000000000000BB",
|
||||||
challenge: {
|
interactive: true,
|
||||||
pendingUser: "foo",
|
} as CaptchaChallenge);
|
||||||
pendingUserAvatar: "https://picsum.photos/64",
|
export const ChallengeTurnstileForce = captchaFactory({
|
||||||
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
pendingUser: "foo",
|
||||||
siteKey: "1x00000000000000000000BB",
|
pendingUserAvatar: "https://picsum.photos/64",
|
||||||
} as CaptchaChallenge,
|
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||||
},
|
siteKey: "3x00000000000000000000FF",
|
||||||
argTypes: {
|
interactive: true,
|
||||||
theme: {
|
} as CaptchaChallenge);
|
||||||
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
|
|
||||||
control: {
|
|
||||||
type: "select",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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(
|
||||||
grecaptcha.ready(() => {
|
html`<div
|
||||||
const captchaId = grecaptcha.render(this.captchaContainer, {
|
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,
|
sitekey: this.challenge.siteKey,
|
||||||
callback: this.onTokenChange,
|
callback: this.onTokenChange,
|
||||||
size: "invisible",
|
size: "invisible",
|
||||||
});
|
});
|
||||||
grecaptcha.execute(captchaId);
|
hcaptcha.execute(captchaId);
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHCaptcha(): boolean {
|
|
||||||
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);
|
|
||||||
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
|
||||||
sitekey: this.challenge.siteKey,
|
class="cf-turnstile"
|
||||||
callback: this.onTokenChange,
|
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;
|
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