sources/saml: revamp SAML Source (#3785)

* update saml source to use user connections, add all attributes to flow context

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* check for SAML Status in response, add tests

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* package apple icon

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add webui for connections

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens L 2022-10-14 18:04:47 +03:00 committed by GitHub
parent 5e620c74f9
commit 363872715d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 780 additions and 113 deletions

View File

@ -53,7 +53,7 @@ migrate:
python -m lifecycle.migrate python -m lifecycle.migrate
run: run:
go run -v cmd/server/main.go go run -v ./cmd/server/
i18n-extract: i18n-extract-core web-extract i18n-extract: i18n-extract-core web-extract

View File

@ -59,7 +59,8 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet
from authentik.sources.oauth.api.source_connection import UserOAuthSourceConnectionViewSet from authentik.sources.oauth.api.source_connection import UserOAuthSourceConnectionViewSet
from authentik.sources.plex.api.source import PlexSourceViewSet from authentik.sources.plex.api.source import PlexSourceViewSet
from authentik.sources.plex.api.source_connection import PlexSourceConnectionViewSet from authentik.sources.plex.api.source_connection import PlexSourceConnectionViewSet
from authentik.sources.saml.api import SAMLSourceViewSet from authentik.sources.saml.api.source import SAMLSourceViewSet
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionViewSet
from authentik.stages.authenticator_duo.api import ( from authentik.stages.authenticator_duo.api import (
AuthenticatorDuoStageViewSet, AuthenticatorDuoStageViewSet,
DuoAdminDeviceViewSet, DuoAdminDeviceViewSet,
@ -138,6 +139,7 @@ router.register("sources/all", SourceViewSet)
router.register("sources/user_connections/all", UserSourceConnectionViewSet) router.register("sources/user_connections/all", UserSourceConnectionViewSet)
router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet) router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet)
router.register("sources/user_connections/plex", PlexSourceConnectionViewSet) router.register("sources/user_connections/plex", PlexSourceConnectionViewSet)
router.register("sources/user_connections/saml", UserSAMLSourceConnectionViewSet)
router.register("sources/ldap", LDAPSourceViewSet) router.register("sources/ldap", LDAPSourceViewSet)
router.register("sources/saml", SAMLSourceViewSet) router.register("sources/saml", SAMLSourceViewSet)
router.register("sources/oauth", OAuthSourceViewSet) router.register("sources/oauth", OAuthSourceViewSet)

View File

@ -130,8 +130,8 @@ class TestAuthNRequest(TestCase):
http_request.POST = QueryDict(mutable=True) http_request.POST = QueryDict(mutable=True)
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode() http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
response_parser = ResponseProcessor(self.source) response_parser = ResponseProcessor(self.source, http_request)
response_parser.parse(http_request) response_parser.parse()
def test_request_id_invalid(self): def test_request_id_invalid(self):
"""Test generated AuthNRequest with invalid request ID""" """Test generated AuthNRequest with invalid request ID"""
@ -157,10 +157,10 @@ class TestAuthNRequest(TestCase):
http_request.POST = QueryDict(mutable=True) http_request.POST = QueryDict(mutable=True)
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode() http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
response_parser = ResponseProcessor(self.source) response_parser = ResponseProcessor(self.source, http_request)
with self.assertRaises(MismatchedRequestID): with self.assertRaises(MismatchedRequestID):
response_parser.parse(http_request) response_parser.parse()
def test_signed_valid_detached(self): def test_signed_valid_detached(self):
"""Test generated AuthNRequest with valid signature (detached)""" """Test generated AuthNRequest with valid signature (detached)"""

View File

@ -114,9 +114,6 @@ class AppleType(SourceType):
access_token_url = "https://appleid.apple.com/auth/token" # nosec access_token_url = "https://appleid.apple.com/auth/token" # nosec
profile_url = "" profile_url = ""
def icon_url(self) -> str:
return "https://appleid.cdn-apple.com/appleid/button/logo"
def login_challenge(self, source: OAuthSource, request: HttpRequest) -> Challenge: def login_challenge(self, source: OAuthSource, request: HttpRequest) -> Challenge:
"""Pre-general all the things required for the JS SDK""" """Pre-general all the things required for the JS SDK"""
apple_client = AppleOAuthClient( apple_client = AppleOAuthClient(

View File

View File

@ -0,0 +1,29 @@
"""SAML Source Serializer"""
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
from authentik.core.api.sources import UserSourceConnectionSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.sources.saml.models import UserSAMLSourceConnection
class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer):
"""SAML Source Serializer"""
class Meta:
model = UserSAMLSourceConnection
fields = ["pk", "user", "source", "identifier"]
class UserSAMLSourceConnectionViewSet(UsedByMixin, ModelViewSet):
"""Source Viewset"""
queryset = UserSAMLSourceConnection.objects.all()
serializer_class = UserSAMLSourceConnectionSerializer
filterset_fields = ["source__slug"]
search_fields = ["source__slug"]
permission_classes = [OwnerSuperuserPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["source__slug"]

View File

@ -0,0 +1,37 @@
# Generated by Django 4.1.2 on 2022-10-14 12:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0022_alter_group_parent"),
("authentik_sources_saml", "0011_auto_20210324_0736"),
]
operations = [
migrations.CreateModel(
name="UserSAMLSourceConnection",
fields=[
(
"usersourceconnection_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.usersourceconnection",
),
),
("identifier", models.TextField()),
],
options={
"verbose_name": "User SAML Source Connection",
"verbose_name_plural": "User SAML Source Connections",
},
bases=("authentik_core.usersourceconnection",),
),
]

View File

@ -1,13 +1,15 @@
"""saml sp models""" """saml sp models"""
from typing import Optional
from django.db import models from django.db import models
from django.http import HttpRequest from django.http import HttpRequest
from django.templatetags.static import static
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from authentik.core.models import Source from authentik.core.models import Source, UserSourceConnection
from authentik.core.types import UILoginButton from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.flows.challenge import ChallengeTypes, RedirectChallenge from authentik.flows.challenge import ChallengeTypes, RedirectChallenge
from authentik.flows.models import Flow from authentik.flows.models import Flow
@ -161,7 +163,7 @@ class SAMLSource(Source):
@property @property
def serializer(self) -> type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.sources.saml.api import SAMLSourceSerializer from authentik.sources.saml.api.source import SAMLSourceSerializer
return SAMLSourceSerializer return SAMLSourceSerializer
@ -191,6 +193,19 @@ class SAMLSource(Source):
name=self.name, name=self.name,
) )
def ui_user_settings(self) -> Optional[UserSettingSerializer]:
return UserSettingSerializer(
data={
"title": self.name,
"component": "ak-user-settings-source-saml",
"configure_url": reverse(
"authentik_sources_saml:login",
kwargs={"source_slug": self.slug},
),
"icon_url": static(f"authentik/sources/{self.slug}.svg"),
}
)
def __str__(self): def __str__(self):
return f"SAML Source {self.name}" return f"SAML Source {self.name}"
@ -198,3 +213,20 @@ class SAMLSource(Source):
verbose_name = _("SAML Source") verbose_name = _("SAML Source")
verbose_name_plural = _("SAML Sources") verbose_name_plural = _("SAML Sources")
class UserSAMLSourceConnection(UserSourceConnection):
"""Connection to configured SAML Sources."""
identifier = models.TextField()
@property
def serializer(self) -> Serializer:
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer
return UserSAMLSourceConnectionSerializer
class Meta:
verbose_name = _("User SAML Source Connection")
verbose_name_plural = _("User SAML Source Connections")

