stages/identification: refresh captcha on failure (#13697)

* refactor cleanup behavior after stage form submit

* refresh captcha on failing Identification stage

* Revert "stages/identification: check captcha after checking authentication (#13533)"

This reverts commit b7beac6795.

Including a Captcha stage in an Identification stage is partially to
prevent password spraying attacks. The reverted commit negated this
feature to fix a UX bug. After 6fde42a9170, the functionality can now be
reinstated.

---------

Co-authored-by: Simonyi Gergő <gergo@goauthentik.io>
This commit is contained in:
Jens L.
2025-03-28 14:16:13 +01:00
committed by GitHub
parent 63a118a2ba
commit 5af907db0c
4 changed files with 108 additions and 43 deletions

View File

@ -142,35 +142,38 @@ class IdentificationChallengeResponse(ChallengeResponse):
raise ValidationError("Failed to authenticate.") raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user self.pre_user = pre_user
# Password check
if current_stage.password_stage:
password = attrs.get("password", None)
if not password:
self.stage.logger.warning("Password not set for ident+auth attempt")
try:
with start_span(
op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)",
):
user = authenticate(
self.stage.request,
current_stage.password_stage.backends,
current_stage,
username=self.pre_user.username,
password=password,
)
if not user:
raise ValidationError("Failed to authenticate.")
self.pre_user = user
except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc
# Captcha check # Captcha check
if captcha_stage := current_stage.captcha_stage: if captcha_stage := current_stage.captcha_stage:
captcha_token = attrs.get("captcha_token", None) captcha_token = attrs.get("captcha_token", None)
if not captcha_token: if not captcha_token:
self.stage.logger.warning("Token not set for captcha attempt") self.stage.logger.warning("Token not set for captcha attempt")
verify_captcha_token(captcha_stage, captcha_token, client_ip) verify_captcha_token(captcha_stage, captcha_token, client_ip)
# Password check
if not current_stage.password_stage:
# No password stage select, don't validate the password
return attrs
password = attrs.get("password", None)
if not password:
self.stage.logger.warning("Password not set for ident+auth attempt")
try:
with start_span(
op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)",
):
user = authenticate(
self.stage.request,
current_stage.password_stage.backends,
current_stage,
username=self.pre_user.username,
password=password,
)
if not user:
raise ValidationError("Failed to authenticate.")
self.pre_user = user
except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc
return attrs return attrs

View File

@ -72,7 +72,9 @@ export class BaseStage<
} }
return this.host?.submit(object as unknown as Tout).then((successful) => { return this.host?.submit(object as unknown as Tout).then((successful) => {
if (successful) { if (successful) {
this.cleanup(); this.onSubmitSuccess();
} else {
this.onSubmitFailure();
} }
return successful; return successful;
}); });
@ -124,7 +126,11 @@ export class BaseStage<
`; `;
} }
cleanup(): void { onSubmitSuccess(): void {
// Method that can be overridden by stages
return;
}
onSubmitFailure(): void {
// Method that can be overridden by stages // Method that can be overridden by stages
return; return;
} }

View File

