Merge branch 'master' into next
This commit is contained in:
commit
e12d99ba63
|
@ -116,18 +116,18 @@
|
||||||
},
|
},
|
||||||
"boto3": {
|
"boto3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1ddd597e3d8b7553432f84b32b9519cc90aad91c4dc3873725375163c9f98353",
|
"sha256:1e6e06b2f1eee5a76acdde1e7b4f57c93c1bf2905341207d74f2a140ce060cd8",
|
||||||
"sha256:8f33cb3d2fc42b0547a5560a6d7397aa93336f50899386762b2450682c0e992b"
|
"sha256:40e84a5f7888924db74a2710dbe48d066b51fe1f5549efaffe90e6efe813f37b"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.17.34"
|
"version": "==1.17.35"
|
||||||
},
|
},
|
||||||
"botocore": {
|
"botocore": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:749bdb151e340329f1b25600bfe9d223e930f8ba26bd74b71478ca5781f2feaf",
|
"sha256:9119ffb231145ffadd55391c9356dcdb18e3de65c3a7c82844634e949f0ca5a0",
|
||||||
"sha256:c4fe4fea1d6a3934dd8c670ee83b128f935a64078786fe8afb8a662446304926"
|
"sha256:e34bbb7d7de154c2ff2a73ae0691c601a69c5bda887374c8a6a23072380b07a4"
|
||||||
],
|
],
|
||||||
"version": "==1.20.34"
|
"version": "==1.20.35"
|
||||||
},
|
},
|
||||||
"cachetools": {
|
"cachetools": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
|
@ -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 %}
|
|
|
@ -26,7 +26,7 @@ class UILoginButtonSerializer(Serializer):
|
||||||
|
|
||||||
name = CharField()
|
name = CharField()
|
||||||
url = CharField()
|
url = CharField()
|
||||||
icon_url = CharField()
|
icon_url = CharField(required=False)
|
||||||
|
|
||||||
def create(self, validated_data: dict) -> Model:
|
def create(self, validated_data: dict) -> Model:
|
||||||
return Model()
|
return Model()
|
||||||
|
|
|
@ -77,7 +77,7 @@ class Stage(SerializerModel):
|
||||||
|
|
||||||
|
|
||||||
def in_memory_stage(view: Type["StageView"]) -> Stage:
|
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()
|
stage = Stage()
|
||||||
# Because we can't pickle a locally generated function,
|
# Because we can't pickle a locally generated function,
|
||||||
# we set the view as a separate property and reference a generic function
|
# we set the view as a separate property and reference a generic function
|
||||||
|
|
|
@ -81,6 +81,9 @@ class TestAuthNRequest(TestCase):
|
||||||
self.source = SAMLSource.objects.create(
|
self.source = SAMLSource.objects.create(
|
||||||
slug="provider",
|
slug="provider",
|
||||||
issuer="authentik",
|
issuer="authentik",
|
||||||
|
pre_authentication_flow=Flow.objects.get(
|
||||||
|
slug="default-source-pre-authentication"
|
||||||
|
),
|
||||||
signing_kp=cert,
|
signing_kp=cert,
|
||||||
)
|
)
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
|
|
@ -37,6 +37,9 @@ class TestSchema(TestCase):
|
||||||
slug="provider",
|
slug="provider",
|
||||||
issuer="authentik",
|
issuer="authentik",
|
||||||
signing_kp=cert,
|
signing_kp=cert,
|
||||||
|
pre_authentication_flow=Flow.objects.get(
|
||||||
|
slug="default-source-pre-authentication"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
|
|
@ -136,7 +136,9 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
messages.error(self.request, _("Authentication Failed."))
|
messages.error(self.request, _("Authentication Failed."))
|
||||||
return redirect(self.get_error_redirect(source, reason))
|
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"""
|
"""Prepare Authentication Plan, redirect user FlowExecutor"""
|
||||||
# Ensure redirect is carried through when user was trying to
|
# Ensure redirect is carried through when user was trying to
|
||||||
# authorize application
|
# 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
|
# We run the Flow planner here so we can pass the Pending user in the context
|
||||||
planner = FlowPlanner(flow)
|
planner = FlowPlanner(flow)
|
||||||
plan = planner.plan(self.request, kwargs)
|
plan = planner.plan(self.request, kwargs)
|
||||||
|
for stage in stages_to_append:
|
||||||
|
plan.append(stage)
|
||||||
self.request.session[SESSION_KEY_PLAN] = plan
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
return redirect_with_qs(
|
return redirect_with_qs(
|
||||||
"authentik_core:if-flow",
|
"authentik_core:if-flow",
|
||||||
|
@ -224,27 +228,18 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
% {"source": self.source.name}
|
% {"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
|
# We run the Flow planner here so we can pass the Pending user in the context
|
||||||
if not source.enrollment_flow:
|
if not source.enrollment_flow:
|
||||||
LOGGER.warning("source has no enrollment flow", source=source)
|
LOGGER.warning("source has no enrollment flow", source=source)
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
planner = FlowPlanner(source.enrollment_flow)
|
return self.handle_login_flow(
|
||||||
plan = planner.plan(self.request, context)
|
source.enrollment_flow,
|
||||||
plan.append(in_memory_stage(PostUserEnrollmentStage))
|
in_memory_stage(PostUserEnrollmentStage),
|
||||||
self.request.session[SESSION_KEY_PLAN] = plan
|
**{
|
||||||
return redirect_with_qs(
|
PLAN_CONTEXT_PROMPT: delete_none_keys(
|
||||||
"authentik_core:if-flow",
|
self.get_user_enroll_context(source, access, info)
|
||||||
self.request.GET,
|
),
|
||||||
flow_slug=source.enrollment_flow.slug,
|
PLAN_CONTEXT_SOURCES_OAUTH_ACCESS: access,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -18,6 +18,7 @@ class SAMLSourceSerializer(SourceSerializer):
|
||||||
|
|
||||||
model = SAMLSource
|
model = SAMLSource
|
||||||
fields = SourceSerializer.Meta.fields + [
|
fields = SourceSerializer.Meta.fields + [
|
||||||
|
"pre_authentication_flow",
|
||||||
"issuer",
|
"issuer",
|
||||||
"sso_url",
|
"sso_url",
|
||||||
"slo_url",
|
"slo_url",
|
||||||
|
|
|
@ -14,6 +14,9 @@ class SAMLSourceForm(forms.ModelForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*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(
|
self.fields["authentication_flow"].queryset = Flow.objects.filter(
|
||||||
designation=FlowDesignation.AUTHENTICATION
|
designation=FlowDesignation.AUTHENTICATION
|
||||||
)
|
)
|
||||||
|
@ -32,6 +35,7 @@ class SAMLSourceForm(forms.ModelForm):
|
||||||
"name",
|
"name",
|
||||||
"slug",
|
"slug",
|
||||||
"enabled",
|
"enabled",
|
||||||
|
"pre_authentication_flow",
|
||||||
"authentication_flow",
|
"authentication_flow",
|
||||||
"enrollment_flow",
|
"enrollment_flow",
|
||||||
"issuer",
|
"issuer",
|
||||||
|
|
|
@ -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),
|
||||||
|
]
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -11,6 +11,7 @@ from rest_framework.serializers import Serializer
|
||||||
from authentik.core.models import Source
|
from authentik.core.models import Source
|
||||||
from authentik.core.types import UILoginButton
|
from authentik.core.types import UILoginButton
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
from authentik.flows.models import Flow
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_string_validator
|
||||||
from authentik.sources.saml.processors.constants import (
|
from authentik.sources.saml.processors.constants import (
|
||||||
DSA_SHA1,
|
DSA_SHA1,
|
||||||
|
@ -51,6 +52,13 @@ class SAMLNameIDPolicy(models.TextChoices):
|
||||||
class SAMLSource(Source):
|
class SAMLSource(Source):
|
||||||
"""Authenticate using an external SAML Identity Provider."""
|
"""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(
|
issuer = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
@ -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 %}
|
|
|
@ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase
|
||||||
from lxml import etree # nosec
|
from lxml import etree # nosec
|
||||||
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
from authentik.flows.models import Flow
|
||||||
from authentik.sources.saml.models import SAMLSource
|
from authentik.sources.saml.models import SAMLSource
|
||||||
from authentik.sources.saml.processors.metadata import MetadataProcessor
|
from authentik.sources.saml.processors.metadata import MetadataProcessor
|
||||||
|
|
||||||
|
@ -20,6 +21,9 @@ class TestMetadataProcessor(TestCase):
|
||||||
slug="provider",
|
slug="provider",
|
||||||
issuer="authentik",
|
issuer="authentik",
|
||||||
signing_kp=CertificateKeyPair.objects.first(),
|
signing_kp=CertificateKeyPair.objects.first(),
|
||||||
|
pre_authentication_flow=Flow.objects.get(
|
||||||
|
slug="default-source-pre-authentication"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
request = self.factory.get("/")
|
request = self.factory.get("/")
|
||||||
xml = MetadataProcessor(source, request).build_entity_descriptor()
|
xml = MetadataProcessor(source, request).build_entity_descriptor()
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
from django.contrib.auth import logout
|
from django.contrib.auth import logout
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.http import Http404, HttpRequest, HttpResponse
|
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.decorators import method_decorator
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
from django.utils.translation import gettext_lazy as _
|
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 django.views.decorators.csrf import csrf_exempt
|
||||||
from xmlsec import VerificationError
|
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.lib.views import bad_request_message
|
||||||
from authentik.providers.saml.utils.encoding import nice64
|
from authentik.providers.saml.utils.encoding import nice64
|
||||||
|
from authentik.providers.saml.views.flows import AutosubmitChallenge
|
||||||
from authentik.sources.saml.exceptions import (
|
from authentik.sources.saml.exceptions import (
|
||||||
MissingSAMLResponse,
|
MissingSAMLResponse,
|
||||||
UnsupportedNameIDFormat,
|
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.metadata import MetadataProcessor
|
||||||
from authentik.sources.saml.processors.request import RequestProcessor
|
from authentik.sources.saml.processors.request import RequestProcessor
|
||||||
from authentik.sources.saml.processors.response import ResponseProcessor
|
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):
|
class InitiateView(View):
|
||||||
"""Get the Form with SAML Request, which sends us to the IDP"""
|
"""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:
|
def get(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||||
"""Replies with an XHTML SSO Request."""
|
"""Replies with an XHTML SSO Request."""
|
||||||
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
|
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}")
|
return redirect(f"{source.sso_url}?{url_args}")
|
||||||
# As POST Binding we show a form
|
# As POST Binding we show a form
|
||||||
saml_request = nice64(auth_n_req.build_auth_n())
|
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:
|
if source.binding_type == SAMLBindingTypes.POST:
|
||||||
return render(
|
injected_stages.append(in_memory_stage(ConsentStageView))
|
||||||
request,
|
plan_kwargs[PLAN_CONTEXT_CONSENT_HEADER] = f"Continue to {source.name}"
|
||||||
"saml/sp/login.html",
|
injected_stages.append(in_memory_stage(AutosubmitStageView))
|
||||||
{
|
return self.handle_login_flow(
|
||||||
"request_url": source.sso_url,
|
source,
|
||||||
"request": saml_request,
|
*injected_stages,
|
||||||
"relay_state": relay_state,
|
**plan_kwargs,
|
||||||
"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
|
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
<header class="pf-c-login__main-header">
|
|
||||||
<h1 class="pf-c-title pf-m-3xl">
|
|
||||||
{% trans 'WebAuthn' %}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<div class="pf-c-login__main-body">
|
|
||||||
{% block card %}
|
|
||||||
<div class="pf-c-form">
|
|
||||||
<ak-stage-webauthn-auth>
|
|
||||||
</ak-stage-webauthn-auth>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
|
@ -1,16 +0,0 @@
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
<header class="pf-c-login__main-header">
|
|
||||||
<h1 class="pf-c-title pf-m-3xl">
|
|
||||||
{% trans 'Configure WebAuthn' %}
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<div class="pf-c-login__main-body">
|
|
||||||
{% block card %}
|
|
||||||
<div class="pf-c-form">
|
|
||||||
<ak-stage-webauthn-register>
|
|
||||||
</ak-stage-webauthn-register>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ from authentik.flows.stage import ChallengeStageView
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent
|
from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent
|
||||||
|
|
||||||
|
PLAN_CONTEXT_CONSENT_TITLE = "consent_title"
|
||||||
PLAN_CONTEXT_CONSENT_HEADER = "consent_header"
|
PLAN_CONTEXT_CONSENT_HEADER = "consent_header"
|
||||||
PLAN_CONTEXT_CONSENT_PERMISSIONS = "consent_permissions"
|
PLAN_CONTEXT_CONSENT_PERMISSIONS = "consent_permissions"
|
||||||
|
|
||||||
|
@ -42,6 +43,10 @@ class ConsentStageView(ChallengeStageView):
|
||||||
"component": "ak-stage-consent",
|
"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:
|
if PLAN_CONTEXT_CONSENT_HEADER in self.executor.plan.context:
|
||||||
challenge.initial_data["header_text"] = self.executor.plan.context[
|
challenge.initial_data["header_text"] = self.executor.plan.context[
|
||||||
PLAN_CONTEXT_CONSENT_HEADER
|
PLAN_CONTEXT_CONSENT_HEADER
|
||||||
|
@ -50,16 +55,15 @@ class ConsentStageView(ChallengeStageView):
|
||||||
challenge.initial_data["permissions"] = self.executor.plan.context[
|
challenge.initial_data["permissions"] = self.executor.plan.context[
|
||||||
PLAN_CONTEXT_CONSENT_PERMISSIONS
|
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
|
return challenge
|
||||||
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
current_stage: ConsentStage = self.executor.current_stage
|
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
|
# For always require, we always return the challenge
|
||||||
if current_stage.mode == ConsentMode.ALWAYS_REQUIRE:
|
if current_stage.mode == ConsentMode.ALWAYS_REQUIRE:
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
@ -85,6 +89,11 @@ class ConsentStageView(ChallengeStageView):
|
||||||
if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context:
|
if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context:
|
||||||
return self.executor.stage_ok()
|
return self.executor.stage_ok()
|
||||||
application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
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
|
# Since we only get here when no consent exists, we can create it without update
|
||||||
if current_stage.mode == ConsentMode.PERMANENT:
|
if current_stage.mode == ConsentMode.PERMANENT:
|
||||||
UserConsent.objects.create(
|
UserConsent.objects.create(
|
||||||
|
|
|
@ -98,12 +98,18 @@ class TestSourceSAML(SeleniumTestCase):
|
||||||
@apply_migration("authentik_flows", "0008_default_flows")
|
@apply_migration("authentik_flows", "0008_default_flows")
|
||||||
@apply_migration("authentik_flows", "0009_source_flows")
|
@apply_migration("authentik_flows", "0009_source_flows")
|
||||||
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
|
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
|
||||||
|
@apply_migration(
|
||||||
|
"authentik_sources_saml", "0010_samlsource_pre_authentication_flow"
|
||||||
|
)
|
||||||
@object_manager
|
@object_manager
|
||||||
def test_idp_redirect(self):
|
def test_idp_redirect(self):
|
||||||
"""test SAML Source With redirect binding"""
|
"""test SAML Source With redirect binding"""
|
||||||
# Bootstrap all needed objects
|
# Bootstrap all needed objects
|
||||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||||
|
pre_authentication_flow = Flow.objects.get(
|
||||||
|
slug="default-source-pre-authentication"
|
||||||
|
)
|
||||||
keypair = CertificateKeyPair.objects.create(
|
keypair = CertificateKeyPair.objects.create(
|
||||||
name="test-idp-cert",
|
name="test-idp-cert",
|
||||||
certificate_data=IDP_CERT,
|
certificate_data=IDP_CERT,
|
||||||
|
@ -115,6 +121,7 @@ class TestSourceSAML(SeleniumTestCase):
|
||||||
slug="saml-idp-test",
|
slug="saml-idp-test",
|
||||||
authentication_flow=authentication_flow,
|
authentication_flow=authentication_flow,
|
||||||
enrollment_flow=enrollment_flow,
|
enrollment_flow=enrollment_flow,
|
||||||
|
pre_authentication_flow=pre_authentication_flow,
|
||||||
issuer="entity-id",
|
issuer="entity-id",
|
||||||
sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
|
sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
|
||||||
binding_type=SAMLBindingTypes.Redirect,
|
binding_type=SAMLBindingTypes.Redirect,
|
||||||
|
@ -158,23 +165,30 @@ class TestSourceSAML(SeleniumTestCase):
|
||||||
@apply_migration("authentik_flows", "0008_default_flows")
|
@apply_migration("authentik_flows", "0008_default_flows")
|
||||||
@apply_migration("authentik_flows", "0009_source_flows")
|
@apply_migration("authentik_flows", "0009_source_flows")
|
||||||
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
|
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
|
||||||
|
@apply_migration(
|
||||||
|
"authentik_sources_saml", "0010_samlsource_pre_authentication_flow"
|
||||||
|
)
|
||||||
@object_manager
|
@object_manager
|
||||||
def test_idp_post(self):
|
def test_idp_post(self):
|
||||||
"""test SAML Source With post binding"""
|
"""test SAML Source With post binding"""
|
||||||
# Bootstrap all needed objects
|
# Bootstrap all needed objects
|
||||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||||
|
pre_authentication_flow = Flow.objects.get(
|
||||||
|
slug="default-source-pre-authentication"
|
||||||
|
)
|
||||||
keypair = CertificateKeyPair.objects.create(
|
keypair = CertificateKeyPair.objects.create(
|
||||||
name="test-idp-cert",
|
name="test-idp-cert",
|
||||||
certificate_data=IDP_CERT,
|
certificate_data=IDP_CERT,
|
||||||
key_data=IDP_KEY,
|
key_data=IDP_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
SAMLSource.objects.create(
|
source = SAMLSource.objects.create(
|
||||||
name="saml-idp-test",
|
name="saml-idp-test",
|
||||||
slug="saml-idp-test",
|
slug="saml-idp-test",
|
||||||
authentication_flow=authentication_flow,
|
authentication_flow=authentication_flow,
|
||||||
enrollment_flow=enrollment_flow,
|
enrollment_flow=enrollment_flow,
|
||||||
|
pre_authentication_flow=pre_authentication_flow,
|
||||||
issuer="entity-id",
|
issuer="entity-id",
|
||||||
sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
|
sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
|
||||||
binding_type=SAMLBindingTypes.POST,
|
binding_type=SAMLBindingTypes.POST,
|
||||||
|
@ -198,7 +212,18 @@ class TestSourceSAML(SeleniumTestCase):
|
||||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||||
).click()
|
).click()
|
||||||
sleep(1)
|
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
|
# Now we should be at the IDP, wait for the username field
|
||||||
self.wait.until(ec.presence_of_element_located((By.ID, "username")))
|
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", "0008_default_flows")
|
||||||
@apply_migration("authentik_flows", "0009_source_flows")
|
@apply_migration("authentik_flows", "0009_source_flows")
|
||||||
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
|
@apply_migration("authentik_crypto", "0002_create_self_signed_kp")
|
||||||
|
@apply_migration(
|
||||||
|
"authentik_sources_saml", "0010_samlsource_pre_authentication_flow"
|
||||||
|
)
|
||||||
@object_manager
|
@object_manager
|
||||||
def test_idp_post_auto(self):
|
def test_idp_post_auto(self):
|
||||||
"""test SAML Source With post binding (auto redirect)"""
|
"""test SAML Source With post binding (auto redirect)"""
|
||||||
# Bootstrap all needed objects
|
# Bootstrap all needed objects
|
||||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||||
|
pre_authentication_flow = Flow.objects.get(
|
||||||
|
slug="default-source-pre-authentication"
|
||||||
|
)
|
||||||
keypair = CertificateKeyPair.objects.create(
|
keypair = CertificateKeyPair.objects.create(
|
||||||
name="test-idp-cert",
|
name="test-idp-cert",
|
||||||
certificate_data=IDP_CERT,
|
certificate_data=IDP_CERT,
|
||||||
|
@ -237,6 +268,7 @@ class TestSourceSAML(SeleniumTestCase):
|
||||||
slug="saml-idp-test",
|
slug="saml-idp-test",
|
||||||
authentication_flow=authentication_flow,
|
authentication_flow=authentication_flow,
|
||||||
enrollment_flow=enrollment_flow,
|
enrollment_flow=enrollment_flow,
|
||||||
|
pre_authentication_flow=pre_authentication_flow,
|
||||||
issuer="entity-id",
|
issuer="entity-id",
|
||||||
sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
|
sso_url="http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
|
||||||
binding_type=SAMLBindingTypes.POST_AUTO,
|
binding_type=SAMLBindingTypes.POST_AUTO,
|
||||||
|
|
Reference in New Issue