From 9ee0ba141c4cb4d6144430d39862f03ce9739a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simonyi=20Gerg=C5=91?= <28359278+gergosimonyi@users.noreply.github.com> Date: Fri, 25 Oct 2024 08:13:35 +0200 Subject: [PATCH] stages/identification: add captcha to identification stage (#11711) * add captcha to identification stage * simplify component invocations * fail fast on `onTokenChange` default behavior * reword docs * rename `token` to `captcha_token` in Identification stage contexts (In Captcha stage contexts the name `token` seems well-scoped.) * use `nothing` instead of ``` html`` ``` * remove rendered Captcha component from document flow on Identification stages Note: this doesn't remove the captcha itself, if interactive, only the loading indicator. * add invisible requirement to captcha on Identification stage * stylize docs * add friendlier error messages to Captcha stage * fix tests * make captcha error messages even friendlier * add test case to retriable captcha * use default Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer Co-authored-by: Jens Langhammer --- authentik/flows/tests/test_inspector.py | 1 + authentik/stages/captcha/stage.py | 89 +++++++----- 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 | 132 ++++++++++++++++++ blueprints/schema.json | 5 + schema.yml | 28 ++++ .../identification/IdentificationStageForm.ts | 50 +++++-- web/src/flow/stages/captcha/CaptchaStage.ts | 32 ++--- .../identification/IdentificationStage.ts | 18 ++- .../stages/identification/index.md | 10 +- 13 files changed, 363 insertions(+), 69 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..73bcff5dec15 100644 --- a/authentik/stages/captcha/stage.py +++ b/authentik/stages/captcha/stage.py @@ -1,10 +1,11 @@ """authentik captcha stage""" from django.http.response import HttpResponse -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext as _ from requests import RequestException from rest_framework.fields import CharField from rest_framework.serializers import ValidationError +from structlog.stdlib import get_logger from authentik.flows.challenge import ( Challenge, @@ -16,6 +17,7 @@ from authentik.root.middleware import ClientIPMiddleware from authentik.stages.captcha.models import CaptchaStage +LOGGER = get_logger() PLAN_CONTEXT_CAPTCHA = "captcha" @@ -27,6 +29,56 @@ 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): + error_codes = data.get("error-codes", ["unknown-error"]) + LOGGER.warning("Failed to verify captcha token", error_codes=error_codes) + + # These cases can usually be fixed by simply requesting a new token and retrying. + # [reCAPTCHA](https://developers.google.com/recaptcha/docs/verify#error_code_reference) + # [hCaptcha](https://docs.hcaptcha.com/#siteverify-error-codes-table) + # [Turnstile](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes) + retriable_error_codes = [ + "missing-input-response", + "invalid-input-response", + "timeout-or-duplicate", + "expired-input-response", + "already-seen-response", + ] + + if set(error_codes).issubset(set(retriable_error_codes)): + error_message = _("Invalid captcha response. Retrying may solve this issue.") + else: + error_message = _("Invalid captcha response") + raise ValidationError(error_message) + 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 +88,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..1d2dfe8cab4f 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) + captcha_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: + captcha_token = attrs.get("captcha_token", None) + if not captcha_token: + self.stage.logger.warning("Token not set for captcha attempt") + verify_captcha_token(captcha_stage, captcha_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..c39434e24af4 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,135 @@ 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, "captcha_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, + "captcha_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": "Invalid captcha response"}] + }, + 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"], + ) + + @Mocker() + def test_invalid_with_captcha_retriable(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, + "error-codes": ["timeout-or-duplicate"], + }, + ) + + 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, + "captcha_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": "Invalid captcha response. Retrying may solve this issue.", + } + ] + }, + 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 9b3b91eb7419..0604c8d76d10 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -10679,6 +10679,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 f0c8447abbc4..c920d1ab11e7 100644 --- a/schema.yml +++ b/schema.yml @@ -33862,6 +33862,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: @@ -42504,6 +42509,8 @@ components: type: string flow_designation: $ref: '#/components/schemas/FlowDesignationEnum' + captcha_stage: + $ref: '#/components/schemas/CaptchaChallenge' enroll_url: type: string recovery_url: @@ -42538,6 +42545,9 @@ components: password: type: string nullable: true + captcha_token: + type: string + nullable: true required: - uid_field IdentificationStage: @@ -42583,6 +42593,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. @@ -42651,6 +42667,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. @@ -48241,6 +48263,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 6a9c65de08a5..7a20af84d612 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"; @@ -140,19 +141,13 @@ export class IdentificationStageForm extends BaseStageForm ).stagesPasswordList(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?.passwordStage; - }} - ?blankable=${true} + .groupBy=${(items: Stage[]) => + groupBy(items, (stage) => stage.verboseNamePlural)} + .renderElement=${(stage: Stage): string => stage.name} + .value=${(stage: Stage | undefined): string | undefined => stage?.pk} + .selected=${(stage: Stage): boolean => + stage.pk === this.instance?.passwordStage} + blankable >

@@ -161,6 +156,35 @@ 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[]) => + groupBy(items, (stage) => stage.verboseNamePlural)} + .renderElement=${(stage: Stage): string => stage.name} + .value=${(stage: Stage | undefined): string | undefined => stage?.pk} + .selected=${(stage: Stage): boolean => + stage.pk === this.instance?.captchaStage} + blankable + > + +

+ ${msg( + "When set, adds functionality exactly like a Captcha stage, but baked into the Identification stage.", + )} +

+