sources/saml: replace server-side pre-auth views for pre_auth flow

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-03-24 09:22:06 +01:00
parent da4fa96499
commit 4612cea970
15 changed files with 245 additions and 93 deletions

View File

@ -1,34 +0,0 @@
{% extends "login/base_full.html" %}
{% load authentik_utils %}
{% load i18n %}
{% block title %}
{{ title }}
{% endblock %}
{% block card %}
<form method="POST" action="{{ url }}" autosubmit>
{% csrf_token %}
{% for key, value in attrs.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<div class="pf-c-form__group pf-u-display-flex pf-u-justify-content-center">
<div class="pf-c-form__group-control">
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
</div>
<div class="pf-c-form__group pf-m-action">
<div class="pf-c-form__actions">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans 'Continue' %}</button>
</div>
</div>
</form>
<script>
document.querySelector("form").submit();
</script>
{% endblock %}

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -18,6 +18,7 @@ class SAMLSourceSerializer(SourceSerializer):
model = SAMLSource
fields = SourceSerializer.Meta.fields + [
"pre_authentication_flow",
"issuer",
"sso_url",
"slo_url",

View File

@ -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",

View File

@ -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),
]

View File

@ -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",
),
),
]

View File

@ -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,

View File

@ -1,26 +0,0 @@
{% extends "login/base_full.html" %}
{% load authentik_utils %}
{% load i18n %}
{% block title %}
{% trans 'Authorize Application' %}
{% endblock %}
{% block card %}
<form class="pf-c-form" method="POST" action="{{ request_url }}">
{% csrf_token %}
<input type="hidden" name="SAMLRequest" value="{{ request }}" />
<input type="hidden" name="RelayState" value="{{ relay_state }}" />
<div class="login-group">
<h3>
{% blocktrans with source=source.name %}
You're about to sign-in via {{ source }}.
{% endblocktrans %}
</h3>
</div>
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans "Continue" %}</button>
</div>
</form>
{% endblock %}

View File

@ -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()

View File

@ -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,
},
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,
)
# 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
@method_decorator(csrf_exempt, name="dispatch")

View File

@ -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(

View File

@ -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,