@ -9,7 +9,7 @@ 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 { P, match } from "ts-pattern"; import { P, match } from "ts-pattern";
import type { TurnstileObject } from "turnstile-types"; import type * as _ from "turnstile-types";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
@ -24,10 +24,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api"; import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
interface TurnstileWindow extends Window {
turnstile: TurnstileObject;
}
type TokenHandler = (token: string) => void; type TokenHandler = (token: string) => void;
type Dims = { height: number }; type Dims = { height: number };
@ -52,6 +48,8 @@ type CaptchaHandler = {
name: string; name: string;
interactive: () => Promise<unknown>; interactive: () => Promise<unknown>;
execute: () => Promise<unknown>; execute: () => Promise<unknown>;
refreshInteractive: () => Promise<unknown>;
refresh: () => Promise<unknown>;
}; };
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces // A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
@ -119,6 +117,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
this.host.submit({ component: "ak-stage-captcha", token }); this.host.submit({ component: "ak-stage-captcha", token });
}; };
@property({ attribute: false })
refreshedAt = new Date();
@state()
activeHandler?: CaptchaHandler = undefined;
@state() @state()
error?: string; error?: string;
@ -127,16 +131,22 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
name: "grecaptcha", name: "grecaptcha",
interactive: this.renderGReCaptchaFrame, interactive: this.renderGReCaptchaFrame,
execute: this.executeGReCaptcha, execute: this.executeGReCaptcha,
refreshInteractive: this.refreshGReCaptchaFrame,
refresh: this.refreshGReCaptcha,
}, },
{ {
name: "hcaptcha", name: "hcaptcha",
interactive: this.renderHCaptchaFrame, interactive: this.renderHCaptchaFrame,
execute: this.executeHCaptcha, execute: this.executeHCaptcha,
refreshInteractive: this.refreshHCaptchaFrame,
refresh: this.refreshHCaptcha,
}, },
{ {
name: "turnstile", name: "turnstile",
interactive: this.renderTurnstileFrame, interactive: this.renderTurnstileFrame,
execute: this.executeTurnstile, execute: this.executeTurnstile,
refreshInteractive: this.refreshTurnstileFrame,
refresh: this.refreshTurnstile,
}, },
]; ];
@ -230,6 +240,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
}); });
} }
async refreshGReCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.grecaptcha.reset();
}
async refreshGReCaptcha() {
window.grecaptcha.reset();
window.grecaptcha.execute();
}
async renderHCaptchaFrame() { async renderHCaptchaFrame() {
this.renderFrame( this.renderFrame(
html`<div html`<div
@ -251,6 +270,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
); );
} }
async refreshHCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.hcaptcha.reset();
}
async refreshHCaptcha() {
window.hcaptcha.reset();
window.hcaptcha.execute();
}
async renderTurnstileFrame() { async renderTurnstileFrame() {
this.renderFrame( this.renderFrame(
html`<div html`<div
@ -262,13 +290,18 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
} }
async executeTurnstile() { async executeTurnstile() {
return (window as unknown as TurnstileWindow).turnstile.render( return window.turnstile.render(this.captchaDocumentContainer, {
this.captchaDocumentContainer, sitekey: this.challenge.siteKey,
{ callback: this.onTokenChange,
sitekey: this.challenge.siteKey, });
callback: this.onTokenChange, }
},
); async refreshTurnstileFrame() {
(this.captchaFrame.contentWindow as typeof window)?.turnstile.reset();
}
async refreshTurnstile() {
window.turnstile.reset();
} }
async renderFrame(captchaElement: TemplateResult) { async renderFrame(captchaElement: TemplateResult) {
@ -336,16 +369,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name)); const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
let lastError = undefined; let lastError = undefined;
let found = false; let found = false;
for (const { name, interactive, execute } of handlers) { for (const handler of handlers) {
console.debug(`authentik/stages/captcha: trying handler ${name}`); console.debug(`authentik/stages/captcha: trying handler ${handler.name}`);
try { try {
const runner = this.challenge.interactive ? interactive : execute; const runner = this.challenge.interactive
? handler.interactive
: handler.execute;
await runner.apply(this); await runner.apply(this);
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`); console.debug(`authentik/stages/captcha[${handler.name}]: handler succeeded`);
found = true; found = true;
this.activeHandler = handler;
break; break;
} catch (exc) { } catch (exc) {
console.debug(`authentik/stages/captcha[${name}]: handler failed`); console.debug(`authentik/stages/captcha[${handler.name}]: handler failed`);
console.debug(exc); console.debug(exc);
lastError = exc; lastError = exc;
} }
@ -370,6 +406,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
document.body.appendChild(this.captchaDocumentContainer); document.body.appendChild(this.captchaDocumentContainer);
} }
} }
updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("refreshedAt") || !this.challenge) {
return;
}
console.debug("authentik/stages/captcha: refresh triggered");
if (this.challenge.interactive) {
this.activeHandler?.refreshInteractive.apply(this);
} else {
this.activeHandler?.refresh.apply(this);
}
}
} }
declare global { declare global {

View File

@ -49,6 +49,8 @@ export class IdentificationStage extends BaseStage<
@state() @state()
captchaToken = ""; captchaToken = "";
@state()
captchaRefreshedAt = new Date();
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
@ -179,12 +181,16 @@ export class IdentificationStage extends BaseStage<
this.form.appendChild(totp); this.form.appendChild(totp);
} }
cleanup(): void { onSubmitSuccess(): void {
if (this.form) { if (this.form) {
this.form.remove(); this.form.remove();
} }
} }
onSubmitFailure(): void {
this.captchaRefreshedAt = new Date();
}
renderSource(source: LoginSource): TemplateResult { renderSource(source: LoginSource): TemplateResult {
const icon = renderSourceIcon(source.name, source.iconUrl); const icon = renderSourceIcon(source.name, source.iconUrl);
return html`<li class="pf-c-login__main-footer-links-item"> return html`<li class="pf-c-login__main-footer-links-item">
@ -287,6 +293,7 @@ export class IdentificationStage extends BaseStage<
.onTokenChange=${(token: string) => { .onTokenChange=${(token: string) => {
this.captchaToken = token; this.captchaToken = token;
}} }}
.refreshedAt=${this.captchaRefreshedAt}
embedded embedded
></ak-stage-captcha> ></ak-stage-captcha>
` `