diff --git a/authentik/stages/captcha/api.py b/authentik/stages/captcha/api.py
index 8d833197cd..bd83c59f74 100644
--- a/authentik/stages/captcha/api.py
+++ b/authentik/stages/captcha/api.py
@@ -12,7 +12,15 @@ class CaptchaStageSerializer(StageSerializer):
class Meta:
model = CaptchaStage
- fields = StageSerializer.Meta.fields + ["public_key", "private_key", "js_url", "api_url"]
+ fields = StageSerializer.Meta.fields + [
+ "public_key",
+ "private_key",
+ "js_url",
+ "api_url",
+ "score_min_threshold",
+ "score_max_threshold",
+ "error_on_invalid_score",
+ ]
extra_kwargs = {"private_key": {"write_only": True}}
diff --git a/authentik/stages/captcha/migrations/0003_captchastage_error_on_invalid_score_and_more.py b/authentik/stages/captcha/migrations/0003_captchastage_error_on_invalid_score_and_more.py
new file mode 100644
index 0000000000..c9a673abe9
--- /dev/null
+++ b/authentik/stages/captcha/migrations/0003_captchastage_error_on_invalid_score_and_more.py
@@ -0,0 +1,31 @@
+# Generated by Django 5.0.6 on 2024-06-03 15:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_captcha", "0002_captchastage_api_url_captchastage_js_url_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="captchastage",
+ name="error_on_invalid_score",
+ field=models.BooleanField(
+ default=True,
+ help_text="When enabled and the received captcha score is outside of the given threshold, the stage will show an error message. When not enabled, the flow will continue, but the data from the captcha will be available in the context for policy decisions",
+ ),
+ ),
+ migrations.AddField(
+ model_name="captchastage",
+ name="score_max_threshold",
+ field=models.FloatField(default=1.0),
+ ),
+ migrations.AddField(
+ model_name="captchastage",
+ name="score_min_threshold",
+ field=models.FloatField(default=0.5),
+ ),
+ ]
diff --git a/authentik/stages/captcha/models.py b/authentik/stages/captcha/models.py
index e0e126b056..02f20882f1 100644
--- a/authentik/stages/captcha/models.py
+++ b/authentik/stages/captcha/models.py
@@ -14,6 +14,18 @@ class CaptchaStage(Stage):
public_key = models.TextField(help_text=_("Public key, acquired your captcha Provider."))
private_key = models.TextField(help_text=_("Private key, acquired your captcha Provider."))
+ score_min_threshold = models.FloatField(default=0.5) # Default values for reCaptcha
+ score_max_threshold = models.FloatField(default=1.0) # Default values for reCaptcha
+
+ error_on_invalid_score = models.BooleanField(
+ default=True,
+ help_text=_(
+ "When enabled and the received captcha score is outside of the given threshold, "
+ "the stage will show an error message. When not enabled, the flow will continue, "
+ "but the data from the captcha will be available in the context for policy decisions"
+ ),
+ )
+
js_url = models.TextField(default="https://www.recaptcha.net/recaptcha/api.js")
api_url = models.TextField(default="https://www.recaptcha.net/recaptcha/api/siteverify")
diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py
index e0a8c20d6a..e770119c5a 100644
--- a/authentik/stages/captcha/stage.py
+++ b/authentik/stages/captcha/stage.py
@@ -1,6 +1,7 @@
"""authentik captcha stage"""
from django.http.response import HttpResponse
+from django.utils.translation import gettext_lazy as _
from requests import RequestException
from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError
@@ -16,6 +17,8 @@ from authentik.lib.utils.http import get_http_session
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.captcha.models import CaptchaStage
+PLAN_CONTEXT_CAPTCHA = "captcha"
+
class CaptchaChallenge(WithUserInfoChallenge):
"""Site public key"""
@@ -48,11 +51,24 @@ class CaptchaChallengeResponse(ChallengeResponse):
)
response.raise_for_status()
data = response.json()
- if not data.get("success", False):
- raise ValidationError(f"Failed to validate token: {data.get('error-codes', '')}")
- except RequestException as exc:
- raise ValidationError("Failed to validate token") from exc
- return token
+ if stage.error_on_invalid_score:
+ if not data.get("success", False):
+ raise ValidationError(
+ _(
+ "Failed to validate token: {error}".format(
+ error=data.get("error-codes", _("Unknown error"))
+ )
+ )
+ )
+ if "score" in data:
+ score = float(data.get("score"))
+ if stage.score_max_threshold > -1 and score > stage.score_max_threshold:
+ raise ValidationError(_("Invalid captcha response"))
+ if stage.score_min_threshold > -1 and score < stage.score_min_threshold:
+ raise ValidationError(_("Invalid captcha response"))
+ except (RequestException, TypeError) as exc:
+ raise ValidationError(_("Failed to validate token")) from exc
+ return data
class CaptchaStageView(ChallengeStageView):
@@ -69,5 +85,10 @@ class CaptchaStageView(ChallengeStageView):
}
)
- def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
+ def challenge_valid(self, response: CaptchaChallengeResponse) -> HttpResponse:
+ response = response.validated_data["token"]
+ self.executor.plan.context[PLAN_CONTEXT_CAPTCHA] = {
+ "response": response,
+ "stage": self.executor.current_stage,
+ }
return self.executor.stage_ok()
diff --git a/authentik/stages/captcha/tests.py b/authentik/stages/captcha/tests.py
index d5d63d8c20..c03384803b 100644
--- a/authentik/stages/captcha/tests.py
+++ b/authentik/stages/captcha/tests.py
@@ -1,6 +1,7 @@
"""captcha tests"""
from django.urls import reverse
+from requests_mock import Mocker
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.markers import StageMarker
@@ -30,8 +31,89 @@ class TestCaptchaStage(FlowTestCase):
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
- def test_valid(self):
+ @Mocker()
+ def test_valid(self, mock: Mocker):
"""Test valid captcha"""
+ mock.post(
+ "https://www.recaptcha.net/recaptcha/api/siteverify",
+ json={
+ "success": True,
+ "score": 0.5,
+ },
+ )
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+ response = self.client.post(
+ reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+ {"token": "PASSED"},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
+
+ @Mocker()
+ def test_invalid_score_high(self, mock: Mocker):
+ """Test invalid captcha (score too high)"""
+ mock.post(
+ "https://www.recaptcha.net/recaptcha/api/siteverify",
+ json={
+ "success": True,
+ "score": 99,
+ },
+ )
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+ response = self.client.post(
+ reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+ {"token": "PASSED"},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertStageResponse(
+ response,
+ component="ak-stage-captcha",
+ response_errors={"token": [{"string": "Invalid captcha response", "code": "invalid"}]},
+ )
+
+ @Mocker()
+ def test_invalid_score_low(self, mock: Mocker):
+ """Test invalid captcha (score too low)"""
+ mock.post(
+ "https://www.recaptcha.net/recaptcha/api/siteverify",
+ json={
+ "success": True,
+ "score": -3,
+ },
+ )
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ session = self.client.session
+ session[SESSION_KEY_PLAN] = plan
+ session.save()
+ response = self.client.post(
+ reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+ {"token": "PASSED"},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertStageResponse(
+ response,
+ component="ak-stage-captcha",
+ response_errors={"token": [{"string": "Invalid captcha response", "code": "invalid"}]},
+ )
+
+ @Mocker()
+ def test_invalid_score_low_continue(self, mock: Mocker):
+ """Test invalid captcha (score too low, but continue)"""
+ self.stage.error_on_invalid_score = False
+ self.stage.save()
+ mock.post(
+ "https://www.recaptcha.net/recaptcha/api/siteverify",
+ json={
+ "success": True,
+ "score": -3,
+ },
+ )
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
session = self.client.session
session[SESSION_KEY_PLAN] = plan
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 32d40e4d4e..d32ba34e50 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -6164,6 +6164,19 @@
"type": "string",
"minLength": 1,
"title": "Api url"
+ },
+ "score_min_threshold": {
+ "type": "number",
+ "title": "Score min threshold"
+ },
+ "score_max_threshold": {
+ "type": "number",
+ "title": "Score max threshold"
+ },
+ "error_on_invalid_score": {
+ "type": "boolean",
+ "title": "Error on invalid score",
+ "description": "When enabled and the received captcha score is outside of the given threshold, the stage will show an error message. When not enabled, the flow will continue, but the data from the captcha will be available in the context for policy decisions"
}
},
"required": []
diff --git a/schema.yml b/schema.yml
index 0fc89940d8..1f47eccfcf 100644
--- a/schema.yml
+++ b/schema.yml
@@ -34067,6 +34067,18 @@ components:
type: string
api_url:
type: string
+ score_min_threshold:
+ type: number
+ format: double
+ score_max_threshold:
+ type: number
+ format: double
+ error_on_invalid_score:
+ type: boolean
+ description: When enabled and the received captcha score is outside of the
+ given threshold, the stage will show an error message. When not enabled,
+ the flow will continue, but the data from the captcha will be available
+ in the context for policy decisions
required:
- component
- meta_model_name
@@ -34101,6 +34113,18 @@ components:
api_url:
type: string
minLength: 1
+ score_min_threshold:
+ type: number
+ format: double
+ score_max_threshold:
+ type: number
+ format: double
+ error_on_invalid_score:
+ type: boolean
+ description: When enabled and the received captcha score is outside of the
+ given threshold, the stage will show an error message. When not enabled,
+ the flow will continue, but the data from the captcha will be available
+ in the context for policy decisions
required:
- name
- private_key
@@ -41182,6 +41206,18 @@ components:
api_url:
type: string
minLength: 1
+ score_min_threshold:
+ type: number
+ format: double
+ score_max_threshold:
+ type: number
+ format: double
+ error_on_invalid_score:
+ type: boolean
+ description: When enabled and the received captcha score is outside of the
+ given threshold, the stage will show an error message. When not enabled,
+ the flow will continue, but the data from the captcha will be available
+ in the context for policy decisions
PatchedCertificateKeyPairRequest:
type: object
description: CertificateKeyPair Serializer
diff --git a/web/src/admin/stages/captcha/CaptchaStageForm.ts b/web/src/admin/stages/captcha/CaptchaStageForm.ts
index cacdee5dea..27a9470989 100644
--- a/web/src/admin/stages/captcha/CaptchaStageForm.ts
+++ b/web/src/admin/stages/captcha/CaptchaStageForm.ts
@@ -1,5 +1,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/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
@@ -78,6 +80,40 @@ export class CaptchaStageForm extends BaseStageForm
+ ${msg( + "When enabled and the resultant score is outside the threshold, the user will not be able to continue. When disabled, the user will be able to continue and the score can be used in policies to customize further stages.", + )} +
+