Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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