From 465010ea01ba5e57274127b3b99092e4602048a9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Simonyi=20Gerg=C5=91?=
Date: Wed, 4 Sep 2024 11:13:44 +0200
Subject: [PATCH 01/14] add captcha to identification stage
---
authentik/flows/tests/test_inspector.py | 1 +
authentik/stages/captcha/stage.py | 72 +++++++++--------
authentik/stages/identification/api.py | 2 +
.../0015_identificationstage_captcha_stage.py | 26 ++++++
authentik/stages/identification/models.py | 14 ++++
authentik/stages/identification/stage.py | 25 +++++-
authentik/stages/identification/tests.py | 79 +++++++++++++++++++
blueprints/schema.json | 5 ++
schema.yml | 28 +++++++
.../identification/IdentificationStageForm.ts | 36 +++++++++
web/src/flow/stages/captcha/CaptchaStage.ts | 36 +++++----
.../identification/IdentificationStage.ts | 18 ++++-
.../stages/identification/index.md | 6 +-
13 files changed, 296 insertions(+), 52 deletions(-)
create mode 100644 authentik/stages/identification/migrations/0015_identificationstage_captcha_stage.py
diff --git a/authentik/flows/tests/test_inspector.py b/authentik/flows/tests/test_inspector.py
index 2a01ea370c7a..81a04a797bb6 100644
--- a/authentik/flows/tests/test_inspector.py
+++ b/authentik/flows/tests/test_inspector.py
@@ -46,6 +46,7 @@ def test(self):
res.content,
{
"allow_show_password": False,
+ "captcha_stage": None,
"component": "ak-stage-identification",
"flow_info": {
"background": flow.background_url,
diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py
index 3967e6d3d399..8b9018de229a 100644
--- a/authentik/stages/captcha/stage.py
+++ b/authentik/stages/captcha/stage.py
@@ -27,6 +27,43 @@ class CaptchaChallenge(WithUserInfoChallenge):
component = CharField(default="ak-stage-captcha")
+def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str):
+ """Validate captcha token"""
+ try:
+ response = get_http_session().post(
+ stage.api_url,
+ headers={
+ "Content-type": "application/x-www-form-urlencoded",
+ },
+ data={
+ "secret": stage.private_key,
+ "response": token,
+ "remoteip": remote_ip,
+ },
+ )
+ response.raise_for_status()
+ data = response.json()
+ 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 CaptchaChallengeResponse(ChallengeResponse):
"""Validate captcha token"""
@@ -36,38 +73,9 @@ class CaptchaChallengeResponse(ChallengeResponse):
def validate_token(self, token: str) -> str:
"""Validate captcha token"""
stage: CaptchaStage = self.stage.executor.current_stage
- try:
- response = get_http_session().post(
- stage.api_url,
- headers={
- "Content-type": "application/x-www-form-urlencoded",
- },
- data={
- "secret": stage.private_key,
- "response": token,
- "remoteip": ClientIPMiddleware.get_client_ip(self.stage.request),
- },
- )
- response.raise_for_status()
- data = response.json()
- 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
+ client_ip = ClientIPMiddleware.get_client_ip(self.stage.request)
+
+ return verify_captcha_token(stage, token, client_ip)
class CaptchaStageView(ChallengeStageView):
diff --git a/authentik/stages/identification/api.py b/authentik/stages/identification/api.py
index 9ad97320e87b..c8de2d74364c 100644
--- a/authentik/stages/identification/api.py
+++ b/authentik/stages/identification/api.py
@@ -27,6 +27,7 @@ class Meta:
fields = StageSerializer.Meta.fields + [
"user_fields",
"password_stage",
+ "captcha_stage",
"case_insensitive_matching",
"show_matched_user",
"enrollment_flow",
@@ -46,6 +47,7 @@ class IdentificationStageViewSet(UsedByMixin, ModelViewSet):
filterset_fields = [
"name",
"password_stage",
+ "captcha_stage",
"case_insensitive_matching",
"show_matched_user",
"enrollment_flow",
diff --git a/authentik/stages/identification/migrations/0015_identificationstage_captcha_stage.py b/authentik/stages/identification/migrations/0015_identificationstage_captcha_stage.py
new file mode 100644
index 000000000000..734dc7631cea
--- /dev/null
+++ b/authentik/stages/identification/migrations/0015_identificationstage_captcha_stage.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.0.8 on 2024-08-29 11:31
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_captcha", "0003_captchastage_error_on_invalid_score_and_more"),
+ ("authentik_stages_identification", "0014_identificationstage_pretend"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="identificationstage",
+ name="captcha_stage",
+ field=models.ForeignKey(
+ default=None,
+ help_text="When set, adds functionality exactly like a Captcha stage, but baked into the Identification stage.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="authentik_stages_captcha.captchastage",
+ ),
+ ),
+ ]
diff --git a/authentik/stages/identification/models.py b/authentik/stages/identification/models.py
index 27cfcb92f1ea..ed6728c9327a 100644
--- a/authentik/stages/identification/models.py
+++ b/authentik/stages/identification/models.py
@@ -8,6 +8,7 @@
from authentik.core.models import Source
from authentik.flows.models import Flow, Stage
+from authentik.stages.captcha.models import CaptchaStage
from authentik.stages.password.models import PasswordStage
@@ -43,6 +44,19 @@ class IdentificationStage(Stage):
),
)
+ captcha_stage = models.ForeignKey(
+ CaptchaStage,
+ null=True,
+ default=None,
+ on_delete=models.SET_NULL,
+ help_text=_(
+ (
+ "When set, adds functionality exactly like a Captcha stage, but baked into the "
+ "Identification stage."
+ ),
+ ),
+ )
+
case_insensitive_matching = models.BooleanField(
default=True,
help_text=_("When enabled, user fields are matched regardless of their casing."),
diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py
index dffd119da9a5..50fbc16238e8 100644
--- a/authentik/stages/identification/stage.py
+++ b/authentik/stages/identification/stage.py
@@ -29,6 +29,7 @@
from authentik.lib.utils.reflection import all_subclasses
from authentik.lib.utils.urls import reverse_with_qs
from authentik.root.middleware import ClientIPMiddleware
+from authentik.stages.captcha.stage import CaptchaChallenge, verify_captcha_token
from authentik.stages.identification.models import IdentificationStage
from authentik.stages.identification.signals import identification_failed
from authentik.stages.password.stage import authenticate
@@ -75,6 +76,7 @@ class IdentificationChallenge(Challenge):
allow_show_password = BooleanField(default=False)
application_pre = CharField(required=False)
flow_designation = ChoiceField(FlowDesignation.choices)
+ captcha_stage = CaptchaChallenge(required=False)
enroll_url = CharField(required=False)
recovery_url = CharField(required=False)
@@ -91,14 +93,16 @@ class IdentificationChallengeResponse(ChallengeResponse):
uid_field = CharField()
password = CharField(required=False, allow_blank=True, allow_null=True)
+ token = CharField(required=False, allow_blank=True, allow_null=True)
component = CharField(default="ak-stage-identification")
pre_user: User | None = None
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
- """Validate that user exists, and optionally their password"""
+ """Validate that user exists, and optionally their password and captcha token"""
uid_field = attrs["uid_field"]
current_stage: IdentificationStage = self.stage.executor.current_stage
+ client_ip = ClientIPMiddleware.get_client_ip(self.stage.request)
pre_user = self.stage.get_user(uid_field)
if not pre_user:
@@ -113,7 +117,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
self.stage.logger.info(
"invalid_login",
identifier=uid_field,
- client_ip=ClientIPMiddleware.get_client_ip(self.stage.request),
+ client_ip=client_ip,
action="invalid_identifier",
context={
"stage": sanitize_item(self.stage),
@@ -136,6 +140,15 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
return attrs
raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user
+
+ # Captcha check
+ if captcha_stage := current_stage.captcha_stage:
+ token = attrs.get("token", None)
+ if not token:
+ self.stage.logger.warning("Token not set for captcha attempt")
+ verify_captcha_token(captcha_stage, token, client_ip)
+
+ # Password check
if not current_stage.password_stage:
# No password stage select, don't validate the password
return attrs
@@ -206,6 +219,14 @@ def get_challenge(self) -> Challenge:
"primary_action": self.get_primary_action(),
"user_fields": current_stage.user_fields,
"password_fields": bool(current_stage.password_stage),
+ "captcha_stage": (
+ {
+ "js_url": current_stage.captcha_stage.js_url,
+ "site_key": current_stage.captcha_stage.public_key,
+ }
+ if current_stage.captcha_stage
+ else None
+ ),
"allow_show_password": bool(current_stage.password_stage)
and current_stage.password_stage.allow_show_password,
"show_source_labels": current_stage.show_source_labels,
diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py
index 57ffed12839b..6f905feb4f3e 100644
--- a/authentik/stages/identification/tests.py
+++ b/authentik/stages/identification/tests.py
@@ -1,6 +1,7 @@
"""identification tests"""
from django.urls import reverse
+from requests_mock import Mocker
from rest_framework.exceptions import ValidationError
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
@@ -8,6 +9,8 @@
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.sources.oauth.models import OAuthSource
+from authentik.stages.captcha.models import CaptchaStage
+from authentik.stages.captcha.tests import RECAPTCHA_PRIVATE_KEY, RECAPTCHA_PUBLIC_KEY
from authentik.stages.identification.api import IdentificationStageSerializer
from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.password import BACKEND_INBUILT
@@ -133,6 +136,82 @@ def test_invalid_with_password_pretend(self):
user_fields=["email"],
)
+ @Mocker()
+ def test_valid_with_captcha(self, mock: Mocker):
+ """Test with valid email and captcha token in single step"""
+ mock.post(
+ "https://www.recaptcha.net/recaptcha/api/siteverify",
+ json={
+ "success": True,
+ "score": 0.5,
+ },
+ )
+
+ captcha_stage = CaptchaStage.objects.create(
+ name="captcha",
+ public_key=RECAPTCHA_PUBLIC_KEY,
+ private_key=RECAPTCHA_PRIVATE_KEY,
+ )
+ self.stage.captcha_stage = captcha_stage
+ self.stage.save()
+
+ form_data = {"uid_field": self.user.email, "token": "PASSED"}
+ url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
+ response = self.client.post(url, form_data)
+ self.assertEqual(response.status_code, 200)
+ self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
+
+ @Mocker()
+ def test_invalid_with_captcha(self, mock: Mocker):
+ """Test with valid email and invalid captcha token in single step"""
+ mock.post(
+ "https://www.recaptcha.net/recaptcha/api/siteverify",
+ json={
+ "success": False,
+ "score": 0.5,
+ },
+ )
+
+ captcha_stage = CaptchaStage.objects.create(
+ name="captcha",
+ public_key=RECAPTCHA_PUBLIC_KEY,
+ private_key=RECAPTCHA_PRIVATE_KEY,
+ )
+
+ self.stage.captcha_stage = captcha_stage
+ self.stage.save()
+
+ form_data = {
+ "uid_field": self.user.email,
+ "token": "FAILED",
+ }
+ url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
+ response = self.client.post(url, form_data)
+ self.assertStageResponse(
+ response,
+ self.flow,
+ component="ak-stage-identification",
+ password_fields=False,
+ primary_action="Log in",
+ response_errors={
+ "non_field_errors": [
+ {"code": "invalid", "string": "Failed to validate token: Unknown error"}
+ ]
+ },
+ sources=[
+ {
+ "challenge": {
+ "component": "xak-flow-redirect",
+ "to": "/source/oauth/login/test/",
+ },
+ "icon_url": "/static/authentik/sources/default.svg",
+ "name": "test",
+ }
+ ],
+ show_source_labels=False,
+ user_fields=["email"],
+ )
+
def test_invalid_with_username(self):
"""Test invalid with username (user exists but stage only allows email)"""
form_data = {"uid_field": self.user.username}
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 802ce9b268c0..3092f6addc9e 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -10131,6 +10131,11 @@
"title": "Password stage",
"description": "When set, shows a password field, instead of showing the password field as separate step."
},
+ "captcha_stage": {
+ "type": "integer",
+ "title": "Captcha stage",
+ "description": "When set, adds functionality exactly like a Captcha stage, but baked into the Identification stage."
+ },
"case_insensitive_matching": {
"type": "boolean",
"title": "Case insensitive matching",
diff --git a/schema.yml b/schema.yml
index f8b5472862b7..d01dacfd842f 100644
--- a/schema.yml
+++ b/schema.yml
@@ -32102,6 +32102,11 @@ paths:
operationId: stages_identification_list
description: IdentificationStage Viewset
parameters:
+ - in: query
+ name: captcha_stage
+ schema:
+ type: string
+ format: uuid
- in: query
name: case_insensitive_matching
schema:
@@ -40562,6 +40567,8 @@ components:
type: string
flow_designation:
$ref: '#/components/schemas/FlowDesignationEnum'
+ captcha_stage:
+ $ref: '#/components/schemas/CaptchaChallenge'
enroll_url:
type: string
recovery_url:
@@ -40596,6 +40603,9 @@ components:
password:
type: string
nullable: true
+ token:
+ type: string
+ nullable: true
required:
- uid_field
IdentificationStage:
@@ -40641,6 +40651,12 @@ components:
nullable: true
description: When set, shows a password field, instead of showing the password
field as separate step.
+ captcha_stage:
+ type: string
+ format: uuid
+ nullable: true
+ description: When set, adds functionality exactly like a Captcha stage,
+ but baked into the Identification stage.
case_insensitive_matching:
type: boolean
description: When enabled, user fields are matched regardless of their casing.
@@ -40709,6 +40725,12 @@ components:
nullable: true
description: When set, shows a password field, instead of showing the password
field as separate step.
+ captcha_stage:
+ type: string
+ format: uuid
+ nullable: true
+ description: When set, adds functionality exactly like a Captcha stage,
+ but baked into the Identification stage.
case_insensitive_matching:
type: boolean
description: When enabled, user fields are matched regardless of their casing.
@@ -45877,6 +45899,12 @@ components:
nullable: true
description: When set, shows a password field, instead of showing the password
field as separate step.
+ captcha_stage:
+ type: string
+ format: uuid
+ nullable: true
+ description: When set, adds functionality exactly like a Captcha stage,
+ but baked into the Identification stage.
case_insensitive_matching:
type: boolean
description: When enabled, user fields are matched regardless of their casing.
diff --git a/web/src/admin/stages/identification/IdentificationStageForm.ts b/web/src/admin/stages/identification/IdentificationStageForm.ts
index 9123fba71ad6..835b3d4803e8 100644
--- a/web/src/admin/stages/identification/IdentificationStageForm.ts
+++ b/web/src/admin/stages/identification/IdentificationStageForm.ts
@@ -21,6 +21,7 @@ import {
SourcesApi,
Stage,
StagesApi,
+ StagesCaptchaListRequest,
StagesPasswordListRequest,
UserFieldsEnum,
} from "@goauthentik/api";
@@ -161,6 +162,41 @@ export class IdentificationStageForm extends BaseStageForm
)}
+
+ => {
+ const args: StagesCaptchaListRequest = {
+ ordering: "name",
+ };
+ if (query !== undefined) {
+ args.search = query;
+ }
+ const stages = await new StagesApi(
+ DEFAULT_CONFIG,
+ ).stagesCaptchaList(args);
+ return stages.results;
+ }}
+ .groupBy=${(items: Stage[]) => {
+ return groupBy(items, (stage) => stage.verboseNamePlural);
+ }}
+ .renderElement=${(stage: Stage): string => {
+ return stage.name;
+ }}
+ .value=${(stage: Stage | undefined): string | undefined => {
+ return stage?.pk;
+ }}
+ .selected=${(stage: Stage): boolean => {
+ return stage.pk === this.instance?.captchaStage;
+ }}
+ ?blankable=${true}
+ >
+
+
+ ${msg(
+ "When set, adds functionality exactly like a Captcha stage, but baked into the Identification stage.",
+ )}
+
+