Skip to content

Commit

Permalink
web/admin: rework captcha stage (#9256)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
BeryJu authored Apr 15, 2024
1 parent 6ddfe17 commit bc9984f
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 73 deletions.
1 change: 1 addition & 0 deletions tests/e2e/test_flows_enroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def initial_stages(self):
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)
Expand Down
7 changes: 4 additions & 3 deletions web/src/flow/FlowExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
`;
}

Expand Down
65 changes: 12 additions & 53 deletions web/src/flow/stages/access_denied/AccessDeniedStage.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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">
Expand Down
119 changes: 119 additions & 0 deletions web/src/flow/stages/captcha/CaptchaStage.stories.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
},
};
58 changes: 41 additions & 17 deletions web/src/flow/stages/captcha/CaptchaStage.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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[] {
Expand All @@ -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;
Expand All @@ -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`,
Expand All @@ -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({
Expand All @@ -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) => {
Expand All @@ -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({
Expand All @@ -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")}>
Expand All @@ -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">
Expand Down
4 changes: 4 additions & 0 deletions website/docs/flow/stages/captcha/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
:::

0 comments on commit bc9984f

Please sign in to comment.