From 2149e81d8f46702cc5e78032342d9c71329d43e9 Mon Sep 17 00:00:00 2001
From: Jens Langhammer
Date: Sat, 24 Aug 2024 15:43:33 +0200
Subject: [PATCH] base
Signed-off-by: Jens Langhammer
---
authentik/stages/identification/api.py | 1 +
.../0015_identificationstage_captcha_stage.py | 26 ++++++++
authentik/stages/identification/models.py | 10 +++
authentik/stages/identification/stage.py | 63 ++++++++++++-------
blueprints/schema.json | 5 ++
schema.yml | 22 +++++++
.../identification/IdentificationStageForm.ts | 36 +++++++++++
web/src/flow/stages/captcha/CaptchaStage.ts | 8 ++-
.../identification/IdentificationStage.ts | 9 +++
9 files changed, 155 insertions(+), 25 deletions(-)
create mode 100644 authentik/stages/identification/migrations/0015_identificationstage_captcha_stage.py
diff --git a/authentik/stages/identification/api.py b/authentik/stages/identification/api.py
index 9ad97320e8..b3ceb4cf8e 100644
--- a/authentik/stages/identification/api.py
+++ b/authentik/stages/identification/api.py
@@ -27,6 +27,7 @@ class IdentificationStageSerializer(StageSerializer):
fields = StageSerializer.Meta.fields + [
"user_fields",
"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 0000000000..ca8a02ffd8
--- /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-24 12:58
+
+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, the captcha element is shown on 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 27cfcb92f1..fd2faa0e2a 100644
--- a/authentik/stages/identification/models.py
+++ b/authentik/stages/identification/models.py
@@ -8,6 +8,7 @@ from rest_framework.serializers import BaseSerializer
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
@@ -42,6 +43,15 @@ class IdentificationStage(Stage):
),
),
)
+ captcha_stage = models.ForeignKey(
+ CaptchaStage,
+ null=True,
+ default=None,
+ on_delete=models.SET_NULL,
+ help_text=_(
+ ("When set, the captcha element is shown on the identification stage."),
+ ),
+ )
case_insensitive_matching = models.BooleanField(
default=True,
diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py
index 3cdccf3c00..1591949741 100644
--- a/authentik/stages/identification/stage.py
+++ b/authentik/stages/identification/stage.py
@@ -30,6 +30,11 @@ from authentik.lib.utils.urls import reverse_with_qs
from authentik.root.middleware import ClientIPMiddleware
from authentik.sources.oauth.types.apple import AppleLoginChallenge
from authentik.sources.plex.models import PlexAuthenticationChallenge
+from authentik.stages.captcha.stage import (
+ CaptchaChallenge,
+ CaptchaChallengeResponse,
+ CaptchaStageView,
+)
from authentik.stages.identification.models import IdentificationStage
from authentik.stages.identification.signals import identification_failed
from authentik.stages.password.stage import authenticate
@@ -64,6 +69,7 @@ class IdentificationChallenge(Challenge):
user_fields = ListField(child=CharField(), allow_empty=True, allow_null=True)
password_fields = BooleanField()
+ captcha_stage = CaptchaChallenge(required=False)
allow_show_password = BooleanField(default=False)
application_pre = CharField(required=False)
flow_designation = ChoiceField(FlowDesignation.choices)
@@ -84,6 +90,7 @@ class IdentificationChallengeResponse(ChallengeResponse):
uid_field = CharField()
password = CharField(required=False, allow_blank=True, allow_null=True)
component = CharField(default="ak-stage-identification")
+ captcha = CaptchaChallengeResponse(required=False)
pre_user: User | None = None
@@ -128,30 +135,32 @@ class IdentificationChallengeResponse(ChallengeResponse):
return attrs
raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user
- if not current_stage.password_stage:
- # No password stage select, don't validate the password
- return attrs
-
- password = attrs.get("password", None)
- if not password:
- self.stage.logger.warning("Password not set for ident+auth attempt")
- try:
- with start_span(
- op="authentik.stages.identification.authenticate",
- description="User authenticate call (combo stage)",
- ):
- user = authenticate(
- self.stage.request,
- current_stage.password_stage.backends,
- current_stage,
- username=self.pre_user.username,
- password=password,
- )
- if not user:
- raise ValidationError("Failed to authenticate.")
- self.pre_user = user
- except PermissionDenied as exc:
- raise ValidationError(str(exc)) from exc
+ if current_stage.password_stage:
+ password = attrs.get("password", None)
+ if not password:
+ self.stage.logger.warning("Password not set for ident+auth attempt")
+ try:
+ with start_span(
+ op="authentik.stages.identification.authenticate",
+ description="User authenticate call (combo stage)",
+ ):
+ user = authenticate(
+ self.stage.request,
+ current_stage.password_stage.backends,
+ current_stage,
+ username=self.pre_user.username,
+ password=password,
+ )
+ if not user:
+ raise ValidationError("Failed to authenticate.")
+ self.pre_user = user
+ except PermissionDenied as exc:
+ raise ValidationError(str(exc)) from exc
+ print(attrs)
+ # if current_stage.captcha_stage:
+ # captcha = CaptchaStageView(self.stage.executor)
+ # captcha.stage = current_stage.captcha_stage
+ # captcha.challenge_valid(attrs.get("captcha"))
return attrs
@@ -230,6 +239,12 @@ class IdentificationStageView(ChallengeStageView):
query=get_qs,
kwargs={"flow_slug": current_stage.passwordless_flow.slug},
)
+ if current_stage.captcha_stage:
+ captcha = CaptchaStageView(self.executor)
+ captcha.stage = current_stage.captcha_stage
+ captcha_challenge = captcha.get_challenge()
+ captcha_challenge.is_valid()
+ challenge.initial_data["captcha_stage"] = captcha_challenge.data
# Check all enabled source, add them if they have a UI Login button.
ui_sources = []
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 890022991c..8fb64613d8 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -10091,6 +10091,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, the captcha element is shown on the identification stage."
+ },
"case_insensitive_matching": {
"type": "boolean",
"title": "Case insensitive matching",
diff --git a/schema.yml b/schema.yml
index 6c21371d7b..7918d71ada 100644
--- a/schema.yml
+++ b/schema.yml
@@ -40459,6 +40459,8 @@ components:
nullable: true
password_fields:
type: boolean
+ captcha_stage:
+ $ref: '#/components/schemas/CaptchaChallenge'
allow_show_password:
type: boolean
default: false
@@ -40500,6 +40502,8 @@ components:
password:
type: string
nullable: true
+ captcha:
+ $ref: '#/components/schemas/CaptchaChallengeResponseRequest'
required:
- uid_field
IdentificationStage:
@@ -40545,6 +40549,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, the captcha element is shown on the identification
+ stage.
case_insensitive_matching:
type: boolean
description: When enabled, user fields are matched regardless of their casing.
@@ -40613,6 +40623,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, the captcha element is shown on the identification
+ stage.
case_insensitive_matching:
type: boolean
description: When enabled, user fields are matched regardless of their casing.
@@ -45745,6 +45761,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, the captcha element is shown on 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 8b4c553c96..18fc1ae1a5 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";
@@ -160,6 +161,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(
+ "TODO.",
+ )}
+
+