providers/saml: disallow idp-initiated SSO by default and validate Request ID

This commit is contained in:
Jens Langhammer
2020-09-12 00:53:38 +02:00
parent c2ebaa7f64
commit ca0ba85023
10 changed files with 138 additions and 47 deletions

View File

@ -15,9 +15,10 @@ class SAMLSourceSerializer(ModelSerializer):
fields = SOURCE_FORM_FIELDS + [
"issuer",
"sso_url",
"slo_url",
"allow_idp_initiated",
"name_id_policy",
"binding_type",
"slo_url",
"temporary_user_delete_after",
"signing_kp",
]

View File

@ -8,3 +8,7 @@ class MissingSAMLResponse(SentryIgnoredException):
class UnsupportedNameIDFormat(SentryIgnoredException):
"""Exception raised when SAML Response contains NameID Format not supported."""
class MismatchedRequestID(SentryIgnoredException):
"""Exception raised when the returned request ID doesn't match the saved ID."""

View File

@ -30,9 +30,10 @@ class SAMLSourceForm(forms.ModelForm):
fields = SOURCE_FORM_FIELDS + [
"issuer",
"sso_url",
"name_id_policy",
"binding_type",
"slo_url",
"binding_type",
"name_id_policy",
"allow_idp_initiated",
"temporary_user_delete_after",
"signing_kp",
]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.1.1 on 2020-09-11 22:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_sources_saml", "0005_samlsource_name_id_policy"),
]
operations = [
migrations.AddField(
model_name="samlsource",
name="allow_idp_initiated",
field=models.BooleanField(
default=False,
help_text="Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done.",
),
),
]

View File

@ -53,6 +53,21 @@ class SAMLSource(Source):
verbose_name=_("SSO URL"),
help_text=_("URL that the initial Login request is sent to."),
)
slo_url = models.URLField(
default=None,
blank=True,
null=True,
verbose_name=_("SLO URL"),
help_text=_("Optional URL if your IDP supports Single-Logout."),
)
allow_idp_initiated = models.BooleanField(
default=False,
help_text=_(
"Allows authentication flows initiated by the IdP. This can be a security risk, "
"as no validation of the request ID is done."
),
)
name_id_policy = models.TextField(
choices=SAMLNameIDPolicy.choices,
default=SAMLNameIDPolicy.TRANSIENT,
@ -66,14 +81,6 @@ class SAMLSource(Source):
default=SAMLBindingTypes.Redirect,
)
slo_url = models.URLField(
default=None,
blank=True,
null=True,
verbose_name=_("SLO URL"),
help_text=_("Optional URL if your IDP supports Single-Logout."),
)
temporary_user_delete_after = models.TextField(
default="days=1",
verbose_name=_("Delete temporary users after"),

View File

@ -20,6 +20,8 @@ from passbook.sources.saml.processors.constants import (
NS_SAML_PROTOCOL,
)
SESSION_REQUEST_ID = "passbook_source_saml_request_id"
class RequestProcessor:
"""SAML AuthnRequest Processor"""
@ -37,6 +39,7 @@ class RequestProcessor:
self.http_request = request
self.relay_state = relay_state
self.request_id = get_random_id()
self.http_request.session[SESSION_REQUEST_ID] = self.request_id
self.issue_instant = get_time_string()
def get_issuer(self) -> Element:

View File

@ -18,6 +18,7 @@ from passbook.lib.utils.urls import redirect_with_qs
from passbook.policies.utils import delete_none_keys
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate
from passbook.sources.saml.exceptions import (
MismatchedRequestID,
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
@ -29,6 +30,7 @@ from passbook.sources.saml.processors.constants import (
SAML_NAME_ID_FORMAT_WINDOWS,
SAML_NAME_ID_FORMAT_X509,
)
from passbook.sources.saml.processors.request import SESSION_REQUEST_ID
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
@ -59,8 +61,9 @@ class ResponseProcessor:
# Check if response is compressed, b64 decode it
self._root_xml = decode_base64_and_inflate(raw_response)
self._root = ElementTree.fromstring(self._root_xml)
# Verify signed XML
self._verify_signed()
self._verify_request_id(request)
def _verify_signed(self):
"""Verify SAML Response's Signature"""
@ -70,6 +73,16 @@ class ResponseProcessor:
)
LOGGER.debug("Successfully verified signautre")
def _verify_request_id(self, request: HttpRequest):
if self._source.allow_idp_initiated:
return
if SESSION_REQUEST_ID not in request.session or "ID" not in self._root.attrib:
raise MismatchedRequestID(
"Missing request ID and IdP-initiated Logins are not allowed"
)
if request.session[SESSION_REQUEST_ID] != self._root.attrib["ID"]:
raise MismatchedRequestID("Mismatched request ID")
def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse:
"""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