saml_idp: cleanup, fix XML signing

This commit is contained in:
Jens Langhammer 2018-12-26 21:56:08 +01:00
parent aa7e3c2a15
commit ebda84bcaf
No known key found for this signature in database
GPG Key ID: BEBC05297D92821B
11 changed files with 36 additions and 63 deletions

View File

@ -51,7 +51,7 @@ class Processor:
_saml_response = None _saml_response = None
_session_index = None _session_index = None
_subject = None _subject = None
_subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:email' _subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
_system_params = { _system_params = {
'ISSUER': CONFIG.y('saml_idp.issuer'), 'ISSUER': CONFIG.y('saml_idp.issuer'),
} }
@ -84,8 +84,7 @@ class Processor:
'AUTH_INSTANT': get_time_string(), 'AUTH_INSTANT': get_time_string(),
'ISSUE_INSTANT': get_time_string(), 'ISSUE_INSTANT': get_time_string(),
'NOT_BEFORE': get_time_string(-1 * HOURS), # TODO: Make these settings. 'NOT_BEFORE': get_time_string(-1 * HOURS), # TODO: Make these settings.
'NOT_ON_OR_AFTER': get_time_string(int(CONFIG.y('saml_idp.assertion_valid_for')) 'NOT_ON_OR_AFTER': get_time_string(86400 * MINUTES),
* MINUTES),
'SESSION_INDEX': self._session_index, 'SESSION_INDEX': self._session_index,
'SESSION_NOT_ON_OR_AFTER': get_time_string(8 * HOURS), 'SESSION_NOT_ON_OR_AFTER': get_time_string(8 * HOURS),
'SP_NAME_QUALIFIER': self._audience, 'SP_NAME_QUALIFIER': self._audience,
@ -226,7 +225,7 @@ class Processor:
self._saml_response = sp_config self._saml_response = sp_config
self._session_index = sp_config self._session_index = sp_config
self._subject = sp_config self._subject = sp_config
self._subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:email' self._subject_format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
self._system_params = { self._system_params = {
'ISSUER': CONFIG.y('saml_idp.issuer'), 'ISSUER': CONFIG.y('saml_idp.issuer'),
} }

View File

@ -40,8 +40,12 @@ class SAMLProvider(Provider):
def link_download_metadata(self): def link_download_metadata(self):
"""Get link to download XML metadata for admin interface""" """Get link to download XML metadata for admin interface"""
return reverse('passbook_saml_idp:metadata_xml', # pylint: disable=no-member
kwargs={'provider_id': self.pk}) if self.application:
# pylint: disable=no-member
return reverse('passbook_saml_idp:metadata_xml',
kwargs={'application': self.application.slug})
return None
class Meta: class Meta:

View File

@ -5,14 +5,14 @@
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer> <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
{% include 'saml/xml/signature.xml' %} {% include 'saml/xml/signature.xml' %}
{{ SUBJECT_STATEMENT }} {{ SUBJECT_STATEMENT }}
<saml:Conditions NotBefore="2014-07-17T01:01:18Z" NotOnOrAfter="2024-01-18T06:21:48Z"> <saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
<saml:AudienceRestriction> <saml:AudienceRestriction>
<saml:Audience>{{ AUDIENCE }}</saml:Audience> <saml:Audience>{{ AUDIENCE }}</saml:Audience>
</saml:AudienceRestriction> </saml:AudienceRestriction>
</saml:Conditions> </saml:Conditions>
<saml:AuthnStatement AuthnInstant="2014-07-17T01:01:48Z" SessionNotOnOrAfter="2024-07-17T09:01:48Z" SessionIndex="_be9967abd904ddcae3c0eb4189adbe3f71e327cf93"> <saml:AuthnStatement AuthnInstant="{{ NOT_BEFORE }}" SessionIndex="{{ ASSERTION_ID }}">
<saml:AuthnContext> <saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef> <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
</saml:AuthnContext> </saml:AuthnContext>
</saml:AuthnStatement> </saml:AuthnStatement>
{{ ATTRIBUTE_STATEMENT }} {{ ATTRIBUTE_STATEMENT }}

View File

@ -1,7 +1,7 @@
<saml:AttributeStatement> <saml:AttributeStatement>
{% for attr in attributes %} {% for attr in attributes %}
<saml:Attribute FriendlyName="{{ attr.FriendlyName }}" Name="{{ attr.Name }}" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"> <saml:Attribute {% if attr.FriendlyName %}FriendlyName="{{ attr.FriendlyName }}" {% endif %}Name="{{ attr.Name }}">
<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">{{ attr.Value }}</saml:AttributeValue> <saml:AttributeValue>{{ attr.Value }}</saml:AttributeValue>
</saml:Attribute> </saml:Attribute>
{% endfor %} {% endfor %}
</saml:AttributeStatement> </saml:AttributeStatement>

