├── README ├── example_login.py └── saml.py /README: -------------------------------------------------------------------------------- 1 | A library for supporting the SAML2 HTTP-POST-SimpleSign binding. 2 | 3 | Only supporting SimpleSign allows us to provide a SAML2 SP, using only the 4 | standard library, and M2Crypto for signature verification. The IdP SSO Binding 5 | is currently only HTTP-Redirect. 6 | 7 | This has been tested against SAML2 IdPs, including shibboleth. When using 8 | shibboleth, responses must be always be signed. XMLsig signatures are ignored. 9 | -------------------------------------------------------------------------------- /example_login.py: -------------------------------------------------------------------------------- 1 | ### Login example using bottle.py 2 | 3 | from bottle import route, response, request, redirect, debug, run 4 | 5 | import saml 6 | 7 | # saml.py using the logging module, and a logger called 'saml' 8 | # use DEBUG for verbose output 9 | import logging 10 | logger = logging.getLogger('saml') 11 | logger.setLevel(logging.DEBUG) 12 | # optionally configure logging for custom output 13 | #log_fmt = logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s") 14 | #handler = logging.StreamHandler() 15 | #handler.setFormatter(log_fmt) 16 | #logger.addHandler(handler) 17 | 18 | 19 | # First, register metadata for both parties 20 | 21 | ## Service Provider metadata 22 | # entityID registered with the IdP 23 | saml.SP['entityID'] = 'https://sp.test.org/shibboleth-sp' 24 | # Assertion Consumer Server URL 25 | saml.SP['ACS'] = 'https://sp.test.org/SSO' 26 | 27 | # Identity Provider metadata 28 | saml.IdP['entityID'] = 'https://idp.test.org/idp/shibboleth' 29 | saml.IdP['SingleSignOnService'] = 'https://idp.test.org/idp/profile/SAML2/Redirect/SSO' 30 | # the X509 certificate must in PEM form, including BEGIN and END lines 31 | with open('idp.pem') as pem: 32 | saml.IdP['X509'] = pem.read() 33 | 34 | 35 | 36 | # Accessing this path (/login) starts the authentication process. 37 | # Calling saml.request() generates the URL for the redirect. 38 | # The relay_state paramter is optional, and will be returned to you 39 | # after authentication. 40 | @route('/login') 41 | def login(): 42 | final_destination = 'http://sp.test.org/some/path' 43 | redirect(saml.request(relay_state=final_destination)) 44 | 45 | 46 | # This is your ACS where the user agent posts the SAML Response. 47 | # The html form data is passed to saml.login, in the form of a dict. 48 | @route('/SSO', method='POST') 49 | def sso(): 50 | attrs = saml.login(request.forms) 51 | # attrs is a dict containing simplified SAML attributes. 52 | # Attribute values are returned in a list, even for single values. 53 | # attrs['NameID'] is the Subject/NameID. 54 | # The remaining values are from the AttributeStatement, e.g. 55 | # if attrs.get('eduPersonPrincipalName')[0] in valid_users: 56 | # authenticated = True 57 | 58 | # An unsuccessful authentication will log errors and return 59 | # and empty dict. 60 | 61 | final_destination = request.forms.get('RelayState') 62 | print attrs 63 | # continue 64 | 65 | 66 | 67 | 68 | 69 | debug(True) 70 | run(reloader=True, host='127.0.0.1', port='8888') 71 | -------------------------------------------------------------------------------- /saml.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | import time 5 | import random 6 | import zlib 7 | import urllib 8 | import M2Crypto 9 | import logging 10 | import xml.etree.ElementTree as ET 11 | from datetime import datetime 12 | from dateutil import parser as dt_parser 13 | from dateutil.tz import tzutc 14 | from hashlib import sha1 15 | from base64 import b64decode, b64encode 16 | 17 | 18 | log = logging.getLogger('saml') 19 | log.setLevel(logging.INFO) 20 | 21 | # seconds to cache ID for replay 22 | replay_cache_lifetime = 3600 23 | 24 | # seconds from IssueInstant where the Response is valid 25 | response_window = 300 26 | 27 | # xml namespaces for xpath 28 | ns = {'saml2p': '{urn:oasis:names:tc:SAML:2.0:protocol}', 29 | 'saml2': '{urn:oasis:names:tc:SAML:2.0:assertion}', 30 | 'ds': '{http://www.w3.org/2000/09/xmldsig#}', 31 | 'xs' : '{http://www.w3.org/2001/XMLSchema}', 32 | 'ec' : '{http://www.w3.org/2001/10/xml-exc-c14n#}', 33 | 'xsi' : '{http://www.w3.org/2001/XMLSchema-instance}', 34 | } 35 | 36 | # xpath strings 37 | xp_subject_nameid = '{saml2}Assertion/{saml2}Subject/{saml2}NameID'.format(**ns) 38 | xp_attributestatement = '{saml2}Assertion/{saml2}AttributeStatement'.format(**ns) 39 | xp_status_code = '{saml2p}Status/{saml2p}StatusCode'.format(**ns) 40 | xp_status_message = '{saml2p}Status/{saml2p}StatusMessage'.format(**ns) 41 | 42 | # we only support the HTTP-POST-SimpleSign binding 43 | HTTP_POST_SimpleSign = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST-SimpleSign' 44 | 45 | SAML2_Success = "urn:oasis:names:tc:SAML:2.0:status:Success" 46 | 47 | # SAML2 AuthnRequest template 48 | authnRequest = ('' 49 | '' 56 | '' 57 | '{entityID}' 58 | '' 59 | '' 60 | '') 61 | 62 | # Required Metadata 63 | SP = {'entityID': None, 64 | 'ACS': None, 65 | 'Binding': HTTP_POST_SimpleSign, 66 | } 67 | 68 | IdP = {'entityID': None, 69 | 'SingleSignOnService': None, 70 | 'X509': None, 71 | } 72 | 73 | # cache of response IDs for replay detection 74 | id_cache = {} 75 | 76 | class SAML_Error(Exception): 77 | pass 78 | 79 | # Python's zlib doesn't have a deflate method. 80 | # Luckily, it's just a zlib string without the header and checksum 81 | def b64_deflate(string_val): 82 | cmp_str = zlib.compress(string_val)[2:-4] 83 | return b64encode(cmp_str) 84 | 85 | def expire_cache(max_age): 86 | expired = time.time() + max_age 87 | for k, v in id_cache.items(): 88 | if v < expired: 89 | del(id_cache[k]) 90 | 91 | def cache_id(id_str): 92 | expire_cache(replay_cache_lifetime) 93 | id_cache[id_str] = time.time() 94 | 95 | def gen_id(): 96 | id_str = str(time.time()) + hex(random.getrandbits(16)) 97 | return sha1(id_str).hexdigest() 98 | 99 | def timestamp(): 100 | dt = datetime.utcnow() 101 | dt_str = dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:22] #cut down to millis 102 | return (dt_str + 'Z') 103 | 104 | def parse_simplesign(response, sig, sigalg, relaystate=None): 105 | cert = M2Crypto.X509.load_cert_string(IdP['X509']) 106 | log.debug('Loaded X509 with fingerprint: ' + cert.get_fingerprint()) 107 | pk = cert.get_pubkey() 108 | pk.reset_context(md='sha1') 109 | pk.verify_init() 110 | if relaystate: 111 | sign_string = 'SAMLResponse=%s&RelayState=%s&SigAlg=%s' % (response, relaystate, sigalg) 112 | else: 113 | sign_string = 'SAMLResponse=%s&SigAlg=%s' % (response, sigalg) 114 | 115 | pk.verify_update(sign_string) 116 | 117 | if pk.verify_final(b64decode(sig)) == 0: 118 | raise SAML_Error('Invalid SAML signature') 119 | log.debug('Verified Response signature') 120 | 121 | response_xml = ET.fromstring(response) 122 | 123 | if response_xml.get('ID') in id_cache: 124 | raise SAML_Error('Replay detected') 125 | log.debug('No replay detected within %d seconds' % replay_cache_lifetime) 126 | 127 | cache_id(response_xml.get('ID')) 128 | 129 | issue_inst = dt_parser.parse(response_xml.get('IssueInstant', '')) 130 | now = datetime.now(tz=tzutc()) 131 | delta = now - issue_inst 132 | log.debug('IssueInstant = %s; CurrentTime = %s' % (issue_inst.ctime(), now.ctime())) 133 | if delta.seconds > response_window: 134 | raise SAML_Error('Time delta too great. IssueInstant off by %d seconds' 135 | % delta.seconds) 136 | 137 | status = response_xml.find(xp_status_code) 138 | status_message = response_xml.find(xp_status_message) 139 | 140 | if status.get('Value') != SAML2_Success: 141 | if status_message: 142 | msg = status_message.text 143 | else: 144 | msg = 'SAML Error' 145 | raise SAML_Error(msg) 146 | 147 | return response_xml 148 | 149 | 150 | def request(relay_state=None): 151 | """ 152 | Generate a SAML2 AuthnRequest URL for HTTP-Redirect 153 | """ 154 | md = {} 155 | md.update(SP) 156 | md['SingleSignOnService'] = IdP['SingleSignOnService'] 157 | md['RequestID'] = gen_id() 158 | md['IssueInstant'] = timestamp() 159 | req = authnRequest.format(**md) 160 | SAMLRequest = urllib.quote(b64_deflate(req)) 161 | 162 | log.debug('Generating AuthnRequest') 163 | log.debug('SingleSignOnService: ' + md['SingleSignOnService']) 164 | log.debug('RequestID: ' + md['RequestID']) 165 | log.debug('IssueInstant: ' + md['IssueInstant']) 166 | log.debug('AuthnRequest: ' + req) 167 | 168 | location = IdP['SingleSignOnService'] + '?SAMLRequest=' + SAMLRequest 169 | if relay_state: 170 | log.debug('RelayState = ' + relay_state) 171 | location = location + '&RelayState=' + urllib.quote(relay_state) 172 | 173 | return location 174 | 175 | 176 | def login(form): 177 | """ 178 | Process the HTTP-POST-SimpleSign form data. 179 | """ 180 | attrs = {} 181 | response = attrs['SAMLResponse'] = b64decode(bytes(form.get('SAMLResponse', ''))) 182 | sig = attrs['Signature'] = bytes(form.get('Signature', '')) 183 | sigalg = attrs['SigAlg'] = bytes(form.get('SigAlg', '')) 184 | relaystate = attrs['RelayState'] = bytes(form.get('RelayState', '')) 185 | 186 | log.debug('Processing SAML Response') 187 | log.debug('SAMLResponse: ' + response) 188 | log.debug('Signature: ' + sig) 189 | log.debug('SigAlg: ' + sigalg) 190 | log.debug('RelayState: ' + relaystate) 191 | 192 | try: 193 | response_xml = parse_simplesign(response, sig, sigalg, relaystate) 194 | except SAML_Error, e: 195 | log.error('Authentication Error: ' + str(e)) 196 | log.debug('Returning no attributes') 197 | return attrs 198 | 199 | attrs['SamlResponse_etree'] = response_xml 200 | 201 | name_id = response_xml.find(xp_subject_nameid) 202 | if name_id != None: 203 | attrs['NameID'] = name_id.text 204 | 205 | 206 | attribute_statement = response_xml.find(xp_attributestatement) 207 | if not attribute_statement: 208 | log.info('No attribute statement in SAMLResponse') 209 | return attrs 210 | 211 | for el in attribute_statement: 212 | name = el.get('Name') or el.tag 213 | fname = el.get('FriendlyName') 214 | attrs[name] = [] 215 | if fname: 216 | attrs[fname] = attrs[name] 217 | 218 | for av in el.findall('{saml2}AttributeValue'.format(**ns)): 219 | if av.text: 220 | attrs[name].append(av.text) 221 | else: 222 | log.debug(('Parsing XML AttributeValues for %s. ' 223 | 'Some information may not be returned') % name) 224 | attrs[name].extend(child.text for child in av) 225 | 226 | return attrs 227 | 228 | --------------------------------------------------------------------------------