Repository: goauthentik/authentik
Pull request: stages/captcha: rework
<!--
👋 Hi there! Welcome.

Please check the Contributing guidelines: https://goauthentik.io/developer-docs/#how-can-i-contribute
-->

## Details

Adds a bunch of very dearly needed functionality to the captcha stage
- Score Min/Max threshold (by default set to match recaptcha recommendation)
- Continue/stop on error (when continuing, you can make custom decisions based on score, for example enable an MFA validation stage)
- Better testing

closes #5971

---

## Checklist

-   [ ] Local tests pass (`ak test authentik/`)
-   [ ] The code has been formatted (`make lint-fix`)

If an API change has been made

-   [ ] The API schema has been updated (`make gen-build`)

If changes to the frontend have been made

-   [ ] The code has been formatted (`make web`)

If applicable

-   [ ] The documentation has been updated
-   [ ] The documentation has been formatted (`make website`)


Problem statement:
How to use reCaptcha v3 with authentik
**Describe your question/**
How do I protect user login with a captcha ?
**Relevant infos**
I tried to protect user logins with a captcha. Therefore I created a new captcha stage and added the stage to the default-login-flow. However I was only able to create a reCaptcha Enterpreise api key (v3). I entered the credentials and it seams working, the reCaptcha logo appears on the login site. However, accesses is never blocked and I'm not forced to solve a captcha, even when creating a test key which always returns 0 (the highest risk) 
Further I struggle to see how authentiks reputation policy comes into play with reCaptcha v3 
Any tipp how to get a captcha in front of the login is greatly appreciated

Hints from issue discussion:

Commit message: stages/captcha: rework

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

Patch to review:
diff --git a/authentik/stages/captcha/api.py b/authentik/stages/captcha/api.py
index 8d833197cdac..bd83c59f74d5 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 000000000000..c9a673abe9ee
--- /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 e0e126b056f0..02f20882f12c 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 e0a8c20d6aca..159889d8e6db 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.root.middleware import ClientIPMiddleware
 from authentik.stages.captcha.models import CaptchaStage
 
+PLAN_CONTEXT_CAPTCHA = "captcha"
+
 
 class CaptchaChallenge(WithUserInfoChallenge):
     """Site public key"""
@@ -49,10 +52,22 @@ def validate_token(self, token: str) -> str:
             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
+                raise ValidationError(
+                    _(
+                        "Failed to validate token: {error}".format(
+                            error=data.get("error-codes", _("Unknown error"))
+                        )
+                    )
+                )
+            score = float(data.get("score"))
+            if stage.error_on_invalid_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 score
 
 
 class CaptchaStageView(ChallengeStageView):
@@ -69,5 +84,10 @@ def get_challenge(self, *args, **kwargs) -> Challenge:
             }
         )
 
-    def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
+    def challenge_valid(self, response: CaptchaChallengeResponse) -> HttpResponse:
+        score = response.validated_data["token"]
+        self.executor.plan.context[PLAN_CONTEXT_CAPTCHA] = {
+            "score": score,
+            "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 d5d63d8c20cf..c03384803bb1 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 @@ def setUp(self):
         )
         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 32d40e4d4efb..d32ba34e501d 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 0fc89940d846..1f47eccfcfb8 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 cacdee5deaac..8bfb7f026771 100644
--- a/web/src/admin/stages/captcha/CaptchaStageForm.ts
+++ b/web/src/admin/stages/captcha/CaptchaStageForm.ts
@@ -78,6 +78,40 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
                             )}
                         </p>
                     </ak-form-element-horizontal>
+                    <ak-form-element-horizontal
+                        label=${msg("Score minimum threshold")}
+                        ?required=${true}
+                        name="scoreMinThreshold"
+                    >
+                        <input
+                            type="number"
+                            value="${ifDefined(this.instance?.scoreMinThreshold || 0.5)}"
+                            class="pf-c-form-control"
+                            required
+                        />
+                        <p class="pf-c-form__helper-text">
+                            ${msg(
+                                "Public key, acquired from https://www.google.com/recaptcha/intro/v3.html.",
+                            )}
+                        </p>
+                    </ak-form-element-horizontal>
+                    <ak-form-element-horizontal
+                        label=${msg("Score maximum threshold")}
+                        ?required=${true}
+                        name="scoreMaxThreshold"
+                    >
+                        <input
+                            type="number"
+                            value="${ifDefined(this.instance?.scoreMaxThreshold || -1)}"
+                            class="pf-c-form-control"
+                            required
+                        />
+                        <p class="pf-c-form__helper-text">
+                            ${msg(
+                                "Public key, acquired from https://www.google.com/recaptcha/intro/v3.html.",
+                            )}
+                        </p>
+                    </ak-form-element-horizontal>
                 </div>
             </ak-form-group>
             <ak-form-group>
diff --git a/website/docs/flow/context/index.md b/website/docs/flow/context/index.md
index c092f3471e64..100742189189 100644
--- a/website/docs/flow/context/index.md
+++ b/website/docs/flow/context/index.md
@@ -72,16 +72,6 @@ Set when a flow execution is continued from a token. This happens for example wh
 
 ### Stage-specific keys
 
-#### Consent stage
-
-##### `consent_header` (string)
-
-The title of the consent prompt shown. Set automatically when the consent stage is used with a OAuth2, Proxy or SAML provider.
-
-##### `consent_permissions` (List of PermissionDict)
-
-An optional list of all permissions that will be given to the application by granting consent. Not supported with SAML. When used with an OAuth2 or Proxy provider, this will be set based on the configured scopes.
-
 #### Autosubmit stage
 
 The autosubmit stage is an internal stage type that is not configurable via the API/Web interface. It is used in certain situations, where a POST request is sent from the browser, such as with SAML POST bindings. This works by using an HTML form that is submitted automatically.
@@ -98,6 +88,28 @@ URL that the form will be submitted to.
 
 Key-value pairs of the data that is included in the form and will be submitted to `url`.
 
+#### Captcha stage
+
+:::info
+Requires authentik 2024.6
+:::
+
+##### `captcha` (dictionary)
+
+When `error_on_invalid_score` (TODO) is set to false on a captcha stage, after the execution of the captcha stage, this object will be set in the flow context.
+
+It contains two keys, `score` which is the score received from the configured captcha URL, and `stage`, which is a reference to the captcha stage that executed the test.
+
+#### Consent stage
+
+##### `consent_header` (string)
+
+The title of the consent prompt shown. Set automatically when the consent stage is used with a OAuth2, Proxy or SAML provider.
+
+##### `consent_permissions` (List of PermissionDict)
+
+An optional list of all permissions that will be given to the application by granting consent. Not supported with SAML. When used with an OAuth2 or Proxy provider, this will be set based on the configured scopes.
+
 #### Deny stage
 
 ##### `deny_message` (string)


Code review comments:
diff_hunk: @@ -78,6 +78,40 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
                             )}
                         </p>
                     </ak-form-element-horizontal>
+                    <ak-form-element-horizontal
+                        label=${msg("Score minimum threshold")}
+                        ?required=${true}
+                        name="scoreMinThreshold"
+                    >
+                        <input
path: web/src/admin/stages/captcha/CaptchaStageForm.ts
text: @user1:
Maybe use `ak-number-input`?