View File

@ -7,7 +7,7 @@ import xmlsec
from defusedxml.lxml import fromstring from defusedxml.lxml import fromstring
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import SuspiciousOperation from django.core.exceptions import SuspiciousOperation
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest
from django.utils.timezone import now from django.utils.timezone import now
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -18,17 +18,9 @@ from authentik.core.models import (
USER_ATTRIBUTE_SOURCES, USER_ATTRIBUTE_SOURCES,
User, User,
) )
from authentik.flows.models import Flow from authentik.core.sources.flow_manager import SourceFlowManager
from authentik.flows.planner import ( from authentik.lib.expression.evaluator import BaseEvaluator
PLAN_CONTEXT_PENDING_USER,
PLAN_CONTEXT_REDIRECT,
PLAN_CONTEXT_SOURCE,
PLAN_CONTEXT_SSO,
FlowPlanner,
)
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.utils import delete_none_keys from authentik.policies.utils import delete_none_keys
from authentik.sources.saml.exceptions import ( from authentik.sources.saml.exceptions import (
InvalidSignature, InvalidSignature,
@ -36,9 +28,11 @@ from authentik.sources.saml.exceptions import (
MissingSAMLResponse, MissingSAMLResponse,
UnsupportedNameIDFormat, UnsupportedNameIDFormat,
) )
from authentik.sources.saml.models import SAMLSource from authentik.sources.saml.models import SAMLSource, UserSAMLSourceConnection
from authentik.sources.saml.processors.constants import ( from authentik.sources.saml.processors.constants import (
NS_MAP, NS_MAP,
NS_SAML_ASSERTION,
NS_SAML_PROTOCOL,
SAML_NAME_ID_FORMAT_EMAIL, SAML_NAME_ID_FORMAT_EMAIL,
SAML_NAME_ID_FORMAT_PERSISTENT, SAML_NAME_ID_FORMAT_PERSISTENT,
SAML_NAME_ID_FORMAT_TRANSIENT, SAML_NAME_ID_FORMAT_TRANSIENT,
@ -46,9 +40,6 @@ from authentik.sources.saml.processors.constants import (
SAML_NAME_ID_FORMAT_X509, SAML_NAME_ID_FORMAT_X509,
) )
from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_login.stage import BACKEND_INBUILT
LOGGER = get_logger() LOGGER = get_logger()
if TYPE_CHECKING: if TYPE_CHECKING:
@ -67,14 +58,14 @@ class ResponseProcessor:
_http_request: HttpRequest _http_request: HttpRequest
def __init__(self, source: SAMLSource): def __init__(self, source: SAMLSource, request: HttpRequest):
self._source = source self._source = source
def parse(self, request: HttpRequest):
"""Check if `request` contains SAML Response data, parse and validate it."""
self._http_request = request self._http_request = request
def parse(self):
"""Check if `request` contains SAML Response data, parse and validate it."""
# First off, check if we have any SAML Data at all. # First off, check if we have any SAML Data at all.
raw_response = request.POST.get("SAMLResponse", None) raw_response = self._http_request.POST.get("SAMLResponse", None)
if not raw_response: if not raw_response:
raise MissingSAMLResponse("Request does not contain 'SAMLResponse'") raise MissingSAMLResponse("Request does not contain 'SAMLResponse'")
# Check if response is compressed, b64 decode it # Check if response is compressed, b64 decode it
@ -83,7 +74,8 @@ class ResponseProcessor:
if self._source.signing_kp: if self._source.signing_kp:
self._verify_signed() self._verify_signed()
self._verify_request_id(request) self._verify_request_id()
self._verify_status()
def _verify_signed(self): def _verify_signed(self):
"""Verify SAML Response's Signature""" """Verify SAML Response's Signature"""
@ -109,7 +101,7 @@ class ResponseProcessor:
raise InvalidSignature from exc raise InvalidSignature from exc
LOGGER.debug("Successfully verified signautre") LOGGER.debug("Successfully verified signautre")
def _verify_request_id(self, request: HttpRequest): def _verify_request_id(self):
if self._source.allow_idp_initiated: if self._source.allow_idp_initiated:
# If IdP-initiated SSO flows are enabled, we want to cache the Response ID # If IdP-initiated SSO flows are enabled, we want to cache the Response ID
# somewhat mitigate replay attacks # somewhat mitigate replay attacks
@ -119,14 +111,26 @@ class ResponseProcessor:
seen_ids.append(self._root.attrib["ID"]) seen_ids.append(self._root.attrib["ID"])
cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids) cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids)
return return
if SESSION_KEY_REQUEST_ID not in request.session or "InResponseTo" not in self._root.attrib: if (
SESSION_KEY_REQUEST_ID not in self._http_request.session
or "InResponseTo" not in self._root.attrib
):
raise MismatchedRequestID( raise MismatchedRequestID(
"Missing InResponseTo and IdP-initiated Logins are not allowed" "Missing InResponseTo and IdP-initiated Logins are not allowed"
) )
if request.session[SESSION_KEY_REQUEST_ID] != self._root.attrib["InResponseTo"]: if self._http_request.session[SESSION_KEY_REQUEST_ID] != self._root.attrib["InResponseTo"]:
raise MismatchedRequestID("Mismatched request ID") raise MismatchedRequestID("Mismatched request ID")
def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse: def _verify_status(self):
"""Check for SAML Status elements"""
status = self._root.find(f"{{{NS_SAML_PROTOCOL}}}Status")
if status is None:
return
message = status.find(f"{{{NS_SAML_PROTOCOL}}}StatusMessage")
if message is not None:
raise ValueError(message.text)
def _handle_name_id_transient(self) -> SourceFlowManager:
"""Handle a NameID with the Format of Transient. This is a bit more complex than other """Handle a NameID with the Format of Transient. This is a bit more complex than other
formats, as we need to create a temporary User that is used in the session. This formats, as we need to create a temporary User that is used in the session. This
user has an attribute that refers to our Source for cleanup. The user is also deleted user has an attribute that refers to our Source for cleanup. The user is also deleted
@ -151,24 +155,23 @@ class ResponseProcessor:
LOGGER.debug("Created temporary user for NameID Transient", username=name_id) LOGGER.debug("Created temporary user for NameID Transient", username=name_id)
user.set_unusable_password() user.set_unusable_password()
user.save() user.save()
return self._flow_response( UserSAMLSourceConnection.objects.create(source=self._source, user=user, identifier=name_id)
request, return SAMLSourceFlowManager(
self._source.authentication_flow, self._source,
**{ self._http_request,
PLAN_CONTEXT_PENDING_USER: user, name_id,
PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT, delete_none_keys(self.get_attributes()),
},
) )
def _get_name_id(self) -> "Element": def _get_name_id(self) -> "Element":
"""Get NameID Element""" """Get NameID Element"""
assertion = self._root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion") assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
if not assertion: if assertion is None:
raise ValueError("Assertion element not found") raise ValueError("Assertion element not found")
subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject") subject = assertion.find(f"{{{NS_SAML_ASSERTION}}}Subject")
if not subject: if subject is None:
raise ValueError("Subject element not found") raise ValueError("Subject element not found")
name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID") name_id = subject.find(f"{{{NS_SAML_ASSERTION}}}NameID")
if name_id is None: if name_id is None:
raise ValueError("NameID element not found") raise ValueError("NameID element not found")
return name_id return name_id
@ -195,7 +198,27 @@ class ResponseProcessor:
f"Assertion contains NameID with unsupported format {_format}." f"Assertion contains NameID with unsupported format {_format}."
) )
def prepare_flow(self, request: HttpRequest) -> HttpResponse: def get_attributes(self) -> dict[str, list[str] | str]:
"""Get all attributes sent"""
attributes = {}
assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
if not assertion:
raise ValueError("Assertion element not found")
attribute_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
if not attribute_statement:
raise ValueError("Attribute statement element not found")
# Get all attributes and their values into a dict
for attribute in attribute_statement.iterchildren():
key = attribute.attrib["Name"]
attributes.setdefault(key, [])
for value in attribute.iterchildren():
attributes[key].append(value.text)
# Flatten all lists in the dict
for key, value in attributes.items():
attributes[key] = BaseEvaluator.expr_flatten(value)
return attributes
def prepare_flow_manager(self) -> SourceFlowManager:
"""Prepare flow plan depending on whether or not the user exists""" """Prepare flow plan depending on whether or not the user exists"""
name_id = self._get_name_id() name_id = self._get_name_id()
# Sanity check, show a warning if NameIDPolicy doesn't match what we go # Sanity check, show a warning if NameIDPolicy doesn't match what we go
@ -207,38 +230,17 @@ class ResponseProcessor:
) )
# transient NameIDs are handled separately as they don't have to go through flows. # transient NameIDs are handled separately as they don't have to go through flows.
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT: if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
return self._handle_name_id_transient(request) return self._handle_name_id_transient()
name_id_filter = self._get_name_id_filter() return SAMLSourceFlowManager(
matching_users = User.objects.filter(**name_id_filter) self._source,
# Ensure redirect is carried through when user was trying to self._http_request,
# authorize application name_id.text,
final_redirect = self._http_request.session.get(SESSION_KEY_GET, {}).get( delete_none_keys(self.get_attributes()),
NEXT_ARG_NAME, "authentik_core:if-user"
)
if matching_users.exists():
# User exists already, switch to authentication flow
return self._flow_response(
request,
self._source.authentication_flow,
**{
PLAN_CONTEXT_PENDING_USER: matching_users.first(),
PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT,
PLAN_CONTEXT_REDIRECT: final_redirect,
},
)
return self._flow_response(
request,
self._source.enrollment_flow,
**{PLAN_CONTEXT_PROMPT: delete_none_keys(name_id_filter)},
) )
def _flow_response(self, request: HttpRequest, flow: Flow, **kwargs) -> HttpResponse:
kwargs[PLAN_CONTEXT_SSO] = True class SAMLSourceFlowManager(SourceFlowManager):
kwargs[PLAN_CONTEXT_SOURCE] = self._source """Source flow manager for SAML Sources"""
request.session[SESSION_KEY_PLAN] = FlowPlanner(flow).plan(request, kwargs)
return redirect_with_qs( connection_type = UserSAMLSourceConnection
"authentik_core:if-flow",
request.GET,
flow_slug=flow.slug,
)

