Skip to content

Commit

Permalink
stages/captcha: Run interactive captcha in Frame (#11857)
Browse files Browse the repository at this point in the history
* initial turnstile frame

Signed-off-by: Jens Langhammer <[email protected]>

* add interactive flag

Signed-off-by: Jens Langhammer <[email protected]>

* add interactive support for all

Signed-off-by: Jens Langhammer <[email protected]>

* fix missing migration

Signed-off-by: Jens Langhammer <[email protected]>

* don't hide in identification stage if interactive

Signed-off-by: Jens Langhammer <[email protected]>

* fixup

Signed-off-by: Jens Langhammer <[email protected]>

* require less hacky css

Signed-off-by: Jens Langhammer <[email protected]>

* update docs

Signed-off-by: Jens Langhammer <[email protected]>

---------

Signed-off-by: Jens Langhammer <[email protected]>
  • Loading branch information
BeryJu authored Nov 11, 2024
1 parent 10d5048 commit 4f1ddc5
Show file tree
Hide file tree
Showing 15 changed files with 363 additions and 145 deletions.
1 change: 1 addition & 0 deletions authentik/stages/captcha/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Meta:
"private_key",
"js_url",
"api_url",
"interactive",
"score_min_threshold",
"score_max_threshold",
"error_on_invalid_score",
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
4 changes: 3 additions & 1 deletion authentik/stages/captcha/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@


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."))
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_max_threshold = models.FloatField(default=1.0) # Default values for reCaptcha

Expand Down
9 changes: 6 additions & 3 deletions authentik/stages/captcha/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.http.response import HttpResponse
from django.utils.translation import gettext as _
from requests import RequestException
from rest_framework.fields import CharField
from rest_framework.fields import BooleanField, CharField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger

Expand All @@ -24,10 +24,12 @@
class CaptchaChallenge(WithUserInfoChallenge):
"""Site public key"""

site_key = CharField()
js_url = CharField()
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):
"""Validate captcha token"""
Expand Down Expand Up @@ -103,6 +105,7 @@ def get_challenge(self, *args, **kwargs) -> Challenge:
data={
"js_url": self.executor.current_stage.js_url,
"site_key": self.executor.current_stage.public_key,
"interactive": self.executor.current_stage.interactive,
}
)

Expand Down
1 change: 1 addition & 0 deletions authentik/stages/identification/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ def get_challenge(self) -> Challenge:
{
"js_url": current_stage.captcha_stage.js_url,
"site_key": current_stage.captcha_stage.public_key,
"interactive": current_stage.captcha_stage.interactive,
}
if current_stage.captcha_stage
else None
Expand Down
4 changes: 4 additions & 0 deletions blueprints/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -9781,6 +9781,10 @@
"minLength": 1,
"title": "Api url"
},
"interactive": {
"type": "boolean",
"title": "Interactive"
},
"score_min_threshold": {
"type": "number",
"title": "Score min threshold"
Expand Down
9 changes: 9 additions & 0 deletions schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39220,7 +39220,10 @@ components:
type: string
js_url:
type: string
interactive:
type: boolean
required:
- interactive
- js_url
- pending_user
- pending_user_avatar
Expand Down Expand Up @@ -39276,6 +39279,8 @@ components:
type: string
api_url:
type: string
interactive:
type: boolean
score_min_threshold:
type: number
format: double
Expand Down Expand Up @@ -39322,6 +39327,8 @@ components:
api_url:
type: string
minLength: 1
interactive:
type: boolean
score_min_threshold:
type: number
format: double
Expand Down Expand Up @@ -47732,6 +47739,8 @@ components:
api_url:
type: string
minLength: 1
interactive:
type: boolean
score_min_threshold:
type: number
format: double
Expand Down
10 changes: 10 additions & 0 deletions web/src/admin/stages/captcha/CaptchaStageForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";

Expand Down Expand Up @@ -80,6 +81,15 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
)}
</p>
</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
label=${msg("Score minimum threshold")}
required
Expand Down
6 changes: 5 additions & 1 deletion web/src/common/purify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ export const DOM_PURIFY_STRICT: DOMPurify.Config = {
ALLOWED_TAGS: ["#text"],
};

export async function renderStatic(input: TemplateResult): Promise<string> {
return await collectResult(render(input));
}

export function purify(input: TemplateResult): TemplateResult {
return html`${until(
(async () => {
const rendered = await collectResult(render(input));
const rendered = await renderStatic(input);
const purified = DOMPurify.sanitize(rendered);
return html`${unsafeHTML(purified)}`;
})(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
?loading="${this.authenticating}"
header=${this.authenticating
? msg("Authenticating...")
: this.errorMessage || msg("Failed to authenticate")}
: this.errorMessage || msg("Loading")}
icon="fa-times"
>
</ak-empty-state>
Expand Down
140 changes: 54 additions & 86 deletions web/src/flow/stages/captcha/CaptchaStage.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import "../../../stories/flow-interface";
import "./CaptchaStage";

export default {
title: "Flow / Stages / CaptchaStage",
title: "Flow / Stages / Captcha",
};

export const LoadingNoChallenge = () => {
Expand All @@ -25,92 +25,60 @@ export const LoadingNoChallenge = () => {
</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: {
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",
},
function captchaFactory(challenge: CaptchaChallenge): 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-captcha .challenge=${challenge}></ak-stage-captcha>
</div>
</div></div
></ak-storybook-interface>`;
},
},
};

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: {
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",
},
args: {
theme: "automatic",
challenge: challenge,
},
},
};

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: {
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",
argTypes: {
theme: {
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
control: {
type: "select",
},
},
},
},
};
};
}

export const ChallengeHCaptcha = captchaFactory({
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://js.hcaptcha.com/1/api.js",
siteKey: "10000000-ffff-ffff-ffff-000000000001",
interactive: true,
} as CaptchaChallenge);

// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
export const ChallengeTurnstileVisible = captchaFactory({
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
siteKey: "1x00000000000000000000AA",
interactive: true,
} as CaptchaChallenge);
export const ChallengeTurnstileInvisible = captchaFactory({
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
siteKey: "1x00000000000000000000BB",
interactive: true,
} as CaptchaChallenge);
export const ChallengeTurnstileForce = captchaFactory({
pendingUser: "foo",
pendingUserAvatar: "https://picsum.photos/64",
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
siteKey: "3x00000000000000000000FF",
interactive: true,
} as CaptchaChallenge);
Loading

0 comments on commit 4f1ddc5

Please sign in to comment.