web/admin: rework captcha stage (#9256)
* web/admin: rework captcha stage Signed-off-by: Jens Langhammer <jens@goauthentik.io> * idk man selenium is an enigma to me Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@ -129,6 +129,7 @@ class TestFlowsEnroll(SeleniumTestCase):
|
||||
prompt_stage.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
|
||||
|
||||
# Second prompt stage
|
||||
sleep(1)
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
prompt_stage = self.get_shadow_root("ak-stage-prompt", flow_executor)
|
||||
wait = WebDriverWait(prompt_stage, self.wait_timeout)
|
||||
|
@ -440,13 +440,14 @@ export class FlowExecutor extends Interface implements StageHost {
|
||||
const logo = html`<div class="pf-c-login__main-header pf-c-brand ak-brand">
|
||||
<img src="${first(this.brand?.brandingLogo, "")}" alt="authentik Logo" />
|
||||
</div>`;
|
||||
const fallbackLoadSpinner = html`<ak-empty-state ?loading=${true} header=${msg("Loading")}>
|
||||
</ak-empty-state>`;
|
||||
if (!this.challenge) {
|
||||
return html`${logo}<ak-empty-state ?loading=${true} header=${msg("Loading")}>
|
||||
</ak-empty-state>`;
|
||||
return html`${logo}${fallbackLoadSpinner}`;
|
||||
}
|
||||
return html`
|
||||
${this.loading ? html`<ak-loading-overlay></ak-loading-overlay>` : nothing} ${logo}
|
||||
${until(this.renderChallenge())}
|
||||
${until(this.renderChallenge(), fallbackLoadSpinner)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -1,72 +1,26 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
import "@goauthentik/flow/FormStatic";
|
||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { CSSResult, TemplateResult, html, nothing } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFList from "@patternfly/patternfly/components/List/list.css";
|
||||
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
||||
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { AccessDeniedChallenge, FlowChallengeResponseRequest } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-stage-access-denied-icon")
|
||||
export class AccessDeniedIcon extends AKElement {
|
||||
@property()
|
||||
errorMessage?: string;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
PFBase,
|
||||
PFTitle,
|
||||
PFDivider,
|
||||
css`
|
||||
.big-icon {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
height: 5rem;
|
||||
}
|
||||
.big-icon i {
|
||||
font-size: 3rem;
|
||||
}
|
||||
.reason {
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html` <div class="pf-c-form__group">
|
||||
<p class="big-icon">
|
||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||
</p>
|
||||
<h3 class="pf-c-title pf-m-3xl reason">${msg("Request has been denied.")}</h3>
|
||||
${this.errorMessage
|
||||
? html` <hr class="pf-c-divider" />
|
||||
<p>${this.errorMessage}</p>`
|
||||
: html``}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ak-stage-access-denied")
|
||||
export class AccessDeniedStage extends BaseStage<
|
||||
AccessDeniedChallenge,
|
||||
FlowChallengeResponseRequest
|
||||
> {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFLogin, PFForm, PFList, PFFormControl, PFTitle];
|
||||
return [PFBase, PFLogin, PFForm, PFFormControl];
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
@ -90,10 +44,15 @@ export class AccessDeniedStage extends BaseStage<
|
||||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
<ak-stage-access-denied-icon
|
||||
errorMessage=${ifDefined(this.challenge.errorMessage)}
|
||||
>
|
||||
</ak-stage-access-denied-icon>
|
||||
<ak-empty-state icon="fa-times" header=${msg("Request has been denied.")}>
|
||||
${this.challenge.errorMessage
|
||||
? html`
|
||||
<div slot="body">
|
||||
<p>${this.challenge.errorMessage}</p>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</ak-empty-state>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
|
119
web/src/flow/stages/captcha/CaptchaStage.stories.ts
Normal file
119
web/src/flow/stages/captcha/CaptchaStage.stories.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import type { StoryObj } from "@storybook/web-components";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import "@patternfly/patternfly/components/Login/login.css";
|
||||
|
||||
import { CaptchaChallenge, ChallengeChoices, UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
import "../../../stories/flow-interface";
|
||||
import "./CaptchaStage";
|
||||
|
||||
export default {
|
||||
title: "Flow / Stages / CaptchaStage",
|
||||
};
|
||||
|
||||
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-captcha></ak-stage-captcha>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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: {
|
||||
type: ChallengeChoices.Native,
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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: {
|
||||
type: ChallengeChoices.Native,
|
||||
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 = {
|
||||
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: {
|
||||
type: ChallengeChoices.Native,
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
@ -1,9 +1,7 @@
|
||||
///<reference types="@hcaptcha/types"/>
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
import "@goauthentik/elements/forms/FormElement";
|
||||
import "@goauthentik/flow/FormStatic";
|
||||
import "@goauthentik/flow/stages/access_denied/AccessDeniedStage";
|
||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||
import type { TurnstileObject } from "turnstile-types";
|
||||
|
||||
@ -25,6 +23,8 @@ interface TurnstileWindow extends Window {
|
||||
turnstile: TurnstileObject;
|
||||
}
|
||||
|
||||
const captchaContainerID = "captcha-container";
|
||||
|
||||
@customElement("ak-stage-captcha")
|
||||
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
|
||||
static get styles(): CSSResult[] {
|
||||
@ -36,13 +36,23 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
@state()
|
||||
error?: string;
|
||||
|
||||
@state()
|
||||
captchaInteractive: boolean = true;
|
||||
|
||||
@state()
|
||||
captchaContainer: HTMLDivElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.captchaContainer = document.createElement("div");
|
||||
this.captchaContainer.id = captchaContainerID;
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
const script = document.createElement("script");
|
||||
script.src = this.challenge.jsUrl;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
const captchaContainer = document.createElement("div");
|
||||
document.body.appendChild(captchaContainer);
|
||||
script.onload = () => {
|
||||
console.debug("authentik/stages/captcha: script loaded");
|
||||
let found = false;
|
||||
@ -51,7 +61,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
let handlerFound = false;
|
||||
try {
|
||||
console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`);
|
||||
handlerFound = handler.apply(this, [captchaContainer]);
|
||||
handlerFound = handler.apply(this);
|
||||
if (handlerFound) {
|
||||
console.debug(
|
||||
`authentik/stages/captcha[${handler.name}]: handler succeeded`,
|
||||
@ -74,12 +84,14 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
handleGReCaptcha(container: HTMLDivElement): boolean {
|
||||
handleGReCaptcha(): boolean {
|
||||
if (!Object.hasOwn(window, "grecaptcha")) {
|
||||
return false;
|
||||
}
|
||||
this.captchaInteractive = false;
|
||||
document.body.appendChild(this.captchaContainer);
|
||||
grecaptcha.ready(() => {
|
||||
const captchaId = grecaptcha.render(container, {
|
||||
const captchaId = grecaptcha.render(this.captchaContainer, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: (token) => {
|
||||
this.host?.submit({
|
||||
@ -93,11 +105,13 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
return true;
|
||||
}
|
||||
|
||||
handleHCaptcha(container: HTMLDivElement): boolean {
|
||||
handleHCaptcha(): boolean {
|
||||
if (!Object.hasOwn(window, "hcaptcha")) {
|
||||
return false;
|
||||
}
|
||||
const captchaId = hcaptcha.render(container, {
|
||||
this.captchaInteractive = false;
|
||||
document.body.appendChild(this.captchaContainer);
|
||||
const captchaId = hcaptcha.render(this.captchaContainer, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
size: "invisible",
|
||||
callback: (token) => {
|
||||
@ -110,11 +124,13 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
return true;
|
||||
}
|
||||
|
||||
handleTurnstile(container: HTMLDivElement): boolean {
|
||||
handleTurnstile(): boolean {
|
||||
if (!Object.hasOwn(window, "turnstile")) {
|
||||
return false;
|
||||
}
|
||||
(window as unknown as TurnstileWindow).turnstile.render(container, {
|
||||
this.captchaInteractive = false;
|
||||
document.body.appendChild(this.captchaContainer);
|
||||
(window as unknown as TurnstileWindow).turnstile.render(`#${captchaContainerID}`, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: (token) => {
|
||||
this.host?.submit({
|
||||
@ -125,6 +141,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
return true;
|
||||
}
|
||||
|
||||
renderBody(): TemplateResult {
|
||||
if (this.error) {
|
||||
return html`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`;
|
||||
}
|
||||
if (this.captchaInteractive) {
|
||||
return html`${this.captchaContainer}`;
|
||||
}
|
||||
return html`<ak-empty-state
|
||||
?loading=${true}
|
||||
header=${msg("Verifying...")}
|
||||
></ak-empty-state>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
|
||||
@ -146,12 +175,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
${this.error
|
||||
? html`<ak-stage-access-denied-icon errorMessage=${ifDefined(this.error)}>
|
||||
</ak-stage-access-denied-icon>`
|
||||
: html`<div>
|
||||
<ak-spinner size=${PFSize.XLarge}></ak-spinner>
|
||||
</div>`}
|
||||
${this.renderBody()}
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
|
@ -21,3 +21,7 @@ See https://docs.hcaptcha.com/switch
|
||||
### 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.
|
||||
:::
|
||||
|
Reference in New Issue
Block a user