From 4612cea970a8a2e4ecbac5a6cc6ab8e6ae40892c Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 24 Mar 2021 09:22:06 +0100 Subject: [PATCH] sources/saml: replace server-side pre-auth views for pre_auth flow Signed-off-by: Jens Langhammer --- .../generic/autosubmit_form_full.html | 34 ----- authentik/core/types.py | 2 +- authentik/flows/models.py | 2 +- .../saml/tests/test_auth_n_request.py | 3 + authentik/providers/saml/tests/test_schema.py | 3 + authentik/sources/saml/api.py | 1 + authentik/sources/saml/forms.py | 4 + ...0010_samlsource_pre_authentication_flow.py | 53 ++++++++ .../migrations/0011_auto_20210324_0736.py | 25 ++++ authentik/sources/saml/models.py | 8 ++ .../sources/saml/templates/saml/sp/login.html | 26 ---- authentik/sources/saml/tests/test_metadata.py | 4 + authentik/sources/saml/views.py | 116 ++++++++++++++---- authentik/stages/consent/stage.py | 21 +++- tests/e2e/test_source_saml.py | 36 +++++- 15 files changed, 245 insertions(+), 93 deletions(-) delete mode 100644 authentik/core/templates/generic/autosubmit_form_full.html create mode 100644 authentik/sources/saml/migrations/0010_samlsource_pre_authentication_flow.py create mode 100644 authentik/sources/saml/migrations/0011_auto_20210324_0736.py delete mode 100644 authentik/sources/saml/templates/saml/sp/login.html diff --git a/authentik/core/templates/generic/autosubmit_form_full.html b/authentik/core/templates/generic/autosubmit_form_full.html deleted file mode 100644 index e3b044b8a..000000000 --- a/authentik/core/templates/generic/autosubmit_form_full.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends "login/base_full.html" %} - -{% load authentik_utils %} -{% load i18n %} - -{% block title %} -{{ title }} -{% endblock %} - -{% block card %} -
- {% csrf_token %} - {% for key, value in attrs.items %} - - {% endfor %} -
-
- - - - - -
-
-
-
- -
-
-
- -{% endblock %} diff --git a/authentik/core/types.py b/authentik/core/types.py index 1f9ff8223..0f93a1589 100644 --- a/authentik/core/types.py +++ b/authentik/core/types.py @@ -26,7 +26,7 @@ class UILoginButtonSerializer(Serializer): name = CharField() url = CharField() - icon_url = CharField() + icon_url = CharField(required=False) def create(self, validated_data: dict) -> Model: return Model() diff --git a/authentik/flows/models.py b/authentik/flows/models.py index 5de7369d1..d36cbb2e2 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -77,7 +77,7 @@ class Stage(SerializerModel): def in_memory_stage(view: Type["StageView"]) -> Stage: - """Creates an in-memory stage instance, based on a `_type` as view.""" + """Creates an in-memory stage instance, based on a `view` as view.""" stage = Stage() # Because we can't pickle a locally generated function, # we set the view as a separate property and reference a generic function diff --git a/authentik/providers/saml/tests/test_auth_n_request.py b/authentik/providers/saml/tests/test_auth_n_request.py index e01c45c56..f85f05809 100644 --- a/authentik/providers/saml/tests/test_auth_n_request.py +++ b/authentik/providers/saml/tests/test_auth_n_request.py @@ -81,6 +81,9 @@ class TestAuthNRequest(TestCase): self.source = SAMLSource.objects.create( slug="provider", issuer="authentik", + pre_authentication_flow=Flow.objects.get( + slug="default-source-pre-authentication" + ), signing_kp=cert, ) self.factory = RequestFactory() diff --git a/authentik/providers/saml/tests/test_schema.py b/authentik/providers/saml/tests/test_schema.py index c09a112b8..bb0cbed23 100644 --- a/authentik/providers/saml/tests/test_schema.py +++ b/authentik/providers/saml/tests/test_schema.py @@ -37,6 +37,9 @@ class TestSchema(TestCase): slug="provider", issuer="authentik", signing_kp=cert, + pre_authentication_flow=Flow.objects.get( + slug="default-source-pre-authentication" + ), ) self.factory = RequestFactory() diff --git a/authentik/sources/saml/api.py b/authentik/sources/saml/api.py index d499ce20b..c6ca57bd1 100644 --- a/authentik/sources/saml/api.py +++ b/authentik/sources/saml/api.py @@ -18,6 +18,7 @@ class SAMLSourceSerializer(SourceSerializer): model = SAMLSource fields = SourceSerializer.Meta.fields + [ + "pre_authentication_flow", "issuer", "sso_url", "slo_url", diff --git a/authentik/sources/saml/forms.py b/authentik/sources/saml/forms.py index 66801ec27..8afe9c822 100644 --- a/authentik/sources/saml/forms.py +++ b/authentik/sources/saml/forms.py @@ -14,6 +14,9 @@ class SAMLSourceForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields["pre_authentication_flow"].queryset = Flow.objects.filter( + designation=FlowDesignation.AUTHENTICATION + ) self.fields["authentication_flow"].queryset = Flow.objects.filter( designation=FlowDesignation.AUTHENTICATION ) @@ -32,6 +35,7 @@ class SAMLSourceForm(forms.ModelForm): "name", "slug", "enabled", + "pre_authentication_flow", "authentication_flow", "enrollment_flow", "issuer", diff --git a/authentik/sources/saml/migrations/0010_samlsource_pre_authentication_flow.py b/authentik/sources/saml/migrations/0010_samlsource_pre_authentication_flow.py new file mode 100644 index 000000000..e153fabb8 --- /dev/null +++ b/authentik/sources/saml/migrations/0010_samlsource_pre_authentication_flow.py @@ -0,0 +1,53 @@ +# Generated by Django 3.1.7 on 2021-03-23 22:09 + +import django.db.models.deletion +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation + + +def create_default_pre_authentication_flow( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +): + Flow = apps.get_model("authentik_flows", "Flow") + SAMLSource = apps.get_model("authentik_sources_saml", "samlsource") + + db_alias = schema_editor.connection.alias + + # Empty flow for providers where consent is implicitly given + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="default-source-pre-authentication", + designation=FlowDesignation.AUTHENTICATION, + defaults={"name": "Pre-Authentication", "title": ""}, + ) + + for source in SAMLSource.objects.using(db_alias).all(): + source.pre_authentication_flow = flow + source.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0016_auto_20201202_1307"), + ("authentik_sources_saml", "0009_auto_20210301_0949"), + ] + + operations = [ + migrations.AddField( + model_name="samlsource", + name="pre_authentication_flow", + field=models.ForeignKey( + default=None, + null=True, + help_text="Flow used before authentication.", + on_delete=django.db.models.deletion.CASCADE, + related_name="source_pre_authentication", + to="authentik_flows.flow", + ), + preserve_default=False, + ), + migrations.RunPython(create_default_pre_authentication_flow), + ] diff --git a/authentik/sources/saml/migrations/0011_auto_20210324_0736.py b/authentik/sources/saml/migrations/0011_auto_20210324_0736.py new file mode 100644 index 000000000..f2470b57f --- /dev/null +++ b/authentik/sources/saml/migrations/0011_auto_20210324_0736.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2021-03-24 07:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0016_auto_20201202_1307"), + ("authentik_sources_saml", "0010_samlsource_pre_authentication_flow"), + ] + + operations = [ + migrations.AlterField( + model_name="samlsource", + name="pre_authentication_flow", + field=models.ForeignKey( + help_text="Flow used before authentication.", + on_delete=django.db.models.deletion.CASCADE, + related_name="source_pre_authentication", + to="authentik_flows.flow", + ), + ), + ] diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py index baebb9336..aba16f81d 100644 --- a/authentik/sources/saml/models.py +++ b/authentik/sources/saml/models.py @@ -11,6 +11,7 @@ from rest_framework.serializers import Serializer from authentik.core.models import Source from authentik.core.types import UILoginButton from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow from authentik.lib.utils.time import timedelta_string_validator from authentik.sources.saml.processors.constants import ( DSA_SHA1, @@ -51,6 +52,13 @@ class SAMLNameIDPolicy(models.TextChoices): class SAMLSource(Source): """Authenticate using an external SAML Identity Provider.""" + pre_authentication_flow = models.ForeignKey( + Flow, + on_delete=models.CASCADE, + help_text=_("Flow used before authentication."), + related_name="source_pre_authentication", + ) + issuer = models.TextField( blank=True, default=None, diff --git a/authentik/sources/saml/templates/saml/sp/login.html b/authentik/sources/saml/templates/saml/sp/login.html deleted file mode 100644 index 5cf5c0502..000000000 --- a/authentik/sources/saml/templates/saml/sp/login.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "login/base_full.html" %} - -{% load authentik_utils %} -{% load i18n %} - -{% block title %} -{% trans 'Authorize Application' %} -{% endblock %} - -{% block card %} -
- {% csrf_token %} - - - -
- -
-
-{% endblock %} diff --git a/authentik/sources/saml/tests/test_metadata.py b/authentik/sources/saml/tests/test_metadata.py index 105b03476..6a65ee4ed 100644 --- a/authentik/sources/saml/tests/test_metadata.py +++ b/authentik/sources/saml/tests/test_metadata.py @@ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase from lxml import etree # nosec from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Flow from authentik.sources.saml.models import SAMLSource from authentik.sources.saml.processors.metadata import MetadataProcessor @@ -20,6 +21,9 @@ class TestMetadataProcessor(TestCase): slug="provider", issuer="authentik", signing_kp=CertificateKeyPair.objects.first(), + pre_authentication_flow=Flow.objects.get( + slug="default-source-pre-authentication" + ), ) request = self.factory.get("/") xml = MetadataProcessor(source, request).build_entity_descriptor() diff --git a/authentik/sources/saml/views.py b/authentik/sources/saml/views.py index c5618e7e5..8a60b6828 100644 --- a/authentik/sources/saml/views.py +++ b/authentik/sources/saml/views.py @@ -2,7 +2,8 @@ from django.contrib.auth import logout from django.contrib.auth.mixins import LoginRequiredMixin from django.http import Http404, HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect, render +from django.http.response import HttpResponseBadRequest +from django.shortcuts import get_object_or_404, redirect from django.utils.decorators import method_decorator from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ @@ -10,8 +11,20 @@ from django.views import View from django.views.decorators.csrf import csrf_exempt from xmlsec import VerificationError +from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes +from authentik.flows.models import in_memory_stage +from authentik.flows.planner import ( + PLAN_CONTEXT_REDIRECT, + PLAN_CONTEXT_SOURCE, + PLAN_CONTEXT_SSO, + FlowPlanner, +) +from authentik.flows.stage import ChallengeStageView +from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN +from authentik.lib.utils.urls import redirect_with_qs from authentik.lib.views import bad_request_message from authentik.providers.saml.utils.encoding import nice64 +from authentik.providers.saml.views.flows import AutosubmitChallenge from authentik.sources.saml.exceptions import ( MissingSAMLResponse, UnsupportedNameIDFormat, @@ -20,11 +33,68 @@ from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource from authentik.sources.saml.processors.metadata import MetadataProcessor from authentik.sources.saml.processors.request import RequestProcessor from authentik.sources.saml.processors.response import ResponseProcessor +from authentik.stages.consent.stage import ( + PLAN_CONTEXT_CONSENT_HEADER, + PLAN_CONTEXT_CONSENT_TITLE, + ConsentStageView, +) + +PLAN_CONTEXT_TITLE = "title" +PLAN_CONTEXT_URL = "url" +PLAN_CONTEXT_ATTRS = "attrs" + + +class AutosubmitStageView(ChallengeStageView): + """Wrapper stage to create an autosubmit challenge from plan context variables""" + + def get_challenge(self, *args, **kwargs) -> Challenge: + return AutosubmitChallenge( + data={ + "type": ChallengeTypes.native.value, + "component": "ak-stage-autosubmit", + "title": self.executor.plan.context.get(PLAN_CONTEXT_TITLE, ""), + "url": self.executor.plan.context.get(PLAN_CONTEXT_URL, ""), + "attrs": self.executor.plan.context.get(PLAN_CONTEXT_ATTRS, ""), + }, + ) + + # Since `ak-stage-autosubmit` redirects off site, we don't have anything to check + def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: + return HttpResponseBadRequest() class InitiateView(View): """Get the Form with SAML Request, which sends us to the IDP""" + def handle_login_flow( + self, source: SAMLSource, *stages_to_append, **kwargs + ) -> HttpResponse: + """Prepare Authentication Plan, redirect user FlowExecutor""" + # Ensure redirect is carried through when user was trying to + # authorize application + final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( + NEXT_ARG_NAME, "authentik_core:if-admin" + ) + kwargs.update( + { + PLAN_CONTEXT_SSO: True, + PLAN_CONTEXT_SOURCE: source, + PLAN_CONTEXT_REDIRECT: final_redirect, + } + ) + # We run the Flow planner here so we can pass the Pending user in the context + planner = FlowPlanner(source.pre_authentication_flow) + planner.allow_empty_flows = True + plan = planner.plan(self.request, kwargs) + for stage in stages_to_append: + plan.append(stage) + self.request.session[SESSION_KEY_PLAN] = plan + return redirect_with_qs( + "authentik_core:if-flow", + self.request.GET, + flow_slug=source.pre_authentication_flow.slug, + ) + def get(self, request: HttpRequest, source_slug: str) -> HttpResponse: """Replies with an XHTML SSO Request.""" source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) @@ -38,29 +108,29 @@ class InitiateView(View): return redirect(f"{source.sso_url}?{url_args}") # As POST Binding we show a form saml_request = nice64(auth_n_req.build_auth_n()) + injected_stages = [] + plan_kwargs = { + PLAN_CONTEXT_TITLE: _("Redirecting to %(app)s..." % {"app": source.name}), + PLAN_CONTEXT_CONSENT_TITLE: _( + "Redirecting to %(app)s..." % {"app": source.name} + ), + PLAN_CONTEXT_ATTRS: { + "SAMLRequest": saml_request, + "RelayState": relay_state, + }, + PLAN_CONTEXT_URL: source.sso_url, + } + # For just POST we add a consent stage, + # otherwise we default to POST_AUTO, with direct redirect if source.binding_type == SAMLBindingTypes.POST: - return render( - request, - "saml/sp/login.html", - { - "request_url": source.sso_url, - "request": saml_request, - "relay_state": relay_state, - "source": source, - }, - ) - # Or an auto-submit form - if source.binding_type == SAMLBindingTypes.POST_AUTO: - return render( - request, - "generic/autosubmit_form_full.html", - { - "title": _("Redirecting to %(app)s..." % {"app": source.name}), - "attrs": {"SAMLRequest": saml_request, "RelayState": relay_state}, - "url": source.sso_url, - }, - ) - raise Http404 + injected_stages.append(in_memory_stage(ConsentStageView)) + plan_kwargs[PLAN_CONTEXT_CONSENT_HEADER] = f"Continue to {source.name}" + injected_stages.append(in_memory_stage(AutosubmitStageView)) + return self.handle_login_flow( + source, + *injected_stages, + **plan_kwargs, + ) @method_decorator(csrf_exempt, name="dispatch") diff --git a/authentik/stages/consent/stage.py b/authentik/stages/consent/stage.py index c7d72ae31..387ea0783 100644 --- a/authentik/stages/consent/stage.py +++ b/authentik/stages/consent/stage.py @@ -15,6 +15,7 @@ from authentik.flows.stage import ChallengeStageView from authentik.lib.utils.time import timedelta_from_string from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent +PLAN_CONTEXT_CONSENT_TITLE = "consent_title" PLAN_CONTEXT_CONSENT_HEADER = "consent_header" PLAN_CONTEXT_CONSENT_PERMISSIONS = "consent_permissions" @@ -42,6 +43,10 @@ class ConsentStageView(ChallengeStageView): "component": "ak-stage-consent", } ) + if PLAN_CONTEXT_CONSENT_TITLE in self.executor.plan.context: + challenge.initial_data["title"] = self.executor.plan.context[ + PLAN_CONTEXT_CONSENT_TITLE + ] if PLAN_CONTEXT_CONSENT_HEADER in self.executor.plan.context: challenge.initial_data["header_text"] = self.executor.plan.context[ PLAN_CONTEXT_CONSENT_HEADER @@ -50,16 +55,15 @@ class ConsentStageView(ChallengeStageView): challenge.initial_data["permissions"] = self.executor.plan.context[ PLAN_CONTEXT_CONSENT_PERMISSIONS ] - # If there's a pending user, update the `username` field - # this field is only used by password managers. - # If there's no user set, an error is raised later. - if user := self.get_pending_user(): - challenge.initial_data["pending_user"] = user.username - challenge.initial_data["pending_user_avatar"] = user.avatar return challenge def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: current_stage: ConsentStage = self.executor.current_stage + # Make this StageView work when injected, in which case `current_stage` is an instance + # of the base class, and we don't save any consent, as it is assumed to be a one-time + # prompt + if not isinstance(current_stage, ConsentStage): + return super().get(request, *args, **kwargs) # For always require, we always return the challenge if current_stage.mode == ConsentMode.ALWAYS_REQUIRE: return super().get(request, *args, **kwargs) @@ -85,6 +89,11 @@ class ConsentStageView(ChallengeStageView): if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: return self.executor.stage_ok() application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] + # Make this StageView work when injected, in which case `current_stage` is an instance + # of the base class, and we don't save any consent, as it is assumed to be a one-time + # prompt + if not isinstance(current_stage, ConsentStage): + return self.executor.stage_ok() # Since we only get here when no consent exists, we can create it without update if current_stage.mode == ConsentMode.PERMANENT: UserConsent.objects.create( diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py index f1a71a6f0..413b3e69a 100644 --- a/tests/e2e/test_source_saml.py +++ b/tests/e2e/test_source_saml.py @@ -98,12 +98,18 @@ class TestSourceSAML(SeleniumTestCase): @apply_migration("authentik_flows", "0008_default_flows") @apply_migration("authentik_flows", "0009_source_flows") @apply_migration("authentik_crypto", "0002_create_self_signed_kp") + @apply_migration( + "authentik_sources_saml", "0010_samlsource_pre_authentication_flow" + ) @object_manager def test_idp_redirect(self): """test SAML Source With redirect binding""" # Bootstrap all needed objects authentication_flow = Flow.objects.get(slug="default-source-authentication") enrollment_flow = Flow.objects.get(slug="default-source-enrollment") + pre_authentication_flow = Flow.objects.get( + slug="default-source-pre-authentication" + ) keypair = CertificateKeyPair.objects.create( name="test-idp-cert", certificate_data=IDP_CERT, @@ -115,6 +121,7 @@ class TestSourceSAML(SeleniumTestCase): slug="saml-idp-test", authentication_flow=authentication_flow, enrollment_flow=enrollment_flow, + pre_authentication_flow=pre_authentication_flow, issuer="entity-id", sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php", binding_type=SAMLBindingTypes.Redirect, @@ -158,23 +165,30 @@ class TestSourceSAML(SeleniumTestCase): @apply_migration("authentik_flows", "0008_default_flows") @apply_migration("authentik_flows", "0009_source_flows") @apply_migration("authentik_crypto", "0002_create_self_signed_kp") + @apply_migration( + "authentik_sources_saml", "0010_samlsource_pre_authentication_flow" + ) @object_manager def test_idp_post(self): """test SAML Source With post binding""" # Bootstrap all needed objects authentication_flow = Flow.objects.get(slug="default-source-authentication") enrollment_flow = Flow.objects.get(slug="default-source-enrollment") + pre_authentication_flow = Flow.objects.get( + slug="default-source-pre-authentication" + ) keypair = CertificateKeyPair.objects.create( name="test-idp-cert", certificate_data=IDP_CERT, key_data=IDP_KEY, ) - SAMLSource.objects.create( + source = SAMLSource.objects.create( name="saml-idp-test", slug="saml-idp-test", authentication_flow=authentication_flow, enrollment_flow=enrollment_flow, + pre_authentication_flow=pre_authentication_flow, issuer="entity-id", sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php", binding_type=SAMLBindingTypes.POST, @@ -198,7 +212,18 @@ class TestSourceSAML(SeleniumTestCase): By.CLASS_NAME, "pf-c-login__main-footer-links-item-link" ).click() sleep(1) - self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click() + + flow_executor = self.get_shadow_root("ak-flow-executor") + consent_stage = self.get_shadow_root("ak-stage-consent", flow_executor) + + self.assertIn( + source.name, + consent_stage.find_element(By.CSS_SELECTOR, "#header-text").text, + ) + consent_stage.find_element( + By.CSS_SELECTOR, + ("[type=submit]"), + ).click() # Now we should be at the IDP, wait for the username field self.wait.until(ec.presence_of_element_located((By.ID, "username"))) @@ -220,12 +245,18 @@ class TestSourceSAML(SeleniumTestCase): @apply_migration("authentik_flows", "0008_default_flows") @apply_migration("authentik_flows", "0009_source_flows") @apply_migration("authentik_crypto", "0002_create_self_signed_kp") + @apply_migration( + "authentik_sources_saml", "0010_samlsource_pre_authentication_flow" + ) @object_manager def test_idp_post_auto(self): """test SAML Source With post binding (auto redirect)""" # Bootstrap all needed objects authentication_flow = Flow.objects.get(slug="default-source-authentication") enrollment_flow = Flow.objects.get(slug="default-source-enrollment") + pre_authentication_flow = Flow.objects.get( + slug="default-source-pre-authentication" + ) keypair = CertificateKeyPair.objects.create( name="test-idp-cert", certificate_data=IDP_CERT, @@ -237,6 +268,7 @@ class TestSourceSAML(SeleniumTestCase): slug="saml-idp-test", authentication_flow=authentication_flow, enrollment_flow=enrollment_flow, + pre_authentication_flow=pre_authentication_flow, issuer="entity-id", sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php", binding_type=SAMLBindingTypes.POST_AUTO,