View File

@ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase
from lxml import etree # nosec from lxml import etree # nosec
from authentik.core.tests.utils import create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.lib.xml import lxml_from_string from authentik.lib.xml import lxml_from_string
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
@ -14,17 +15,17 @@ class TestMetadataProcessor(TestCase):
def setUp(self): def setUp(self):
self.factory = RequestFactory() self.factory = RequestFactory()
self.source = SAMLSource.objects.create(
def test_metadata_schema(self): slug=generate_id(),
"""Test Metadata generation being valid"""
source = SAMLSource.objects.create(
slug="provider",
issuer="authentik", issuer="authentik",
signing_kp=create_test_cert(), signing_kp=create_test_cert(),
pre_authentication_flow=create_test_flow(), pre_authentication_flow=create_test_flow(),
) )
def test_metadata_schema(self):
"""Test Metadata generation being valid"""
request = self.factory.get("/") request = self.factory.get("/")
xml = MetadataProcessor(source, request).build_entity_descriptor() xml = MetadataProcessor(self.source, request).build_entity_descriptor()
metadata = lxml_from_string(xml) metadata = lxml_from_string(xml)
schema = etree.XMLSchema(etree.parse("xml/saml-schema-metadata-2.0.xsd")) # nosec schema = etree.XMLSchema(etree.parse("xml/saml-schema-metadata-2.0.xsd")) # nosec
@ -32,38 +33,23 @@ class TestMetadataProcessor(TestCase):
def test_metadata_consistent(self): def test_metadata_consistent(self):
"""Test Metadata generation being consistent (xml stays the same)""" """Test Metadata generation being consistent (xml stays the same)"""
source = SAMLSource.objects.create(
slug="provider",
issuer="authentik",
signing_kp=create_test_cert(),
pre_authentication_flow=create_test_flow(),
)
request = self.factory.get("/") request = self.factory.get("/")
xml_a = MetadataProcessor(source, request).build_entity_descriptor() xml_a = MetadataProcessor(self.source, request).build_entity_descriptor()
xml_b = MetadataProcessor(source, request).build_entity_descriptor() xml_b = MetadataProcessor(self.source, request).build_entity_descriptor()
self.assertEqual(xml_a, xml_b) self.assertEqual(xml_a, xml_b)
def test_metadata(self): def test_metadata(self):
"""Test Metadata generation being valid""" """Test Metadata generation being valid"""
source = SAMLSource.objects.create(
slug="provider",
issuer="authentik",
signing_kp=create_test_cert(),
pre_authentication_flow=create_test_flow(),
)
request = self.factory.get("/") request = self.factory.get("/")
xml = MetadataProcessor(source, request).build_entity_descriptor() xml = MetadataProcessor(self.source, request).build_entity_descriptor()
metadata = ElementTree.fromstring(xml) metadata = ElementTree.fromstring(xml)
self.assertEqual(metadata.attrib["entityID"], "authentik") self.assertEqual(metadata.attrib["entityID"], "authentik")
def test_metadata_without_signautre(self): def test_metadata_without_signautre(self):
"""Test Metadata generation being valid""" """Test Metadata generation being valid"""
source = SAMLSource.objects.create( self.source.signing_kp = None
slug="provider", self.source.save()
issuer="authentik",
pre_authentication_flow=create_test_flow(),
)
request = self.factory.get("/") request = self.factory.get("/")
xml = MetadataProcessor(source, request).build_entity_descriptor() xml = MetadataProcessor(self.source, request).build_entity_descriptor()
metadata = ElementTree.fromstring(xml) metadata = ElementTree.fromstring(xml)
self.assertEqual(metadata.attrib["entityID"], "authentik") self.assertEqual(metadata.attrib["entityID"], "authentik")