View File

@ -16,7 +16,7 @@
</ds:KeyInfo> </ds:KeyInfo>
</md:KeyDescriptor> </md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ slo_url }}"/> <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ slo_url }}"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:email</md:NameIDFormat> <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_url }}"/> <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_url }}"/>
</md:IDPSSODescriptor> </md:IDPSSODescriptor>
{% comment %} {% comment %}

View File

@ -1,4 +1,5 @@
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
Destination="{{ ACS_URL }}" Destination="{{ ACS_URL }}"
ID="{{ RESPONSE_ID }}" ID="{{ RESPONSE_ID }}"
{{ IN_RESPONSE_TO|safe }} {{ IN_RESPONSE_TO|safe }}

View File

@ -1,5 +1,5 @@
<saml:Subject> <saml:Subject>
<saml:NameID Format="{{ SUBJECT_FORMAT }}" SPNameQualifier="{{ SP_NAME_QUALIFIER }}"> <saml:NameID Format="{{ SUBJECT_FORMAT }}">
{{ SUBJECT }} {{ SUBJECT }}
</saml:NameID> </saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">

View File

@ -6,8 +6,8 @@ from passbook.saml_idp import views
urlpatterns = [ urlpatterns = [
path('login/<slug:application>/', path('login/<slug:application>/',
views.LoginBeginView.as_view(), name="saml_login_begin"), views.LoginBeginView.as_view(), name="saml_login_begin"),
path('login/<slug:application>/idp_init/', path('login/<slug:application>/initiate/',
views.LoginInitView.as_view(), name="saml_login_init"), views.InitiateLoginView.as_view(), name="saml_login_init"),
path('login/<slug:application>/process/', path('login/<slug:application>/process/',
views.LoginProcessView.as_view(), name='saml_login_process'), views.LoginProcessView.as_view(), name='saml_login_process'),
path('logout/', views.LogoutView.as_view(), name="saml_logout"), path('logout/', views.LogoutView.as_view(), name="saml_logout"),

View File

@ -1,7 +1,6 @@
"""passbook SAML IDP Views""" """passbook SAML IDP Views"""
from logging import getLogger from logging import getLogger
from django.conf import settings
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.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -10,14 +9,6 @@ from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render, reverse from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.datastructures import MultiValueDictKeyError from django.utils.datastructures import MultiValueDictKeyError
from django.views import View from django.views import View
from saml2 import BINDING_HTTP_POST
from saml2.authn_context import PASSWORD, AuthnBroker, authn_context_class_ref
from saml2.config import IdPConfig
from saml2.ident import NameID
from saml2.metadata import entity_descriptor
from saml2.s_utils import UnknownPrincipal, UnsupportedBinding
from saml2.saml import NAMEID_FORMAT_EMAILADDRESS, NAMEID_FORMAT_UNSPECIFIED
from saml2.server import Server
from signxml.util import strip_pem_header from signxml.util import strip_pem_header
from passbook.core.models import Application from passbook.core.models import Application
@ -50,11 +41,13 @@ def render_xml(request, template, ctx):
class ProviderMixin: class ProviderMixin:
"""Mixin class for Views using a provider instance"""
_provider = None _provider = None
@property @property
def provider(self): def provider(self):
"""Get provider instance"""
if not self._provider: if not self._provider:
application = get_object_or_404(Application, slug=self.kwargs['application']) application = get_object_or_404(Application, slug=self.kwargs['application'])
self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id) self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
@ -123,7 +116,7 @@ class LoginProcessView(ProviderMixin, View):
saml_response=request.POST.get('SAMLResponse'), saml_response=request.POST.get('SAMLResponse'),
relay_state=request.POST.get('RelayState')) relay_state=request.POST.get('RelayState'))
try: try:
full_res = _generate_response(request, provider) full_res = _generate_response(request, self.provider)
return full_res return full_res
except exceptions.CannotHandleAssertion as exc: except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc) LOGGER.debug(exc)
@ -166,33 +159,17 @@ class SLOLogout(CSRFExemptMixin, LoginRequiredMixin, View):
logout(request) logout(request)
return render(request, 'saml/idp/logged_out.html') return render(request, 'saml/idp/logged_out.html')
class IdPMixin(ProviderMixin):
provider = None
def dispatch(self, request, application):
def get_identity(self, provider, user):
""" Create Identity dict (using SP-specific mapping)
"""
sp_mapping = {'username': 'username'}
# return provider.processor.create_identity(user, sp_mapping)
return {
out_attr: getattr(user, user_attr)
for user_attr, out_attr in sp_mapping.items()
if hasattr(user, user_attr)
}
class DescriptorDownloadView(ProviderMixin, View): class DescriptorDownloadView(ProviderMixin, View):
"""Replies with the XML Metadata IDSSODescriptor.""" """Replies with the XML Metadata IDSSODescriptor."""
def get(self, request, application): def get(self, request, application):
"""Replies with the XML Metadata IDSSODescriptor.""" """Replies with the XML Metadata IDSSODescriptor."""
super().dispatch(request, application)
entity_id = CONFIG.y('saml_idp.issuer') entity_id = CONFIG.y('saml_idp.issuer')
slo_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml_logout')) slo_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml_logout'))
sso_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml_login_begin')) sso_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml_login_begin', kwargs={
'application': application
}))
pubkey = strip_pem_header(self.provider.signing_cert.replace('\r', '')).replace('\n', '') pubkey = strip_pem_header(self.provider.signing_cert.replace('\r', '')).replace('\n', '')
ctx = { ctx = {
'entity_id': entity_id, 'entity_id': entity_id,
@ -207,19 +184,11 @@ class DescriptorDownloadView(ProviderMixin, View):
return response return response
class LoginInitView(IdPMixin, LoginRequiredMixin, View): class InitiateLoginView(ProviderMixin, LoginRequiredMixin, View):
"""IdP-initiated Login"""
def dispatch(self, request, application): def dispatch(self, request, application):
"""Initiates an IdP-initiated link to a simple SP resource/target URL.""" """Initiates an IdP-initiated link to a simple SP resource/target URL."""
super().dispatch(request, application) super().dispatch(request, application)
self.provider.processor.init_deep_link(request, '')
# # linkdict = dict(metadata.get_links(sp_config)) return _generate_response(request, self.provider)
# # pattern = linkdict[resource]
# # is_simple_link = ('/' not in resource)
# # if is_simple_link:
# # simple_target = kwargs['target']
# # url = pattern % simple_target
# # else:
# # url = pattern % kwargs
# provider.processor.init_deep_link(request, 'deep url')
# return _generate_response(request, provider)

View File

@ -83,5 +83,5 @@ def get_response_xml(parameters, saml_provider: 'SAMLProvider', assertion_id='')
signed = sign_with_signxml( signed = sign_with_signxml(
saml_provider.signing_key, raw_response, saml_provider.signing_cert, saml_provider.signing_key, raw_response, saml_provider.signing_cert,
reference_uri=assertion_id).decode("utf-8") reference_uri=assertion_id)
return signed return signed

View File

@ -3,9 +3,8 @@ from logging import getLogger
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from defusedxml import ElementTree
from lxml import etree # nosec from lxml import etree # nosec
from signxml import XMLSigner from signxml import XMLSigner, XMLVerifier
from passbook.lib.utils.template import render_to_string from passbook.lib.utils.template import render_to_string
@ -17,12 +16,13 @@ def sign_with_signxml(private_key, data, cert, reference_uri=None):
key = serialization.load_pem_private_key( key = serialization.load_pem_private_key(
str.encode('\n'.join([x.strip() for x in private_key.split('\n')])), str.encode('\n'.join([x.strip() for x in private_key.split('\n')])),
password=None, backend=default_backend()) password=None, backend=default_backend())
# LXML is used here because defusedxml causes issues with serialization # defused XML is not used here because it messes up XML namespaces
# data is trusted so no issues # Data is trusted, so lxml is ok
root = etree.fromstring(data) # nosec root = etree.fromstring(data) # nosec
signer = XMLSigner(c14n_algorithm='http://www.w3.org/2001/10/xml-exc-c14n#') signer = XMLSigner(c14n_algorithm='http://www.w3.org/2001/10/xml-exc-c14n#')
signed = signer.sign(root, key=key, cert=cert, reference_uri=reference_uri) signed = signer.sign(root, key=key, cert=[cert], reference_uri=reference_uri)
return ElementTree.tostring(signed) XMLVerifier().verify(signed, x509_cert=cert)
return etree.tostring(signed).decode('utf-8') # nosec
def get_signature_xml(): def get_signature_xml():