diff --git a/authentik/stages/consent/stage.py b/authentik/stages/consent/stage.py index ec515f822..a95f8c55d 100644 --- a/authentik/stages/consent/stage.py +++ b/authentik/stages/consent/stage.py @@ -1,5 +1,6 @@ """authentik consent stage""" from typing import Optional +from uuid import uuid4 from django.http import HttpRequest, HttpResponse from django.utils.timezone import now @@ -21,6 +22,7 @@ PLAN_CONTEXT_CONSENT_TITLE = "consent_title" PLAN_CONTEXT_CONSENT_HEADER = "consent_header" PLAN_CONTEXT_CONSENT_PERMISSIONS = "consent_permissions" PLAN_CONTEXT_CONSNET_EXTRA_PERMISSIONS = "consent_additional_permissions" +SESSION_KEY_CONSENT_TOKEN = "authentik/stages/consent/token" # nosec class ConsentChallenge(WithUserInfoChallenge): @@ -30,12 +32,14 @@ class ConsentChallenge(WithUserInfoChallenge): permissions = PermissionSerializer(many=True) additional_permissions = PermissionSerializer(many=True) component = CharField(default="ak-stage-consent") + token = CharField(required=True) class ConsentChallengeResponse(ChallengeResponse): """Consent challenge response, any valid response request is valid""" component = CharField(default="ak-stage-consent") + token = CharField(required=True) class ConsentStageView(ChallengeStageView): @@ -44,12 +48,15 @@ class ConsentStageView(ChallengeStageView): response_class = ConsentChallengeResponse def get_challenge(self) -> Challenge: + token = str(uuid4()) + self.request.session[SESSION_KEY_CONSENT_TOKEN] = token data = { "type": ChallengeTypes.NATIVE.value, "permissions": self.executor.plan.context.get(PLAN_CONTEXT_CONSENT_PERMISSIONS, []), "additional_permissions": self.executor.plan.context.get( PLAN_CONTEXT_CONSNET_EXTRA_PERMISSIONS, [] ), + "token": token, } if PLAN_CONTEXT_CONSENT_TITLE in self.executor.plan.context: data["title"] = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TITLE] @@ -102,6 +109,8 @@ class ConsentStageView(ChallengeStageView): return super().get(request, *args, **kwargs) def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: + if response.data["token"] != self.request.session[SESSION_KEY_CONSENT_TOKEN]: + return self.get(self.request) current_stage: ConsentStage = self.executor.current_stage if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: return self.executor.stage_ok() diff --git a/authentik/stages/consent/tests.py b/authentik/stages/consent/tests.py index 37d72ed82..1645ee2cd 100644 --- a/authentik/stages/consent/tests.py +++ b/authentik/stages/consent/tests.py @@ -1,5 +1,6 @@ """consent tests""" from time import sleep +from uuid import uuid4 from django.urls import reverse @@ -14,7 +15,10 @@ from authentik.flows.tests import FlowTestCase from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.generators import generate_id from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent -from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_PERMISSIONS +from authentik.stages.consent.stage import ( + PLAN_CONTEXT_CONSENT_PERMISSIONS, + SESSION_KEY_CONSENT_TOKEN, +) class TestConsentStage(FlowTestCase): @@ -37,10 +41,13 @@ class TestConsentStage(FlowTestCase): plan = FlowPlan(flow_pk=flow.pk.hex, bindings=[binding], markers=[StageMarker()]) session = self.client.session session[SESSION_KEY_PLAN] = plan + session[SESSION_KEY_CONSENT_TOKEN] = str(uuid4()) session.save() response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), - {}, + { + "token": session[SESSION_KEY_CONSENT_TOKEN], + }, ) # pylint: disable=no-member self.assertEqual(response.status_code, 200) @@ -62,10 +69,13 @@ class TestConsentStage(FlowTestCase): ) session = self.client.session session[SESSION_KEY_PLAN] = plan + session[SESSION_KEY_CONSENT_TOKEN] = str(uuid4()) session.save() response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), - {}, + { + "token": session[SESSION_KEY_CONSENT_TOKEN], + }, ) self.assertEqual(response.status_code, 200) self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) @@ -96,7 +106,7 @@ class TestConsentStage(FlowTestCase): {}, ) self.assertEqual(response.status_code, 200) - self.assertStageResponse( + raw_res = self.assertStageResponse( response, flow, self.user, @@ -105,7 +115,9 @@ class TestConsentStage(FlowTestCase): ) response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), - {}, + { + "token": raw_res["token"], + }, ) self.assertEqual(response.status_code, 200) self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) @@ -144,7 +156,7 @@ class TestConsentStage(FlowTestCase): {}, ) self.assertEqual(response.status_code, 200) - self.assertStageResponse( + raw_res = self.assertStageResponse( response, flow, self.user, @@ -155,7 +167,9 @@ class TestConsentStage(FlowTestCase): ) response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), - {}, + { + "token": raw_res["token"], + }, ) self.assertEqual(response.status_code, 200) self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) @@ -187,7 +201,7 @@ class TestConsentStage(FlowTestCase): {}, ) self.assertEqual(response.status_code, 200) - self.assertStageResponse( + raw_res = self.assertStageResponse( response, flow, self.user, @@ -200,7 +214,9 @@ class TestConsentStage(FlowTestCase): ) response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), - {}, + { + "token": raw_res["token"], + }, ) self.assertEqual(response.status_code, 200) self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) diff --git a/schema.yml b/schema.yml index 287eb800f..ca5a62024 100644 --- a/schema.yml +++ b/schema.yml @@ -20523,11 +20523,14 @@ components: type: array items: $ref: '#/components/schemas/Permission' + token: + type: string required: - additional_permissions - pending_user - pending_user_avatar - permissions + - token - type ConsentChallengeResponseRequest: type: object @@ -20537,6 +20540,11 @@ components: type: string minLength: 1 default: ak-stage-consent + token: + type: string + minLength: 1 + required: + - token ConsentStage: type: object description: ConsentStage Serializer diff --git a/web/src/flows/stages/base.ts b/web/src/flows/stages/base.ts index 90bbbbe4d..e1b03738a 100644 --- a/web/src/flows/stages/base.ts +++ b/web/src/flows/stages/base.ts @@ -23,17 +23,19 @@ export function readFileAsync(file: Blob) { }); } +export type KeyUnknown = { + [key: string]: unknown; +}; + export class BaseStage extends LitElement { host!: StageHost; @property({ attribute: false }) challenge!: Tin; - async submitForm(e: Event): Promise { + async submitForm(e: Event, defaults?: KeyUnknown): Promise { e.preventDefault(); - const object: { - [key: string]: unknown; - } = {}; + const object: KeyUnknown = defaults || {}; const form = new FormData(this.shadowRoot?.querySelector("form") || undefined); for await (const [key, value] of form.entries()) { diff --git a/web/src/flows/stages/consent/ConsentStage.ts b/web/src/flows/stages/consent/ConsentStage.ts index af6bf991d..1e95eee04 100644 --- a/web/src/flows/stages/consent/ConsentStage.ts +++ b/web/src/flows/stages/consent/ConsentStage.ts @@ -107,7 +107,9 @@ export class ConsentStage extends BaseStage { - this.submitForm(e); + this.submitForm(e, { + token: this.challenge.token, + }); }} >