diff --git a/Pipfile.lock b/Pipfile.lock index 2e5f4709eb..26c056c754 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -116,18 +116,18 @@ }, "boto3": { "hashes": [ - "sha256:1ddd597e3d8b7553432f84b32b9519cc90aad91c4dc3873725375163c9f98353", - "sha256:8f33cb3d2fc42b0547a5560a6d7397aa93336f50899386762b2450682c0e992b" + "sha256:1e6e06b2f1eee5a76acdde1e7b4f57c93c1bf2905341207d74f2a140ce060cd8", + "sha256:40e84a5f7888924db74a2710dbe48d066b51fe1f5549efaffe90e6efe813f37b" ], "index": "pypi", - "version": "==1.17.34" + "version": "==1.17.35" }, "botocore": { "hashes": [ - "sha256:749bdb151e340329f1b25600bfe9d223e930f8ba26bd74b71478ca5781f2feaf", - "sha256:c4fe4fea1d6a3934dd8c670ee83b128f935a64078786fe8afb8a662446304926" + "sha256:9119ffb231145ffadd55391c9356dcdb18e3de65c3a7c82844634e949f0ca5a0", + "sha256:e34bbb7d7de154c2ff2a73ae0691c601a69c5bda887374c8a6a23072380b07a4" ], - "version": "==1.20.34" + "version": "==1.20.35" }, "cachetools": { "hashes": [ 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 e3b044b8a4..0000000000 --- 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 %} -
- -{% endblock %} diff --git a/authentik/core/types.py b/authentik/core/types.py index 1f9ff8223f..0f93a15894 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 5de7369d1b..d36cbb2e2f 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 e01c45c567..f85f058094 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 c09a112b81..bb0cbed238 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/oauth/views/callback.py b/authentik/sources/oauth/views/callback.py index f121ef338c..a4e66c8bbb 100644 --- a/authentik/sources/oauth/views/callback.py +++ b/authentik/sources/oauth/views/callback.py @@ -136,7 +136,9 @@ class OAuthCallback(OAuthClientMixin, View): messages.error(self.request, _("Authentication Failed.")) return redirect(self.get_error_redirect(source, reason)) - def handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse: + def handle_login_flow( + self, flow: Flow, *stages_to_append, **kwargs + ) -> HttpResponse: """Prepare Authentication Plan, redirect user FlowExecutor""" # Ensure redirect is carried through when user was trying to # authorize application @@ -157,6 +159,8 @@ class OAuthCallback(OAuthClientMixin, View): # We run the Flow planner here so we can pass the Pending user in the context planner = FlowPlanner(flow) 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", @@ -224,27 +228,18 @@ class OAuthCallback(OAuthClientMixin, View): % {"source": self.source.name} ), ) - # Because we inject a stage into the planned flow, we can't use `self.handle_login_flow` - context = { - # Since we authenticate the user by their token, they have no backend set - PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend", - PLAN_CONTEXT_SSO: True, - PLAN_CONTEXT_SOURCE: self.source, - PLAN_CONTEXT_PROMPT: delete_none_keys( - self.get_user_enroll_context(source, access, info) - ), - PLAN_CONTEXT_SOURCES_OAUTH_ACCESS: access, - } + # We run the Flow planner here so we can pass the Pending user in the context if not source.enrollment_flow: LOGGER.warning("source has no enrollment flow", source=source) return HttpResponseBadRequest() - planner = FlowPlanner(source.enrollment_flow) - plan = planner.plan(self.request, context) - plan.append(in_memory_stage(PostUserEnrollmentStage)) - self.request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "authentik_core:if-flow", - self.request.GET, - flow_slug=source.enrollment_flow.slug, + return self.handle_login_flow( + source.enrollment_flow, + in_memory_stage(PostUserEnrollmentStage), + **{ + PLAN_CONTEXT_PROMPT: delete_none_keys( + self.get_user_enroll_context(source, access, info) + ), + PLAN_CONTEXT_SOURCES_OAUTH_ACCESS: access, + }, ) diff --git a/authentik/sources/saml/api.py b/authentik/sources/saml/api.py index d499ce20b0..c6ca57bd1c 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 66801ec279..8afe9c8225 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 0000000000..e153fabb8a --- /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 0000000000..f2470b57f1 --- /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 baebb93360..aba16f81d5 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 5cf5c05029..0000000000 --- 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 %} - -{% endblock %} diff --git a/authentik/sources/saml/tests/test_metadata.py b/authentik/sources/saml/tests/test_metadata.py index 105b03476b..6a65ee4edd 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 c5618e7e53..8a60b68288 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/authenticator_webauthn/templates/stages/authenticator_webauthn/auth.html b/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/auth.html deleted file mode 100644 index f35ee05ed4..0000000000 --- a/authentik/stages/authenticator_webauthn/templates/stages/authenticator_webauthn/auth.html +++ /dev/null @@ -1,15 +0,0 @@ -{% load i18n %} - -