Skip to content
Snippets Groups Projects
Commit b1c0acf8 authored by kaiyou's avatar kaiyou
Browse files

Overal review of the SAML code and expose SAML metadata

parent f8a7c460
No related branches found
No related tags found
No related merge requests found
""" The SAML SSO provider relies heavily on pysaml2
Instead of simply using pysaml2 however, it overrides the original MetaData
store and removes all unnecessary bits for Hiboo, keeping only the basics of
request parsing and response crafting.
Also, instead of using xmlsec directly through the binary, which requires
writing temporary files, it leverages the Python bindings to xmlsec.
"""
# We monkey-patch the security context factory, so that we can silently
# replace it with our own xmlsec-based implementation.
# replace it with our own Python xmlsec-based implementation.
# This needs to be done before importing other pysaml2 components.
def security_context(conf):
return SecurityContext(conf)
from saml2 import sigver
......@@ -7,27 +18,38 @@ sigver.security_context = security_context
from hiboo.sso import blueprint, forms
from hiboo import models, utils, profile, security
from saml2 import server, saml, config, mdstore, assertion
from cryptography import x509
from cryptography.hazmat import primitives, backends
from saml2 import mdstore, server
import saml2, base64, datetime, flask, xmlsec, lxml.etree, flask_login
import saml2
import base64
import datetime
import flask
import xmlsec
import lxml.etree
class Config(object):
""" Handles service configuration and forms.
Settings are:
- acs: the assertion consuming service (on the SP side)
- entityid: the SP entity id (IDP entity id is its metadata endpoint)
- sign_mode: response signature mode (either the assertion or the response)
"""
IDP_CERT_NAME = "{}-idp"
SP_CERT_NAME = "{}-sp"
RSA_KEY_LENGTH = 2048
@classmethod
def derive_form(cls, form):
""" Add required fields to a form.
"""
return type('NewForm', (forms.SAMLForm, form), {})
return type("DerivedSAMLForm", (forms.SAMLForm, form), {})
@classmethod
def populate_service(cls, form, service):
""" Populate a service from a form
"""
service.config.update({
"acs": form.acs.data,
"entityid": form.entityid.data,
......@@ -37,8 +59,6 @@ class Config(object):
@classmethod
def populate_form(cls, service, form):
""" Populate a form from a service
"""
form.process(
obj=service,
acs=service.config.get("acs"),
......@@ -49,16 +69,18 @@ class Config(object):
@classmethod
def update_keys(cls, service):
if "idp_cert" not in service.config:
key, cert = cls.generate_key(service.uuid + "-idp")
key, cert = cls.generate_key(cls.IDP_CERT_NAME.format(service.uuid))
service.config.update({"idp_key": key, "idp_cert": cert})
if "sp_cert" not in service.config:
key, cert = cls.generate_key(service.uuid + "-sp")
key, cert = cls.generate_key(cls.SP_CERT_NAME.format(service.uuid))
service.config.update({"sp_key": key, "sp_cert": cert})
@classmethod
def generate_key(cls, cn):
""" Generate an RSA key and self signed certificate for SAML.
"""
key = primitives.asymmetric.rsa.generate_private_key(
key_size=2048, public_exponent=65535,
key_size=cls.RSA_KEY_LENGTH, public_exponent=65535,
backend=backends.default_backend()
)
now = datetime.datetime.utcnow()
......@@ -85,12 +107,15 @@ class MetaData(mdstore.InMemoryMetaData):
"""
def __init__(self, attrc, entityid, **kwargs):
(super(MetaData, self).__init__)(attrc, **kwargs)
super(MetaData, self).__init__(attrc, **kwargs)
self.entityid = entityid
def load(self, *args, **kwargs):
""" Load the service metadata for asserion generation.
"""
# We simply load a dummy metadata for an SPSSO descriptor, with a
# standard ACS and no required attribute (since attributes are fixed
# when using Hiboo)
self.entity.update({self.entityid: {'spsso_descriptor': [
{'attribute_consuming_service': [{'requested_attribute': []}]}
]}})
......@@ -99,32 +124,31 @@ class MetaData(mdstore.InMemoryMetaData):
def get_config(cls, service):
""" Load the IDP configuration.
"""
# Very simple IDP configuration, no specific restriction or logic
# since those are implemented in the Flask views
idp_service = {
'endpoints':{}, 'policy':{'default': {'lifetime':{'minutes': 15},
'attribute_restrictions': None,
'name_form': saml2.saml.NAME_FORMAT_URI,
'entity_categories':[]}},
'endpoints':{},
'policy':{
'default': {'lifetime':{'minutes': 15},
'attribute_restrictions': None,
'name_form': saml2.saml.NAME_FORMAT_URI,
'entity_categories':[]}
},
# Name ids are only profile uuid and usernames, so they comply with
# the persistent standard
'name_id_format': [saml2.saml.NAMEID_FORMAT_PERSISTENT]
}
config_dict = {
'key_file': "".join(service.config["idp_key"].strip().split("\n")[1:-1]),
'cert_file': "".join(service.config["sp_cert"].strip().split("\n")[1:-1]),
'service':{'idp': idp_service},
'metadata':[
{'class':'hiboo.sso.saml.MetaData',
'metadata':[(service.config["entityid"], )]}
]
# The IDP only has one metadata, because we spawn one IDP per SP
'metadata':[{
'class':'hiboo.sso.saml.MetaData',
'metadata':[(service.config["entityid"], )]
}]
}
return config.config_factory('idp', config_dict)
class CertHandler(object):
""" Dummy implementation of a CertHandler that we can instanciate for
the security context.
"""
def generate_cert(self):
return False
return saml2.config.config_factory('idp', config_dict)
class SecurityContext(sigver.SecurityContext):
......@@ -136,19 +160,24 @@ class SecurityContext(sigver.SecurityContext):
"""
def __init__(self, conf):
self.cert_handler = CertHandler()
self.cert_handler = self
self.conf = conf
def _check_signature(self, decoded_xml, item, node_name=None, origdoc=None, id_attr='', must=False, only_valid_cert=False, issuer=None):
""" Override the signature checking functino and use xmlsec
"""
# TODO: actually check the signature from authentication requests
# (not critical, but required for production)
return item
def sign_statement(self, statement, node_name, key=None, key_file=None, node_id=None, id_attr=''):
""" Override the statement signature function and use xmlsec
"""
xml = lxml.etree.fromstring(statement)
# Specify the id attribute so that xmlsec can find the object referenced
# in the signature block.
xmlsec.tree.add_ids(xml, ['ID'])
# Actually perform the signature
signature = xmlsec.tree.find_node(xml, xmlsec.constants.NodeSignature)
context = xmlsec.SignatureContext()
context.key = xmlsec.Key.from_memory(
......@@ -158,35 +187,57 @@ class SecurityContext(sigver.SecurityContext):
context.sign(signature)
return lxml.etree.tostring(xml)
def generate_cert(self):
""" Dummy function so that the security context can act as its own
cert handler (which does nothing since certficiates are loaded separately)
"""
return False
@blueprint.route('/saml/<service_uuid>', methods=["GET", "POST"])
@blueprint.route("/saml/redirect/<service_uuid>", methods=["GET", "POST"])
def saml_redirect(service_uuid):
# Get the profile from user input (implies redirects)
service = models.Service.query.get(service_uuid) or flask.abort(404)
service.protocol == "saml" or flask.abort(404)
# Get the profile from user input (implies redirects)
picked = profile.get_profile(service, intent=True) or flask.abort(403)
# Parse the authentication request and check the ACS
# Parse the authentication request (which checks the signature)
idp = server.Server(config=(MetaData.get_config(service)))
xml = flask.request.args["SAMLRequest"]
request = idp.parse_authn_request(xml, saml2.BINDING_HTTP_REDIRECT)
request.message.issuer or flask.abort(403)
#service.config["acs"] == request.message.issuer.text or flask.abort(403)
# Check that the request was properly issued for us and by the configured SP
url = flask.url_for(".saml_redirect", service_uuid=service_uuid, _external=True)
if not (request.message.destination and request.message.issuer and
request.message.destination == url and
request.message.issuer.text == service.config["entityid"]):
flask.abort(403)
# Provide a SAML response
response = idp.create_authn_response(
userid=picked.uuid,
identity={
'uid': picked.username,
'email': picked.email
"uid": picked.username,
"email": picked.email
},
# We currently only authenticate by password, this will change
authn={"class_ref": saml2.saml.AUTHN_PASSWORD},
in_response_to=request.message.id,
issuer=service_uuid,
issuer=flask.url_for(".saml_metadata", service_uuid=service_uuid, _external=True),
destination=service.config["acs"],
sp_entity_id=service.config["entityid"],
userid=picked.username,
authn={'class_ref': saml2.saml.AUTHN_PASSWORD},
sign_response=service.config["sign_mode"] == "response",
sign_assertion=service.config["sign_mode"] == "assertion"
)
return flask.render_template('sso_redirect.html', target=service.config["acs"], data={
'SAMLResponse': base64.b64encode(response).decode('ascii'),
'RelayState': flask.request.args.get('RelayState', '')
return flask.render_template("sso_redirect.html", target=service.config["acs"], data={
"SAMLResponse": base64.b64encode(response).decode("ascii"),
"RelayState": flask.request.args.get("RelayState", "")
})
@blueprint.route("/saml/metadata/<service_uuid>.xml")
def saml_metadata(service_uuid):
service = models.Service.query.get(service_uuid) or flask.abort(404)
service.protocol == "saml" or flask.abort(404)
entityid = flask.url_for(".saml_metadata", service_uuid=service_uuid, _external=True)
metadata = flask.render_template("saml_metadata.xml", service=service, entityid=entityid)
response = flask.make_response(metadata)
response.headers["Content-Type"] = "application/xml"
return response
......@@ -2,11 +2,14 @@
{% macro description() %}{% trans %}SAML2 is a legacy protocol based on XML security. Only redirect/post binding is supported.{% endtrans %}{% endmacro %}
{% macro describe(service) %}
<dt>{% trans %}Endpoint{% endtrans %}</dt>
<dd>{{ url_for("sso.saml_redirect", service_uuid=service.uuid, _external=True) }}</dd>
<dt>{% trans %}SAML Metadata{% endtrans %}</dt>
<dd><pre>{{ url_for("sso.saml_metadata", service_uuid=service.uuid, _external=True) }}</pre></dd>
<dt>{% trans %}SSO redirect binding{% endtrans %}</dt>
<dd><pre>{{ url_for("sso.saml_redirect", service_uuid=service.uuid, _external=True) }}</pre></dd>
<dt>{% trans %}ACS{% endtrans %}</dt>
<dd>{{ service.config["acs"] }}</dd>
<dd><pre>{{ service.config["acs"] }}</pre></dd>
<dt>{% trans %}IDP certificate{% endtrans %}</dt>
<dd><pre>{{ service.config["idp_cert"] }}</pre></dd>
......
<?xml version="1.0" encoding="UTF-8"?>
<EntitiesDescriptor Name="{{ service.uuid }}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
<EntityDescriptor entityID="{{ entityid }}">
<IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<dsig:KeyInfo>
<dsig:X509Data>
<dsig:X509Certificate>{{ "".join(service.config["idp_cert"].strip().split("\n")[1:-1]) }}</dsig:X509Certificate>
</dsig:X509Data>
</dsig:KeyInfo>
</KeyDescriptor>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</NameIDFormat>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ url_for("sso.saml_redirect", service_uuid=service.uuid, _external=True) }}" />
</IDPSSODescriptor>
</EntityDescriptor>
</EntitiesDescriptor>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment