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:
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
`
|
`
|
||||||
|
|||||||
Reference in New Issue
Block a user