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.", + )} +

+