diff --git a/doc/howto/config.rst b/doc/howto/config.rst index 62bad0e..a639d85 100644 --- a/doc/howto/config.rst +++ b/doc/howto/config.rst @@ -9,10 +9,10 @@ file is the same disregarding which type of service you plan to run. What differs is the directives. Below you will find a list of all the used directives in alphabetic order. The configuration is written as a python dictionary which means that the -directives are the toplevel keys. +directives are the top level keys. .. note:: You can build metadata files directly from the configuration. - The make_metadata.py script in the pySAML2 tools directory can do it + The make_metadata.py script in the pySAML2 tools directory will do it for you. @@ -22,8 +22,13 @@ Configuration directives attribute_maps ^^^^^^^^^^^^^^ -A simple key/value file that contains the unique name of attributes and -their friendly names separated by a blank, one attribute per line:: +Format:: + + attribute_maps: ["attribute.map"] + +Points to simple key/value files that, most commonly, contains the unique +name of attributes and their friendly names separated by a blank, one +attribute per line:: urn:oid:2.5.4.4, surName urn:oid:2.5.4.42 givenName @@ -39,6 +44,10 @@ user friendly names and universally unique names. cert_file ^^^^^^^^^ +Format:: + + cert_file: ["cert.pem"] + A file that contains CA certificates that the service will use in HTTPS sessions to verify the server certificate. *cert_file* must be a PEM formatted certificate chain file. @@ -46,16 +55,28 @@ HTTPS sessions to verify the server certificate. debug ^^^^^ -Whether debug information should be sent to the logfile. +Format:: + + debug: 1 + +Whether debug information should be sent to the log file. entityid ^^^^^^^^ +Format:: + + entityid: "http://saml.example.com/sp" + The globally unique identifier of the entity. key_file ^^^^^^^^ +Format:: + + key_file: ["key.pem"] + *key_file* is the name of a PEM formatted file that contains the private key of the service. This is presently used both to encrypt assertions and as client key in a HTTPS session. @@ -71,7 +92,7 @@ a file accessible on the server the service runs on or somewhere on the net.:: "metadata.xml", "vo_metadata.xml" ], "remote": [ - "https://kalmar2.org/simplesaml/module.php/aggregator/?id=kalmarcentral&set=saml2" + "https://kalmar2.org/aggregator/?id=kalmarcentral&set=saml2" ], }, @@ -80,8 +101,9 @@ service Which services the server will provide, those are combinations of "idp","sp" and "aa". -So if one server is supposted to be both SP and AA (attribute authority) then -the configuration could look something like this:: +So if one server is supposed to be both Service Provider (SP) and +Attribute Authority (AA) then the configuration could look something like +this:: "service": { "aa":{ @@ -103,10 +125,12 @@ Both IdPs and AAs can have the option 'assertions' assertions (idp/aa) """"""""""""""""""" -If the server is an IdP or and AA then there might be reasons to things -differently depending on who is asking, this is where that is specified. +If the server is an IdP and/or an AA then there might be reasons to do things +differently depending on who is asking; this is where that is specified. The keys are 'default' and SP entity identifiers, default is used whenever -there is no entry for a specific SP. +there is no entry for a specific SP. The reasoning is also that if there is +no default and only SP entity identifiers as keys, then the server will only +except connections from the specified SPs. An example might be:: "assertions": { @@ -129,7 +153,8 @@ attribute. By default there is no restrictions as to which attributes should be return. Instead all the attributes and values that is gathered by the database backends will be returned if nothing else is stated. -In the example above the SP with the entityid "urn:mace:umu.se:saml:roland:sp" +In the example above the SP with the entity identifier +"urn:mace:umu.se:saml:roland:sp" has an attribute restriction: only the attributes 'givenName' and 'surName' are to be returned. There is no limitations as to what values on these attributes that can be returned. @@ -141,21 +166,22 @@ regular expressions.:: "urn:mace:umu.se:saml:roland:sp": { "lifetime": {"minutes": 5}, "attribute_restrictions":{ - "mail": [".*\.umu\.se$"], + "mail": [".*.umu.se$"], } } } -Here only mailaddresses that ends with ".umu.se" will be returned. +Here only mail addresses that ends with ".umu.se" will be returned. idp (sp) """""""" -Defines the set of IdPs that this SP can use. If there is a metadata loaded +Defines the set of IdPs that this SP can use. If there is metadata loaded then the value is expected to be a dictionary with entity identifiers as keys and possibly the IdP url as values. If the url is not defined then an -attempt is made to learn it from the metadata. -A typical configuration would look something like this:: +attempt is made to pick it out of the metadata. +A typical configuration, when metadata is present, would look something +like this:: "idp": { "urn:mace:umu.se:saml:roland:idp": None, @@ -166,9 +192,9 @@ you are using SAML for services within one organization. At configuration time the url of the IdP might not be know so the evaluation of it is left until a metadata file is present. If more than one IdP can be used then the WAYF function (NOT IMPLEMENTED YET) would use the metadata file to -find out the names for the different IdPs. +find out the names, to be presented to the user, for the different IdPs. On the other hand if the SP only uses one specific IdP then the usage of -metadata file might be overkill so this construct can be used instead:: +metadata might be overkill so this construct can be used instead:: "idp": { "" : "https://example.com/saml2/idp/SSOService.php", @@ -176,11 +202,11 @@ metadata file might be overkill so this construct can be used instead:: Since the user is immediately sent to the IdP the entity identifier of the IdP is immaterial. In this case the key is expected to be the user friendly -name of the IdP. +name of the IdP. Which again if no WAYF is used is immaterial. -There is a third choice and that is to leave the configuration blank, that -is an empty dictionary, in which case all the IdP present in the metadata -will be regarded as eligable services to use. :: +There is a third choice and that is to leave the configuration blank, in +which case all the IdP present in the metadata +will be regarded as eligible services to use. :: "idp": { }, @@ -190,11 +216,19 @@ optional_attributes (sp) Attributes that this SP would like to receive from IdPs. +Example:: + + "optional_attributes": ["title"], + required_attributes (sp) """""""""""""""""""""""" Attributes that this SP demands to receive from IdPs. +Example:: + + "required_attributes": ["surName", "givenName", "mail"], + subject_data ^^^^^^^^^^^^ @@ -202,11 +236,15 @@ subject_data The name of a shelve database where the map between a local identifier and a distributed identifier is kept. +Example:: + + "subject_data": "./idp.subject.db", + xmlsec_binary ^^^^^^^^^^^^^ Presently xmlsec1 binaries are use for all the signing and encryption stuff. -This option defines where the binary is situatied. +This option defines where the binary is situated. virtual_organization ^^^^^^^^^^^^^^^^^^^^ @@ -221,7 +259,7 @@ Gives information about common identifiers for virtual_organizations:: }, Keys are identifiers for virtual organizations, the arguments per organization -is 'nameid_format' and 'common_identifier'. Usefull if all the IdPs and AAs +is 'nameid_format' and 'common_identifier'. Useful if all the IdPs and AAs that are involved in a virtual organization has common attribute values for users that are part of the VO. @@ -251,3 +289,4 @@ We start with a simple Service provider configuration:: }, "attribute_maps": ["attribute.map"], } + diff --git a/doc/howto/idp.rst b/doc/howto/idp.rst index 59c35a4..0becebe 100644 --- a/doc/howto/idp.rst +++ b/doc/howto/idp.rst @@ -4,58 +4,70 @@ How to make a SAML2 identity provider. ====================================== To make an SAML2 identity provider is a bit tricker than doing a service -provider. You have to understand how repoze.who works in order to understand -how the identity provider is supposted to work. +provider, mainly because you have to divide the functionality between +the application and the plugins. +Now, to do that you have to understand how repoze.who works. +Basically on every request; the ingress plugins first gets to do there stuff, +then the application and finally the egress plugins. So in broad terms this is what happens: -A GET request is received for /sso +1. A GET request is received for where ever the IdP is supposted to be listing. -- Identifiers are checked and none of them will be able to identify the - user since no login has been attempted. + 1.1 Identifiers are checked on ingress and none of them will be able to + identify the user since no login has been done. -- The application states that a 401 reponse should be returned if a - user can not be identified. + 1.2 After the ingress plugins have had their turn, the control is passed + to the application, which must state that a 401 reponse should be + returned if a user tries to access the IdP without an identification. -- The egress challenger, in this case the plugin 'form', is activated. - The configuration of this plugin is:: + 1.3 On a 401 response the egress challenger, in this case the plugin 'form', + is activated. - [plugin:form] - use = s2repoze.plugins.formswithhidden:make_plugin - login_form_qs = __do_login - rememberer_name = auth_tkt + The configuration of this plugin is:: + + [plugin:form] + use = s2repoze.plugins.formswithhidden:make_plugin + login_form_qs = __do_login + rememberer_name = auth_tkt - What's special with this form plugin is that the form carries the - query part of the original GET request in hidden fields. + What's special with this form plugin is that the form carries the + query part of the original GET request in hidden fields. -- The form is displayed, the user enters the user name and password and - submits the form. - -- The ingress identifier gets the form and extracts login and password - and passes it on to the authentication plugin. It will also extract - the query parameters from the hidden fields and store them in an - environment variable ('s2repoze.qinfo'). - If the login and password was correct a cookie is issued. If there is a - mdprovider plugin defined it will now add extra information about the - individual. After this the control is passed on to the application. - -- The function sso() now gets to act. This just the main outline: - * It finds the query parameters in the - environment and parses it:: - - query = environ["s2repoze.qinfo"] - (consumer, identifier, name_id_policy, - spid) = idp.parse_authn_request(query["SAMLRequest"][0]) + 1.4 The form is displayed, the user enters the user name and password and + submits the form. - * then for the user information:: +2. The log in form reply is received by the server + + 2.1 The ingress identifier gets the form and extracts login and password + and passes it on to the authentication plugin. It will also extract + the query parameters from the hidden fields and store them in an + environment variable ('s2repoze.qinfo'). + If the login and password was correct a cookie is issued. If there is + a mdprovider plugin defined it will now add extra information about + the individual. After this the control is passed on to the + application. - identity = environ["repoze.who.identity"]["user"] - userid = environ["repoze.who.identity"]['repoze.who.userid'] + 2.2 The function that is bound to the path of the IdP now gets to act. + This is just the main outline: + + * It finds the query parameters in the + environment and parses it:: + + query = environ["s2repoze.qinfo"] + (consumer, identifier, name_id_policy, + spid) = IDP.parse_authn_request(query["SAMLRequest"][0]) - * and finally build the response:: - - authn_resp = authn_response(identity, identifier, consumer, spid, - name_id_policy, userid) + * then for the user information:: + + identity = environ["repoze.who.identity"]["user"] + userid = environ["repoze.who.identity"]['repoze.who.userid'] + * and finally build the response:: + + authn_resp = IDP.authn_response(identity, identifier, consumer, + spid, name_id_policy, userid) + + IDP is assumed to be an instance of saml2.server.Server diff --git a/doc/howto/sp.rst b/doc/howto/sp.rst index 77f3a83..ab0b650 100644 --- a/doc/howto/sp.rst +++ b/doc/howto/sp.rst @@ -9,7 +9,8 @@ How it works A SP handles authentication, by the use of an Identity Provider, and possibly attribute aggregation. Both of these functions can be seen as parts of the normal Repoze.who -setup. Namely the Challenger, Identifier and MetadataProvider parts. +setup. Namely the Challenger, Identifier and MetadataProvider parts so that +is how it is thought to be implemented. Normal for Repoze.who Identifier and MetadataProvider plugins are that they place information they gather in environment variables. The convention is @@ -40,8 +41,9 @@ The set up There are two configuration files you have to deal with, first the pySAML2 configuration file which you can read more about here :ref:`howto_config` and secondly the repoze.who configuration file. +And it is the later one I will deal with here. -The plugin configuration has the following arguments +The **sp** plugin configuration has the following arguments use Which module to use and which factory function in that module that should @@ -57,10 +59,10 @@ virtual_organization Which virtual organization this SP belongs to, can only be none or one. debug - Debug state, and integer. Presently just on/off. + Debug state, an integer. Presently just on (!= 0)/off (0) is supported. cache - If no cache file is defined, a in memory cache will be used to + If no cache file is defined, an in-memory cache will be used to remember information received from IdPs and AAs. If a file name is given that file will be used for persistent storage of the cache. @@ -68,15 +70,18 @@ An example:: [plugin:saml2sp] use = s2repoze.plugins.sp:make_plugin + rememberer_name = auth_tkt saml_conf = sp.conf virtual_organization=urn:mace:umu.se:vo:it-enheten:cms - rememberer_name = auth_tkt debug = 1 + cache = /tmp/sp.cache Once you have configured the plugin you have to tell the server to use the plugin in different ingress and egress operations as specified in `Middleware responsibilities `_ :: +A typical SP configuration would be to use it in all aspects:: + [identifiers] plugins = saml2sp diff --git a/doc/metadata.rst b/doc/metadata.rst index fcb268c..1c9d48b 100644 --- a/doc/metadata.rst +++ b/doc/metadata.rst @@ -1,7 +1,7 @@ .. _metadata: *************************************************** -Base classes representing Saml2.0 protocol elements +Base classes representing Saml2.0 MetaData elements *************************************************** :Author: Roland Hedberg diff --git a/doc/saml2.rst b/doc/saml2.rst index 9e22472..8c56e5d 100644 --- a/doc/saml2.rst +++ b/doc/saml2.rst @@ -18,6 +18,8 @@ Base classes representing basic elements metadata xmldsig xmlenc + client + server Module ========== diff --git a/src/saml2/client.py b/src/saml2/client.py index 4471f6f..a9f35e2 100644 --- a/src/saml2/client.py +++ b/src/saml2/client.py @@ -42,13 +42,14 @@ FORM_SPEC = """
""" -LAX = True +LAX = False SESSION_INFO = {"ava":{}, "came from":"", "not_on_or_after":0, "issuer":"", "session_id":-1} -class Saml2Client: +class Saml2Client(object): + """ The basic pySAML2 service provider class """ def __init__(self, environ, config=None): """ diff --git a/src/saml2/server.py b/src/saml2/server.py index ce8d7f3..4f85fee 100644 --- a/src/saml2/server.py +++ b/src/saml2/server.py @@ -18,6 +18,7 @@ """Contains classes and functions that a SAML2.0 Identity provider (IdP) or attribute authority (AA) may use to conclude its tasks. """ + import shelve from saml2 import saml, samlp, VERSION @@ -41,6 +42,7 @@ from saml2.cache import Cache class Server(object): + """ A class that does things that IdPs or AAs do """ def __init__(self, config_file="", config=None, cache="", log=None, debug=0): if config_file: @@ -384,12 +386,14 @@ class Server(object): identity, # identity as dictionary name_id, ) - except MissingValue: + except MissingValue, exc: + resp = self.do_sso_response( destination, # consumer_url in_response_to, # in_response_to spid, # sp_entity_id name_id, + status = kd_status_from_exception(exc) ) diff --git a/src/saml2/utils.py b/src/saml2/utils.py index 2b841b8..3068fd8 100644 --- a/src/saml2/utils.py +++ b/src/saml2/utils.py @@ -2,7 +2,7 @@ import time import base64 -from saml2 import samlp, saml, VERSION, sigver +from saml2 import samlp, saml, VERSION, sigver, NAME_FORMAT_URI from saml2.time_util import instant try: @@ -350,6 +350,9 @@ def kd_subject(text="", **kwargs): def kd_authn_statement(text="", **kwargs): return klassdict(saml.Subject, text, **kwargs) +def kd_name_id_policy(text="", **kwargs): + return klassdict(samlp.NameIDPolicy, text, **kwargs) + def kd_assertion(text="", **kwargs): kwargs.update({ "version": VERSION, @@ -370,20 +373,42 @@ def kd_response(signature=False, encrypt=False, **kwargs): pass return kwargs +def _attrval(val): + if isinstance(val, basestring): + attrval = [kd_attribute_value(val)] + elif isinstance(val, list): + attrval = [kd_attribute_value(v) for v in val] + elif val == None: + attrval = None + else: + raise OtherError("strange value type on: %s" % val) + + return attrval + +def ava_to_attributes(ava, bmap): + attrs = [] + + for key, val in ava.items(): + dic = {} + attrval = _attrval(val) + if attrval: + dic["attribute_value"] = attrval + + dic["friendly_name"] = key + dic["name"] = bmap[key] + dic["name_format"] = NAME_FORMAT_URI + attrs.append(kd_attribute(**dic)) + return attrs + def do_attributes(identity): attrs = [] for key, val in identity.items(): dic = {} - if isinstance(val, basestring): - attrval = [kd_attribute_value(val)] - elif isinstance(val, list): - attrval = [kd_attribute_value(v) for v in val] - elif val == None: - attrval = None - else: - raise OtherError("strange value type on: %s" % val) + + attrval = _attrval(val) if attrval: dic["attribute_value"] = attrval + if isinstance(key, basestring): dic["name"] = key elif isinstance(key, tuple): # 3-tuple diff --git a/tests/test_server.py b/tests/test_server.py index 1254116..e519b26 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -289,3 +289,37 @@ class TestServer(): assert _eq(ava.keys(), ["mail"]) assert ava["mail"] == ["dj@example.com"] + + def test_authn_response_0(self): + # reset + del self.server.conf["service"]["idp"]["assertions"][ + "urn:mace:example.com:saml:roland:sp"] + + ava = { "givenName": ["Derek"], "surName": ["Jeter"], + "mail": ["derek@nyy.mlb.com"]} + + resp_str = self.server.authn_response(ava, + "1", "http://local:8087/", + "urn:mace:example.com:saml:roland:sp", + utils.make_instance(samlp.NameIDPolicy, + utils.kd_name_id_policy( + format=saml.NAMEID_FORMAT_TRANSIENT, + allow_create="true")), + "foba0001@example.com") + + response = samlp.response_from_string("\n".join(resp_str)) + print response.keyswv() + assert _eq(response.keyswv(),['status', 'destination', 'assertion', + 'in_response_to', 'issue_instant', 'version', + 'issuer', 'id']) + print response.assertion[0].keyswv() + assert len(response.assertion) == 1 + assert _eq(response.assertion[0].keyswv(), ['authn_statement', + 'attribute_statement', 'subject', 'issue_instant', + 'version', 'conditions', 'id']) + assertion = response.assertion[0] + assert len(assertion.attribute_statement) == 1 + astate = assertion.attribute_statement[0] + print astate + assert len(astate.attribute) == 3 + diff --git a/tests/test_utils.py b/tests/test_utils.py index eb720d1..ec32a76 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -498,3 +498,31 @@ def test_filter_values_req_opt_1(): ava = utils.filter_on_attributes(ava, [r], [o]) assert ava.keys() == ["serialNumber"] assert _eq(ava["serialNumber"], ["12345","54321"]) + +def _givenName(a): + assert a["name"] == "urn:oid:2.5.4.42" + assert a["friendly_name"] == "givenName" + assert len(a["attribute_value"]) == 1 + assert a["attribute_value"] == [{"text":"Derek"}] + +def _surName(a): + assert a["name"] == "urn:oid:2.5.4.4" + assert a["friendly_name"] == "surName" + assert len(a["attribute_value"]) == 1 + assert a["attribute_value"] == [{"text":"Jeter"}] + +def test_ava_to_attributes(): + (forward, backward) = utils.parse_attribute_map(["tests/attribute.map"]) + attrs = utils.ava_to_attributes(AVA[0], backward) + + assert len(attrs) == 2 + a = attrs[0] + if a["name"] == "urn:oid:2.5.4.42": + _givenName(a) + _surName(attrs[1]) + elif a["name"] == "urn:oid:2.5.4.4": + _surName(a) + _givenName(attrs[1]) + else: + print a + assert False \ No newline at end of file