View File

@ -0,0 +1,112 @@
"""SAML Source tests"""
from base64 import b64encode
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import RequestFactory, TestCase
from authentik.core.tests.utils import create_test_flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response
from authentik.sources.saml.models import SAMLSource
from authentik.sources.saml.processors.response import ResponseProcessor
RESPONSE_ERROR = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="https://127.0.0.1:9443/source/saml/google/acs/" ID="_ee7a8865ac457e7b22cb4f16b39ceca9" IssueInstant="2022-10-14T13:52:04.479Z" Version="2.0">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Requester">
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:RequestDenied"></saml2p:StatusCode>
</saml2p:StatusCode>
<saml2p:StatusMessage>Invalid request, ACS Url in request http://localhost:9000/source/saml/google/acs/ doesn't match configured ACS Url https://127.0.0.1:9443/source/saml/google/acs/.</saml2p:StatusMessage>
</saml2p:Status>
</saml2p:Response>
"""
RESPONSE_SUCCESS = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="https://127.0.0.1:9443/source/saml/google/acs/" ID="_1e17063957f10819a5a8e147971fec22" InResponseTo="_157fb504b59f4ae3919f74896a6b8565" IssueInstant="2022-10-14T14:11:49.590Z" Version="2.0">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://accounts.google.com/o/saml2?idpid=</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"></saml2p:StatusCode>
</saml2p:Status>
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_346001c5708ffd118c40edbc0c72fc60" IssueInstant="2022-10-14T14:11:49.590Z" Version="2.0">
<saml2:Issuer>https://accounts.google.com/o/saml2?idpid=</saml2:Issuer>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">jens@beryju.org</saml2:NameID>
<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml2:SubjectConfirmationData InResponseTo="_157fb504b59f4ae3919f74896a6b8565" NotOnOrAfter="2022-10-14T14:16:49.590Z" Recipient="https://127.0.0.1:9443/source/saml/google/acs/"></saml2:SubjectConfirmationData>
</saml2:SubjectConfirmation>
</saml2:Subject>
<saml2:Conditions NotBefore="2022-10-14T14:06:49.590Z" NotOnOrAfter="2022-10-14T14:16:49.590Z">
<saml2:AudienceRestriction>
<saml2:Audience>https://accounts.google.com/o/saml2?idpid=</saml2:Audience>
</saml2:AudienceRestriction>
</saml2:Conditions>
<saml2:AttributeStatement>
<saml2:Attribute Name="name">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">foo</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="sn">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">bar</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="email">
<saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:anyType">foo@bar.baz</saml2:AttributeValue>
</saml2:Attribute>
</saml2:AttributeStatement>
<saml2:AuthnStatement AuthnInstant="2022-10-14T12:16:21.000Z" SessionIndex="_346001c5708ffd118c40edbc0c72fc60">
<saml2:AuthnContext>
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>
</saml2:AuthnContext>
</saml2:AuthnStatement>
</saml2:Assertion>
</saml2p:Response>
"""
class TestResponseProcessor(TestCase):
"""Test ResponseProcessor"""
def setUp(self):
self.factory = RequestFactory()
self.source = SAMLSource.objects.create(
slug=generate_id(),
issuer="authentik",
allow_idp_initiated=True,
pre_authentication_flow=create_test_flow(),
)
def test_status_error(self):
"""Test error status"""
request = self.factory.post(
"/", data={"SAMLResponse": b64encode(RESPONSE_ERROR.encode()).decode()}
)
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()
with self.assertRaisesMessage(
ValueError,
(
"Invalid request, ACS Url in request http://localhost:9000/source/saml/google/acs/ "
"doesn't match configured ACS Url https://127.0.0.1:9443/source/saml/google/acs/."
),
):
ResponseProcessor(self.source, request).parse()
def test_success(self):
"""Test success"""
request = self.factory.post(
"/", data={"SAMLResponse": b64encode(RESPONSE_SUCCESS.encode()).decode()}
)
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()
parser = ResponseProcessor(self.source, request)
parser.parse()
sfm = parser.prepare_flow_manager()
self.assertEqual(sfm.enroll_info, {"email": "foo@bar.baz", "name": "foo", "sn": "bar"})

View File

@ -153,16 +153,16 @@ class ACSView(View):
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug) source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
if not source.enabled: if not source.enabled:
raise Http404 raise Http404
processor = ResponseProcessor(source) processor = ResponseProcessor(source, request)
try: try:
processor.parse(request) processor.parse()
except MissingSAMLResponse as exc: except MissingSAMLResponse as exc:
return bad_request_message(request, str(exc)) return bad_request_message(request, str(exc))
except VerificationError as exc: except VerificationError as exc:
return bad_request_message(request, str(exc)) return bad_request_message(request, str(exc))
try: try:
return processor.prepare_flow(request) return processor.prepare_flow_manager().get_flow()
except (UnsupportedNameIDFormat, ValueError) as exc: except (UnsupportedNameIDFormat, ValueError) as exc:
return bad_request_message(request, str(exc)) return bad_request_message(request, str(exc))

View File

@ -89,6 +89,7 @@
"authentik_sources_plex.plexsource", "authentik_sources_plex.plexsource",
"authentik_sources_plex.plexsourceconnection", "authentik_sources_plex.plexsourceconnection",
"authentik_sources_saml.samlsource", "authentik_sources_saml.samlsource",
"authentik_sources_saml.usersamlsourceconnection",
"authentik_stages_authenticator_duo.authenticatorduostage", "authentik_stages_authenticator_duo.authenticatorduostage",
"authentik_stages_authenticator_duo.duodevice", "authentik_stages_authenticator_duo.duodevice",
"authentik_stages_authenticator_sms.authenticatorsmsstage", "authentik_stages_authenticator_sms.authenticatorsmsstage",

