sources/saml: validate SAMLResponse signature

This commit is contained in:
Jens Langhammer 2020-02-20 21:33:45 +01:00
parent 38a22ddf13
commit 295c0bae3f
7 changed files with 116 additions and 82 deletions

View file

@ -13,7 +13,7 @@ class SAMLSourceSerializer(ModelSerializer):
model = SAMLSource
fields = [
"pk",
"entity_id",
"issuer",
"idp_url",
"idp_logout_url",
"auto_logout",

View file

@ -0,0 +1,9 @@
"""passbook saml source exceptions"""
class MissingSAMLResponse(Exception):
"""Exception raised when request does not contain SAML Response."""
class UnsupportedNameIDFormat(Exception):
"""Exception raised when SAML Response contains NameID Format not supported."""

View file

@ -22,7 +22,7 @@ class SAMLSourceForm(forms.ModelForm):
model = SAMLSource
fields = SOURCE_FORM_FIELDS + [
"entity_id",
"issuer",
"idp_url",
"idp_logout_url",
"auto_logout",
@ -31,7 +31,7 @@ class SAMLSourceForm(forms.ModelForm):
widgets = {
"name": forms.TextInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
"entity_id": forms.TextInput(),
"issuer": forms.TextInput(),
"idp_url": forms.TextInput(),
"idp_logout_url": forms.TextInput(),
}

View file

@ -0,0 +1,85 @@
"""passbook saml source processor"""
from typing import Optional
from xml.etree.ElementTree import Element
from defusedxml import ElementTree
from django.http import HttpRequest
from signxml import XMLVerifier
from structlog import get_logger
from passbook.core.models import User
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate
from passbook.sources.saml.exceptions import (
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
from passbook.sources.saml.models import SAMLSource
LOGGER = get_logger()
class Processor:
"""SAML Response Processor"""
_source: SAMLSource
_root: Element
_root_xml: str
def __init__(self, source: SAMLSource):
self._source = source
def parse(self, request: HttpRequest):
"""Check if `request` contains SAML Response data, parse and validate it."""
# First off, check if we have any SAML Data at all.
raw_response = request.POST.get("SAMLResponse", None)
if not raw_response:
raise MissingSAMLResponse("Request does not contain 'SAMLResponse'")
# relay_state = request.POST.get('RelayState', None)
# Check if response is compressed, b64 decode it
self._root_xml = response = decode_base64_and_inflate(raw_response)
self._root = ElementTree.fromstring(self._root_xml)
# Verify signed XML
self._verify_signed()
def _verify_signed(self):
"""Verify SAML Response's Signature"""
verifier = XMLVerifier()
verifier.verify(self._root_xml, x509_cert=self._source.signing_cert)
def _get_email(self) -> Optional[str]:
"""
Returns the email out of the response.
At present, response must pass the email address as the Subject, eg.:
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
SPNameQualifier=""
>email@example.com</saml:NameID>
"""
assertion = self._root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion")
subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject")
name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID")
name_id_format = name_id.attrib["Format"]
if name_id_format != "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress":
raise UnsupportedNameIDFormat(
f"Assertion contains NameID with unsupported format {name_id_format}."
)
return name_id.text
def get_user(self) -> User:
"""
Gets info out of the response and locally logs in this user.
May create a local user account first.
Returns the user object that was created.
"""
email = self._get_email()
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
user = User.objects.create_user(username=email, email=email)
# TODO: Property Mappings
user.set_unusable_password()
user.save()
return user

View file

@ -2,7 +2,6 @@
from django.http import HttpRequest
from django.shortcuts import reverse
from passbook.core.models import User
from passbook.sources.saml.models import SAMLSource
@ -19,65 +18,3 @@ def build_full_url(view: str, request: HttpRequest, source: SAMLSource) -> str:
return request.build_absolute_uri(
reverse(f"passbook_sources_saml:{view}", kwargs={"source_slug": source.slug})
)
def _get_email_from_response(root):
"""
Returns the email out of the response.
At present, response must pass the email address as the Subject, eg.:
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:email"
SPNameQualifier=""
>email@example.com</saml:NameID>
"""
assertion = root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion")
subject = assertion.find("{urn:oasis:names:tc:SAML:2.0:assertion}Subject")
name_id = subject.find("{urn:oasis:names:tc:SAML:2.0:assertion}NameID")
return name_id.text
def _get_attributes_from_response(root):
"""
Returns the SAML Attributes (if any) that are present in the response.
NOTE: Technically, attribute values could be any XML structure.
But for now, just assume a single string value.
"""
flat_attributes = {}
assertion = root.find("{urn:oasis:names:tc:SAML:2.0:assertion}Assertion")
attributes = assertion.find(
"{urn:oasis:names:tc:SAML:2.0:assertion}AttributeStatement"
)
for attribute in attributes.getchildren():
name = attribute.attrib.get("Name")
children = attribute.getchildren()
if not children:
# Ignore empty-valued attributes. (I think these are not allowed.)
continue
if len(children) == 1:
# See NOTE:
flat_attributes[name] = children[0].text
else:
# It has multiple values.
for child in children:
# See NOTE:
flat_attributes.setdefault(name, []).append(child.text)
return flat_attributes
def _get_user_from_response(root):
"""
Gets info out of the response and locally logs in this user.
May create a local user account first.
Returns the user object that was created.
"""
email = _get_email_from_response(root)
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
user = User.objects.create_user(username=email, email=email)
user.set_unusable_password()
user.save()
return user

View file

@ -1,7 +1,4 @@
"""saml sp views"""
import base64
from defusedxml import ElementTree
from django.contrib.auth import login, logout
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render, reverse
@ -10,15 +7,17 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header
from passbook.lib.views import bad_request_message
from passbook.providers.saml.utils import get_random_id, render_xml
from passbook.providers.saml.utils.encoding import nice64
from passbook.providers.saml.utils.time import get_time_string
from passbook.sources.saml.models import SAMLSource
from passbook.sources.saml.utils import (
_get_user_from_response,
build_full_url,
get_issuer,
from passbook.sources.saml.exceptions import (
MissingSAMLResponse,
UnsupportedNameIDFormat,
)
from passbook.sources.saml.models import SAMLSource
from passbook.sources.saml.processors.base import Processor
from passbook.sources.saml.utils import build_full_url, get_issuer
from passbook.sources.saml.xml_render import get_authnrequest_xml
@ -62,14 +61,18 @@ class ACSView(View):
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
if not source.enabled:
raise Http404
# sso_session = request.POST.get('RelayState', None)
data = request.POST.get("SAMLResponse", None)
response = base64.b64decode(data)
root = ElementTree.fromstring(response)
user = _get_user_from_response(root)
# attributes = _get_attributes_from_response(root)
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect(reverse("passbook_core:overview"))
processor = Processor(source)
try:
processor.parse(request)
except MissingSAMLResponse as exc:
return bad_request_message(request, str(exc))
try:
user = processor.get_user()
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect(reverse("passbook_core:overview"))
except UnsupportedNameIDFormat as exc:
return bad_request_message(request, str(exc))
class SLOView(View):