--- /dev/null
+moonshot
+========
+A python module usable in a Moonshot environment to allow a freeradius server
+to fetch information about a user from a SAML2 Attribute Authority.
+
+Installing
+----------
+
+sudo python setup.py install
+
+eventually also
+
+sudo easy_install moonshot
+
+When the python module is installed a couple of changes to the freeradius
+configuration are necessary.
+
+1) create raddb/modules/python
+
+You can use the provided '/usr/local/etc/moonshot/template/modules_python' file
+as is.
+
+2) Edit raddb/sites-available/default
+To the 'post-auth' section add one line referencing the python module.
+You can see how it can be done in
+'/usr/local/etc/moonshot/template/sites-available_default".
+
+3) Edit raddb/sites-available/inner-tunnel.
+To the 'post-auth' section add one line referencing the python module.
+You can see how it can be done in
+'/usr/local/etc/moonshot/template/sites-available_inner-tunnel".
+
+
+Now, you should have the basic setup.
+To get it working you have to do a couple of more things:
+
+
+I) Get the SAML2 metadata for the Attribute Authority (AA) you want to use.
+
+Place it in the '/usr/local/etc/moonshot/' directory
+
+
+II) Change the configuration in /usr/local/etc/moonshot/config.py
+
+You must change the value of ATTRIBUTE_AUTHORITY so it is the identifier of the
+SAML2 AA you want to use.
+
+
+III) Change the configuration '/usr/local/etc/moonshot/pysaml_config.py'.
+A couple of things:
+
+BASE : This is the identifier of the SP (=this module) you are running.
+
+organization: Information about the organization running this service
+
+contact_person: Information about a person people can contact to ask about
+ this service
+
+
+IV) Create your own key pair.
+
+A key and certificate can be created using the openssl tool:
+$ openssl genrsa 1024 > ssl.key
+$ openssl req -new -x509 -nodes -sha1 -days 365 -key ssl.key > ssl.cert
+$ sudo mv ssl.key ssl.cert /usr/local/etc/moonshot/pki
+
+If you chose other names for you key and cert you have to change accordingly
+in pysaml_config.py .
+
+
+V) Create the metadata file for your SP.
+
+$ make_metadata.py /usr/local/etc/moonshot/pysaml_config.py > sp.xml
+This file you have to give to the person/organization that runs the AA you
+want to get information from.
+
+!!! That should be it !!!
+
--- /dev/null
+#! /usr/bin/env python
+#
+# Copyright 2011 Roland Hedberg <roland.hedberg@adm.umu.se>
+#
+# The freeradius extension using ECP
+#
+__author__ = 'rolandh'
+
+import radiusd
+import saml2
+import sys
+import traceback
+
+from saml2 import saml
+
+from saml2.client import Saml2Client
+from saml2.s_utils import sid
+from saml2.response import authn_response
+from saml2.ecp_client import Client
+
+# Where's the configuration file is
+#CONFIG_DIR = "/usr/local/etc/moonshot"
+CONFIG_DIR = "../etc"
+sys.path.insert(0, CONFIG_DIR)
+
+import ecp_config
+
+# Globals
+CLIENT = None
+ECP = None
+
+def eq_len_parts(str, delta=250):
+ res = []
+ n = 0
+ strlen = len(str)
+ while n <= strlen:
+ m = n + delta
+ res.append("".join(str[n:m]))
+ n = m
+ return res
+
+def exception_trace(tag, exc, log):
+ message = traceback.format_exception(*sys.exc_info())
+ log.error("[%s] ExcList: %s" % (tag, "".join(message),))
+ log.error("[%s] Exception: %s" % (tag, exc))
+
+
+def log(level, s):
+ """Log function."""
+ radiusd.radlog(level, 'moonshot.py: ' + s)
+
+
+class LOG(object):
+ def info(self, txt):
+ log(radiusd.L_INFO, txt)
+
+ def error(self, txt):
+ log(radiusd.L_ERR, txt)
+
+
+#noinspection PyUnusedLocal
+def instantiate(p):
+ """Module Instantiation. 0 for success, -1 for failure.
+ """
+ global CLIENT
+ global ECP
+
+ # Use IdP info retrieved from the SP when metadata is missing
+
+ try:
+ CLIENT = Saml2Client(ecp_config.DEBUG, config_file=ecp_config.CONFIG)
+
+ except Exception, e:
+ # Report the error and return -1 for failure.
+ # xxx A more advanced module would retry the database.
+ log(radiusd.L_ERR, str(e))
+ return -1
+
+ try:
+ ECP = Client("", "", None, metadata_file=ecp_config.METADATA_FILE)
+ except Exception, err:
+ log(radiusd.L_ERR, str(err))
+ return -1
+
+ log(radiusd.L_INFO, 'ECP client initialized')
+
+ return 0
+
+
+def authentication_request(cls, ecp, idp_entity_id, destination,
+ log=None, sign=False):
+ """ Does a authentication request to an Identity provider.
+ This function uses the SOAP binding other bindings could be used but are
+ not
+ supported right now.
+
+ :param cls: The SAML2 client instance
+ :param ecp: The ECP client instance
+ :param idp_entity_id: The identifier of the subject
+ :param destination: To whom the query should be sent
+ :param log: Function to use for logging
+ :param sign: Whether the request should be signed or not
+ :return: A Authentication Response
+ """
+
+ if log is None:
+ log = cls.logger
+
+ session_id = sid()
+ acsu = cls.config.endpoint('assertion_consumer_service',
+ saml2.BINDING_PAOS)[0]
+ spentityid = cls.config.entityid
+
+ # create the request
+ request = cls.authn_request(session_id,
+ destination,
+ acsu,
+ spentityid,
+ "",
+ log=LOG(),
+ sign=sign,
+ binding=saml2.BINDING_PAOS,
+ nameid_format=saml.NAMEID_FORMAT_PERSISTENT)
+
+ try:
+ # send the request and receive the response
+ response = ecp.phase2(request, acsu, idp_entity_id)
+ except Exception, exc:
+ exception_trace("soap", exc, log)
+ if log:
+ log.info("SoapClient exception: %s" % (exc,))
+ return None
+
+ if response:
+ try:
+ # synchronous operation
+ aresp = authn_response(cls.config, acsu, log=log, asynchop=False,
+ allow_unsolicited=True)
+ #aresp.debug = True
+ except Exception, exc:
+ if log:
+ log.error("%s", (exc,))
+ return None
+
+ try:
+ _resp = aresp.load_instance(response).verify()
+ except Exception, err:
+ if log:
+ log.error("%s" % err)
+ return None
+ if _resp is None:
+ if log:
+ log.error("Didn't like the response")
+ return None
+
+ return _resp.assertion
+ else:
+ return None
+
+
+def only_allowed_attributes(client, assertion, allowed):
+ res = []
+ _aconvs = client.config.attribute_converters
+
+ for statement in assertion.attribute_statement:
+ for attribute in statement.attribute:
+ if attribute.friendly_name:
+ fname = attribute.friendly_name
+ else:
+ fname = ""
+ for acv in _aconvs:
+ if acv.name_form == attribute.name_form:
+ fname = acv._fro[attribute.name]
+
+ if fname in allowed:
+ res.append(attribute)
+
+ return assertion
+
+
+def post_auth(authData):
+ """ Attribute aggregation after authentication
+ This is the function that is accessible from the freeradius server core.
+
+ :return: A 3-tuple
+ """
+
+ global CLIENT
+ global HTTP
+
+ # Extract the data we need.
+ userName = None
+ serviceName = ""
+ hostName = ""
+ #userPasswd = None
+
+ for t in authData:
+ if t[0] == 'User-Name':
+ userName = t[1][1:-1]
+ elif t[0] == "GSS-Acceptor-Service-Name":
+ serviceName = t[1][1:-1]
+ elif t[0] == "GSS-Acceptor-Host-Name":
+ hostName = t[1][1:-1]
+
+ _srv = "%s:%s" % (serviceName, hostName)
+ log(radiusd.L_DBG, "Working on behalf of: %s" % _srv)
+
+
+ # Find the endpoint to use
+ attribute_service = CLIENT.config.attribute_services(ecp_config.IDP_ENTITYID)
+ location = attribute_service[0].location
+
+ log(radiusd.L_DBG, "location: %s" % location)
+
+
+ _assertion = authentication_request(CLIENT, ECP,
+ ecp_config.IDP_ENTITYID,
+ location,
+ log=LOG(),
+ sign=ecp_config.SIGN)
+
+ if _assertion is None:
+ return radiusd.RLM_MODULE_FAIL
+
+ if _assertion is False:
+ log(radiusd.L_DBG, "IdP returned: %s" % HTTP.server.error_description)
+ return radiusd.RLM_MODULE_FAIL
+
+ # remove the subject confirmation if there is one
+ _assertion.subject.subject_confirmation = []
+ # Only allow attributes that the service should have
+ try:
+ _assertion = only_allowed_attributes(CLIENT, _assertion,
+ ecp_config.ATTRIBUTE_FILTER[_srv])
+ except KeyError:
+ pass
+
+ log(radiusd.L_DBG, "Assertion: %s" % _assertion)
+
+ # Log the success
+ log(radiusd.L_DBG, 'user accepted: %s' % (userName, ))
+
+ # We are adding to the RADIUS packet
+ # We need to set an Auth-Type.
+
+ # UKERNA, 25622; attribute ID is 132
+ attr = "SAML-AAA-Assertion"
+ #attr = "UKERNA-Attr-%d" % 132
+ #attr = "Vendor-%d-Attr-%d" % (25622, 132)
+ restup = (tuple([(attr, x) for x in eq_len_parts("%s" % _assertion, 248)]))
+
+ return radiusd.RLM_MODULE_UPDATED, restup, None
+
+
+# Test the modules
+if __name__ == '__main__':
+ instantiate(None)
+ # print authorize((('User-Name', '"map"'), ('User-Password', '"abc"')))
+ print post_auth((('User-Name', '"roland"'), ('User-Password', '"one"')))
+
\ No newline at end of file
--- /dev/null
+#! /usr/bin/env python
+#
+# Copyright 2011 Roland Hedberg <roland.hedberg@adm.umu.se>
+#
+# $Id$
+
+__author__ = 'rolandh'
+
+import radiusd
+import sys
+from saml2 import soap
+from saml2.client import Saml2Client
+from saml2.s_utils import sid
+from saml2.response import attribute_response
+
+# Where's the configuration
+CONFIG_DIR = "/usr/local/etc/moonshot"
+sys.path.insert(0, CONFIG_DIR)
+
+import config
+
+# Globals
+CLIENT = None
+HTTP = None
+
+
+def eq_len_parts(str, delta=250):
+ res = []
+ n = 0
+ strlen = len(str)
+ while n <= strlen:
+ m = n + delta
+ res.append("".join(str[n:m]))
+ n = m
+ return res
+
+
+def log(level, s):
+ """Log function."""
+ radiusd.radlog(level, 'moonshot.py: ' + s)
+
+
+class LOG(object):
+ def info(self, txt):
+ log(radiusd.L_INFO, txt)
+
+ def error(self, txt):
+ log(radiusd.L_ERR, txt)
+
+#noinspection PyUnusedLocal
+def instantiate(p):
+ """Module Instantiation. 0 for success, -1 for failure.
+ p is a dummy variable here.
+ """
+ global CLIENT
+ global HTTP
+
+ try:
+ CLIENT = Saml2Client(config.DEBUG,
+ identity_cache=config.IDENTITY_CACHE,
+ state_cache=config.STATE_CACHE,
+ config_file=config.CONFIG)
+ except Exception, e:
+ # Report the error and return -1 for failure.
+ # xxx A more advanced module would retry the database.
+ log(radiusd.L_ERR, str(e))
+
+ return -1
+
+ try:
+ HTTP = soap.SOAPClient("") # No default URL
+ except Exception, e:
+ log(radiusd.L_ERR, str(e))
+ return -1
+
+ log(radiusd.L_INFO, 'SP initialized')
+
+ return 0
+
+
+def attribute_query(cls, subject_id, destination, issuer_id=None,
+ attribute=None, sp_name_qualifier=None, name_qualifier=None,
+ nameid_format=None, log=None, sign=False):
+ """ Does a attribute request to an attribute authority, this is
+ by default done over SOAP. Other bindings could be used but are not
+ supported right now.
+
+ :param subject_id: The identifier of the subject
+ :param destination: To whom the query should be sent
+ :param issuer_id: Who is sending this query
+ :param attribute: A dictionary of attributes and values that is asked for
+ :param sp_name_qualifier: The unique identifier of the
+ service provider or affiliation of providers for whom the
+ identifier was generated.
+ :param name_qualifier: The unique identifier of the identity
+ provider that generated the identifier.
+ :param nameid_format: The format of the name ID
+ :param log: Function to use for logging
+ :param sign: Whether the request should be signed or not
+ :return: The Assertion
+ """
+
+ if log is None:
+ log = cls.logger
+
+ session_id = sid()
+ issuer = cls.issuer(issuer_id)
+
+ if not name_qualifier and not sp_name_qualifier:
+ sp_name_qualifier = cls.config.entityid
+
+ request = cls.create_attribute_query(session_id, subject_id,
+ destination, issuer, attribute,
+ sp_name_qualifier,
+ name_qualifier,
+ nameid_format=nameid_format, sign=sign)
+
+ # soapclient = HTTP.send(destination, cls.config.key_file,
+ # cls.config.cert_file)
+
+ try:
+ response = HTTP.send(request, path=destination)
+ except Exception, exc:
+ if log:
+ log.info("SoapClient exception: %s" % (exc,))
+ return None
+
+ if response:
+ try:
+ # synchronous operation
+ return_addr = cls.config.endpoint('assertion_consumer_service')[0]
+ aresp = attribute_response(cls.config, return_addr, log=log)
+ aresp.allow_unsolicited = True
+ aresp.asynchop = False
+ #aresp.debug = True
+ except Exception, exc:
+ if log:
+ log.error("%s", (exc,))
+ return None
+
+ try:
+ _resp = aresp.loads(response, False, HTTP.response).verify()
+ except Exception, err:
+ if log:
+ log.error("%s", (exc,))
+ return None
+ if _resp is None:
+ if log:
+ log.error("Didn't like the response")
+ return None
+
+ return _resp.assertion
+ else:
+ return None
+
+
+def only_allowed_attributes(client, assertion, allowed):
+ res = []
+ _aconvs = client.config.attribute_converters
+
+ for statement in assertion.attribute_statement:
+ for attribute in statement.attribute:
+ if attribute.friendly_name:
+ fname = attribute.friendly_name
+ else:
+ fname = ""
+ for acv in _aconvs:
+ if acv.name_form == attribute.name_form:
+ fname = acv._fro[attribute.name]
+
+ if fname in allowed:
+ res.append(attribute)
+
+ return assertion
+
+
+def post_auth(authData):
+ """ Attribute aggregation after authentication
+ This is the function that is accessible from the freeradius server core.
+
+ :return: A 3-tuple
+ """
+
+ global CLIENT
+ global HTTP
+
+ # Extract the data we need.
+ userName = None
+ serviceName = ""
+ hostName = ""
+ #userPasswd = None
+
+ for t in authData:
+ if t[0] == 'User-Name':
+ userName = t[1][1:-1]
+ elif t[0] == "GSS-Acceptor-Service-Name":
+ serviceName = t[1][1:-1]
+ elif t[0] == "GSS-Acceptor-Host-Name":
+ hostName = t[1][1:-1]
+
+ _srv = "%s:%s" % (serviceName, hostName)
+ log(radiusd.L_DBG, "Working on behalf of: %s" % _srv)
+
+
+ # Find the endpoint to use
+ location = CLIENT.config.attribute_services(
+ config.ATTRIBUTE_AUTHORITY)[0].location
+ log(radiusd.L_DBG, "location: %s" % location)
+
+ # Build and send the attribute query
+ sp_name_qualifier = config.SP_NAME_QUALIFIER
+ name_qualifier = config.NAME_QUALIFIER
+ nameid_format = config.NAMEID_FORMAT
+
+ log(radiusd.L_DBG, "SP_NAME_QUALIFIER: %s" % sp_name_qualifier)
+ log(radiusd.L_DBG, "NAME_QUALIFIER: %s" % name_qualifier)
+ log(radiusd.L_DBG, "NAMEID_FORMAT: %s" % nameid_format)
+
+ _attribute_assertion = attribute_query(CLIENT,
+ userName,
+ location,
+ sp_name_qualifier=sp_name_qualifier,
+ name_qualifier=name_qualifier,
+ nameid_format=nameid_format,
+ issuer_id=CLIENT.issuer(),
+ log=LOG(),
+ sign=config.SIGN)
+
+ if _attribute_assertion is None:
+ return radiusd.RLM_MODULE_FAIL
+
+ if _attribute_assertion is False:
+ log(radiusd.L_DBG, "IdP returned: %s" % HTTP.server.error_description)
+ return radiusd.RLM_MODULE_FAIL
+
+ # remove the subject confirmation if there is one
+ _attribute_assertion.subject.subject_confirmation = []
+ # Only allow attributes that the service should have
+ try:
+ _attribute_assertion = only_allowed_attributes(CLIENT,
+ _attribute_assertion,
+ config.ATTRIBUTE_FILTER[
+ _srv])
+ except KeyError:
+ pass
+
+ log(radiusd.L_DBG, "Assertion: %s" % _attribute_assertion)
+
+ # Log the success
+ log(radiusd.L_DBG, 'user accepted: %s' % (userName, ))
+
+ # We are adding to the RADIUS packet
+ # We need to set an Auth-Type.
+
+ # UKERNA, 25622; attribute ID is 132
+ attr = "SAML-AAA-Assertion"
+ #attr = "UKERNA-Attr-%d" % 132
+ #attr = "Vendor-%d-Attr-%d" % (25622, 132)
+ restup = (tuple([(attr, x) for x in eq_len_parts(
+ "%s" % _attribute_assertion, 248)]))
+
+ return radiusd.RLM_MODULE_UPDATED, restup, None
+
+
+# Test the modules
+if __name__ == '__main__':
+ instantiate(None)
+ # print authorize((('User-Name', '"map"'), ('User-Password', '"abc"')))
+ print post_auth((('User-Name', '"roland"'), ('User-Password', '"one"')))
--- /dev/null
+#! /usr/bin/env python
+#
+# Definitions for RADIUS programs
+#
+# Copyright 2002 Miguel A.L. Paraz <mparaz@mparaz.com>
+#
+# This should only be used when testing modules.
+# Inside freeradius, the 'radiusd' Python module is created by the C module
+# and the definitions are automatically created.
+#
+# $Id$
+
+# from modules.h
+
+RLM_MODULE_REJECT = 0
+RLM_MODULE_FAIL = 1
+RLM_MODULE_OK = 2
+RLM_MODULE_HANDLED = 3
+RLM_MODULE_INVALID = 4
+RLM_MODULE_USERLOCK = 5
+RLM_MODULE_NOTFOUND = 6
+RLM_MODULE_NOOP = 7
+RLM_MODULE_UPDATED = 8
+RLM_MODULE_NUMCODES = 9
+
+
+# from radiusd.h
+L_DBG = 1
+L_AUTH = 2
+L_INFO = 3
+L_ERR = 4
+L_PROXY = 5
+L_CONS = 128
+
+
+# log function
+def radlog(level, msg):
+ import sys
+ sys.stdout.write("<python>" + msg + '\n')
+ level = level
+
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env python
+
+import os
+
+"""Setup script for the pyparsing module distribution."""
+from distutils.core import setup
+
+__author__ = 'rolandh'
+
+def read(fname):
+ return open(os.path.join(os.path.dirname(__file__), fname)).read()
+
+setup(# Distribution meta-data
+ name = "freeradius_pysaml2",
+ version = "0.0.4",
+ description = "FreeRadius python module to be used in Moonshot",
+ author = "Roland Hedberg",
+ author_email = "roland.hedberg@adm.umu.se",
+ license = "MIT License",
+ py_modules = ['freeradius_pysaml2','radiusd', "freeradius_ecp"],
+ classifiers=[
+ 'Development Status :: 4 - Beta',
+ 'Intended Audience :: Developers',
+ 'Intended Audience :: Information Technology',
+ 'License :: OSI Approved :: MIT License',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ ],
+ long_description=read('README'),
+ data_files=[('/usr/local/etc/moonshot', ['etc/config.py',
+ 'etc/metadata.xml',
+ "etc/pysaml_config.py"]),
+ ('/usr/local/etc/moonshot/attributemaps',
+ ['attributemaps/basic.py',
+ 'attributemaps/saml_uri.py',
+ 'attributemaps/shibboleth_uri.py']),
+ ('/usr/local/etc/moonshot/pki',
+ ['pki/ssl.cert', 'pki/ssl.key']),
+ ('/usr/local/etc/moonshot/template',
+ ['template/modules_python',
+ 'template/sites-available_default',
+ 'template/sites-available_inner-tunnel'])],
+ install_requires=[
+ 'pysaml2'
+ ]
+ )