View File

@ -18164,6 +18164,270 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/sources/user_connections/saml/:
get:
operationId: sources_user_connections_saml_list
description: Source Viewset
parameters:
- name: ordering
required: false
in: query
description: Which field to use when ordering the results.
schema:
type: string
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: search
required: false
in: query
description: A search term.
schema:
type: string
- in: query
name: source__slug
schema:
type: string
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedUserSAMLSourceConnectionList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
post:
operationId: sources_user_connections_saml_create
description: Source Viewset
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserSAMLSourceConnectionRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/UserSAMLSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/user_connections/saml/{id}/:
get:
operationId: sources_user_connections_saml_retrieve
description: Source Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User SAML Source Connection.
required: true
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserSAMLSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
put:
operationId: sources_user_connections_saml_update
description: Source Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User SAML Source Connection.
required: true
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserSAMLSourceConnectionRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserSAMLSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
patch:
operationId: sources_user_connections_saml_partial_update
description: Source Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User SAML Source Connection.
required: true
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedUserSAMLSourceConnectionRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserSAMLSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
delete:
operationId: sources_user_connections_saml_destroy
description: Source Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User SAML Source Connection.
required: true
tags:
- sources
security:
- authentik: []
responses:
'204':
description: No response body
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/user_connections/saml/{id}/used_by/:
get:
operationId: sources_user_connections_saml_used_by_list
description: Get a list of all objects that use this object
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User SAML Source Connection.
required: true
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UsedBy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/all/: /stages/all/:
get: get:
operationId: stages_all_list operationId: stages_all_list
@ -32190,6 +32454,41 @@ components:
required: required:
- pagination - pagination
- results - results
PaginatedUserSAMLSourceConnectionList:
type: object
properties:
pagination:
type: object
properties:
next:
type: number
previous:
type: number
count:
type: number
current:
type: number
total_pages:
type: number
start_index:
type: number
end_index:
type: number
required:
- next
- previous
- count
- current
- total_pages
- start_index
- end_index
results:
type: array
items:
$ref: '#/components/schemas/UserSAMLSourceConnection'
required:
- pagination
- results
PaginatedUserSourceConnectionList: PaginatedUserSourceConnectionList:
type: object type: object
properties: properties:
@ -34432,6 +34731,15 @@ components:
path: path:
type: string type: string
minLength: 1 minLength: 1
PatchedUserSAMLSourceConnectionRequest:
type: object
description: SAML Source Serializer
properties:
user:
type: integer
identifier:
type: string
minLength: 1
PatchedUserWriteStageRequest: PatchedUserWriteStageRequest:
type: object type: object
description: UserWriteStage Serializer description: UserWriteStage Serializer
@ -37376,6 +37684,39 @@ components:
- groups - groups
- name - name
- username - username
UserSAMLSourceConnection:
type: object
description: SAML Source Serializer
properties:
pk:
type: integer
readOnly: true
title: ID
user:
type: integer
source:
allOf:
- $ref: '#/components/schemas/Source'
readOnly: true
identifier:
type: string
required:
- identifier
- pk
- source
- user
UserSAMLSourceConnectionRequest:
type: object
description: SAML Source Serializer
properties:
user:
type: integer
identifier:
type: string
minLength: 1
required:
- identifier
- user
UserSelf: UserSelf:
type: object type: object
description: User Serializer for information a user can retrieve about themselves description: User Serializer for information a user can retrieve about themselves

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000">
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@ -1,3 +1,4 @@
import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
@ -22,6 +23,7 @@ import {
SAMLSource, SAMLSource,
SignatureAlgorithmEnum, SignatureAlgorithmEnum,
SourcesApi, SourcesApi,
UserMatchingModeEnum,
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-source-saml-form") @customElement("ak-source-saml-form")
@ -81,6 +83,49 @@ export class SAMLSourceForm extends ModelForm<SAMLSource, string> {
<label class="pf-c-check__label"> ${t`Enabled`} </label> <label class="pf-c-check__label"> ${t`Enabled`} </label>
</div> </div>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`User matching mode`}
?required=${true}
name="userMatchingMode"
>
<select class="pf-c-form-control">
<option
value=${UserMatchingModeEnum.Identifier}
?selected=${this.instance?.userMatchingMode ===
UserMatchingModeEnum.Identifier}
>
${UserMatchingModeToLabel(UserMatchingModeEnum.Identifier)}
</option>
<option
value=${UserMatchingModeEnum.EmailLink}
?selected=${this.instance?.userMatchingMode ===
UserMatchingModeEnum.EmailLink}
>
${UserMatchingModeToLabel(UserMatchingModeEnum.EmailLink)}
</option>
<option
value=${UserMatchingModeEnum.EmailDeny}
?selected=${this.instance?.userMatchingMode ===
UserMatchingModeEnum.EmailDeny}
>
${UserMatchingModeToLabel(UserMatchingModeEnum.EmailDeny)}
</option>
<option
value=${UserMatchingModeEnum.UsernameLink}
?selected=${this.instance?.userMatchingMode ===
UserMatchingModeEnum.UsernameLink}
>
${UserMatchingModeToLabel(UserMatchingModeEnum.UsernameLink)}
</option>
<option
value=${UserMatchingModeEnum.UsernameDeny}
?selected=${this.instance?.userMatchingMode ===
UserMatchingModeEnum.UsernameDeny}
>
${UserMatchingModeToLabel(UserMatchingModeEnum.UsernameDeny)}
</option>
</select>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}> <ak-form-group .expanded=${true}>
<span slot="header"> ${t`Protocol settings`} </span> <span slot="header"> ${t`Protocol settings`} </span>

