Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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",
"request_content_type",
"interactive",
"score_min_threshold",
"score_max_threshold",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 5.2.14 on 2026-05-14 23:58

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_stages_captcha", "0004_captchastage_interactive"),
]

operations = [
migrations.AddField(
model_name="captchastage",
name="request_content_type",
field=models.TextField(
choices=[
("application/x-www-form-urlencoded", "Form encoded"),
("application/json", "JSON"),
],
default="application/x-www-form-urlencoded",
),
),
]
11 changes: 11 additions & 0 deletions authentik/stages/captcha/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
from authentik.flows.models import Stage


class CaptchaRequestContentType(models.TextChoices):
"""Supported request content types for CAPTCHA verification."""

FORM = "application/x-www-form-urlencoded", _("Form encoded")
JSON = "application/json", _("JSON")


class CaptchaStage(Stage):
"""Verify the user is human using Google's reCaptcha/other compatible CAPTCHA solutions."""

Expand All @@ -30,6 +37,10 @@ class CaptchaStage(Stage):

js_url = models.TextField(default="https://www.recaptcha.net/recaptcha/api.js")
api_url = models.TextField(default="https://www.recaptcha.net/recaptcha/api/siteverify")
request_content_type = models.TextField(
choices=CaptchaRequestContentType.choices,
default=CaptchaRequestContentType.FORM,
)

@property
def serializer(self) -> type[BaseSerializer]:
Expand Down
20 changes: 13 additions & 7 deletions authentik/stages/captcha/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from authentik.flows.stage import ChallengeStageView
from authentik.lib.utils.http import get_http_session
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.captcha.models import CaptchaStage
from authentik.stages.captcha.models import CaptchaRequestContentType, CaptchaStage

LOGGER = get_logger()
PLAN_CONTEXT_CAPTCHA = "captcha"
Expand All @@ -35,17 +35,23 @@ class CaptchaChallenge(WithUserInfoChallenge):

def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str, key: str | None = None):
"""Validate captcha token"""
payload = {
"secret": key or stage.private_key,
"response": token,
"remoteip": remote_ip,
}
body_kwargs = (
{"json": payload}
if stage.request_content_type == CaptchaRequestContentType.JSON
else {"data": payload}
)
try:
response = get_http_session().post(
stage.api_url,
headers={
"Content-type": "application/x-www-form-urlencoded",
},
data={
"secret": key or stage.private_key,
"response": token,
"remoteip": remote_ip,
"Content-Type": stage.request_content_type,
},
**body_kwargs,
)
response.raise_for_status()
data = response.json()
Expand Down
35 changes: 34 additions & 1 deletion authentik/stages/captcha/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.stages.captcha.models import CaptchaStage
from authentik.stages.captcha.models import CaptchaRequestContentType, CaptchaStage
from authentik.stages.captcha.stage import (
PLAN_CONTEXT_CAPTCHA_PRIVATE_KEY,
PLAN_CONTEXT_CAPTCHA_SITE_KEY,
Expand Down Expand Up @@ -56,6 +56,39 @@ def test_valid(self, mock: Mocker):
)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertEqual(
mock.request_history[0].headers["Content-Type"],
CaptchaRequestContentType.FORM,
)
self.assertIn("response=PASSED", mock.request_history[0].text)

@Mocker()
def test_valid_json_content_type(self, mock: Mocker):
"""Test valid captcha with JSON verification request"""
self.stage.request_content_type = CaptchaRequestContentType.JSON
self.stage.save()
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"))
self.assertEqual(
mock.request_history[0].headers["Content-Type"],
CaptchaRequestContentType.JSON,
)
self.assertEqual(mock.request_history[0].json()["response"], "PASSED")

@Mocker()
def test_valid_override(self, mock: Mocker):
Expand Down
8 changes: 8 additions & 0 deletions blueprints/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -15160,6 +15160,14 @@
"minLength": 1,
"title": "Api url"
},
"request_content_type": {
"type": "string",
"enum": [
"application/x-www-form-urlencoded",
"application/json"
],
"title": "Request content type"
},
"interactive": {
"type": "boolean",
"title": "Interactive"
Expand Down
16 changes: 16 additions & 0 deletions packages/client-ts/src/models/CaptchaStage.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions packages/client-ts/src/models/CaptchaStageRequest.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions packages/client-ts/src/models/PatchedCaptchaStageRequest.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions packages/client-ts/src/models/RequestContentTypeEnum.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/client-ts/src/models/index.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading