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

+