View File

@ -534,7 +534,7 @@ export class FlowExecutor extends AKElement implements StageHost {
? html` ? html`
<li> <li>
<a <a
href="https://unsplash.com/@impatrickt" href="https://unsplash.com/@brendan_k_steeves"
>${t`Background image`}</a >${t`Background image`}</a
> >
</li> </li>

View File

@ -4,6 +4,7 @@ import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import "@goauthentik/user/user-settings/sources/SourceSettingsOAuth"; import "@goauthentik/user/user-settings/sources/SourceSettingsOAuth";
import "@goauthentik/user/user-settings/sources/SourceSettingsPlex"; import "@goauthentik/user/user-settings/sources/SourceSettingsPlex";
import "@goauthentik/user/user-settings/sources/SourceSettingsSAML";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
@ -95,6 +96,15 @@ export class UserSourceSettingsPage extends AKElement {
.configureUrl=${source.configureUrl} .configureUrl=${source.configureUrl}
> >
</ak-user-settings-source-plex>`; </ak-user-settings-source-plex>`;
case "ak-user-settings-source-saml":
return html`<ak-user-settings-source-saml
class="pf-c-data-list__item-row"
objectId=${source.objectUid}
title=${source.title}
connectionPk=${connectionPk}
.configureUrl=${source.configureUrl}
>
</ak-user-settings-source-saml>`;
default: default:
return html`<p>${t`Error: unsupported source settings: ${source.component}`}</p>`; return html`<p>${t`Error: unsupported source settings: ${source.component}`}</p>`;
} }

View File

@ -0,0 +1,70 @@
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/Spinner";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { BaseUserSettings } from "@goauthentik/user/user-settings/BaseUserSettings";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { SourcesApi } from "@goauthentik/api";
@customElement("ak-user-settings-source-saml")
export class SourceSettingsSAML extends BaseUserSettings {
@property()
title!: string;
@property({ type: Number })
connectionPk = 0;
render(): TemplateResult {
if (this.connectionPk === -1) {
return html`<ak-spinner></ak-spinner>`;
}
if (this.connectionPk > 0) {
return html`<button
class="pf-c-button pf-m-danger"
@click=${() => {
return new SourcesApi(DEFAULT_CONFIG)
.sourcesUserConnectionsSamlDestroy({
id: this.connectionPk,
})
.then(() => {
showMessage({
level: MessageLevel.info,
message: t`Successfully disconnected source`,
});
})
.catch((exc) => {
showMessage({
level: MessageLevel.error,
message: t`Failed to disconnected source: ${exc}`,
});
})
.finally(() => {
this.parentElement?.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
});
}}
>
${t`Disconnect`}
</button>`;
}
return html`<a
class="pf-c-button pf-m-primary"
href="${ifDefined(this.configureUrl)}${AndNext(
`/if/user/#/settings;${JSON.stringify({ page: "page-sources" })}`,
)}"
>
${t`Connect`}
</a>`;
}
}