Files
deb-python-pysaml2/src/saml2/server.py

715 lines
30 KiB
Python

#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2009 Umeå University
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Contains classes and functions that a SAML2.0 Identity provider (IdP)
or attribute authority (AA) may use to conclude its tasks.
"""
import shelve
import sys
import memcache
from saml2 import saml
from saml2 import class_name
from saml2 import soap
from saml2 import BINDING_HTTP_REDIRECT, BINDING_SOAP
from saml2.request import AuthnRequest
from saml2.request import AttributeQuery
from saml2.request import LogoutRequest
from saml2.s_utils import sid
from saml2.s_utils import response_factory, logoutresponse_factory
from saml2.s_utils import MissingValue
from saml2.s_utils import success_status_factory
from saml2.s_utils import OtherError
from saml2.s_utils import UnknownPrincipal
from saml2.s_utils import UnsupportedBinding
from saml2.s_utils import error_status_factory
from saml2.time_util import instant
from saml2.binding import http_soap_message
from saml2.binding import http_redirect_message
from saml2.binding import http_post_message
from saml2.sigver import security_context
from saml2.sigver import signed_instance_factory
from saml2.sigver import pre_signature_part
from saml2.config import IDPConfig
from saml2.assertion import Assertion, Policy
class UnknownVO(Exception):
pass
class Identifier(object):
""" A class that handles identifiers of objects """
def __init__(self, db, voconf=None, debug=0, log=None):
if isinstance(db, basestring):
self.map = shelve.open(db, writeback=True)
else:
self.map = db
self.voconf = voconf
self.debug = debug
self.log = log
def _store(self, typ, entity_id, local, remote):
self.map["|".join([typ, entity_id, "f", local])] = remote
self.map["|".join([typ, entity_id, "b", remote])] = local
def _get_remote(self, typ, entity_id, local):
return self.map["|".join([typ, entity_id, "f", local])]
def _get_local(self, typ, entity_id, remote):
return self.map["|".join([typ, entity_id, "b", remote])]
def persistent(self, entity_id, subject_id):
""" Keeps the link between a permanent identifier and a
temporary/pseudo-temporary identifier for a subject
The store supports look-up both ways: from a permanent local
identifier to a identifier used talking to a SP and from an
identifier given back by an SP to the local permanent.
:param entity_id: SP entity ID or VO entity ID
:param subject_id: The local permanent identifier of the subject
:return: An arbitrary identifier for the subject unique to the
service/group of services/VO with a given entity_id
"""
try:
return self._get_remote("persistent", entity_id, subject_id)
except KeyError:
temp_id = "xyz"
while True:
temp_id = sid()
try:
self._get_local("persistent", entity_id, temp_id)
except KeyError:
break
self._store("persistent", entity_id, subject_id, temp_id)
self.map.sync()
return temp_id
def _get_vo_identifier(self, sp_name_qualifier, userid, identity):
try:
vo_conf = self.voconf(sp_name_qualifier)
if "common_identifier" in vo_conf:
try:
subj_id = identity[vo_conf["common_identifier"]]
except KeyError:
raise MissingValue("Common identifier")
else:
return self.persistent_nameid(sp_name_qualifier, userid)
except KeyError:
raise UnknownVO("%s" % sp_name_qualifier)
try:
nameid_format = vo_conf["nameid_format"]
except KeyError:
nameid_format = saml.NAMEID_FORMAT_PERSISTENT
return saml.NameID(format=nameid_format,
sp_name_qualifier=sp_name_qualifier,
text=subj_id)
def persistent_nameid(self, sp_name_qualifier, userid):
""" Get or create a persistent identifier for this object to be used
when communicating with servers using a specific SPNameQualifier
:param sp_name_qualifier: An identifier for a 'context'
:param userid: The local permanent identifier of the object
:return: A persistent random identifier.
"""
subj_id = self.persistent(sp_name_qualifier, userid)
return saml.NameID(format=saml.NAMEID_FORMAT_PERSISTENT,
sp_name_qualifier=sp_name_qualifier,
text=subj_id)
def transient_nameid(self, sp_entity_id, userid):
""" Returns a random one-time identifier. One-time means it is
kept around as long as the session is active.
:param sp_name_qualifier: A qualifier to bind the created identifier to
:param userid: The local persistent identifier for the subject.
:return: The created identifier,
"""
temp_id = sid()
while True:
try:
_ = self._get_local("transient", sp_entity_id, temp_id)
temp_id = sid()
except KeyError:
break
self._store("transient", sp_entity_id, userid, temp_id)
self.map.sync()
return saml.NameID(format=saml.NAMEID_FORMAT_TRANSIENT,
sp_name_qualifier=sp_entity_id,
text=temp_id)
def construct_nameid(self, local_policy, userid, sp_entity_id,
identity=None, name_id_policy=None):
""" Returns a name_id for the object. How the name_id is
constructed depends on the context.
:param local_policy: The policy the server is configured to follow
:param user: The local permanent identifier of the object
:param sp_entity_id: The 'user' of the name_id
:param identity: Attribute/value pairs describing the object
:param name_id_policy: The policy the server on the other side wants
us to follow.
:return: NameID instance precursor
"""
if name_id_policy and name_id_policy.sp_name_qualifier:
return self._get_vo_identifier(name_id_policy.sp_name_qualifier,
userid, identity)
else:
nameid_format = local_policy.get_nameid_format(sp_entity_id)
if nameid_format == saml.NAMEID_FORMAT_PERSISTENT:
return self.persistent_nameid(sp_entity_id, userid)
elif nameid_format == saml.NAMEID_FORMAT_TRANSIENT:
return self.transient_nameid(sp_entity_id, userid)
def local_name(self, entity_id, remote_id):
""" Get the local persistent name that has the specified remote ID.
:param entity_id: The identifier of the entity that got the remote id
:param remote_id: The identifier that was exported
:return: Local identifier
"""
try:
return self._get_local("persistent", entity_id, remote_id)
except KeyError:
try:
return self._get_local("transient", entity_id, remote_id)
except KeyError:
return None
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):
self.log = log
self.debug = debug
self.ident = None
if config_file:
self.load_config(config_file)
elif config:
self.conf = config
self.metadata = self.conf["metadata"]
self.sec = security_context(self.conf, log)
# if cache:
# if isinstance(cache, basestring):
# self.cache = Cache(cache)
# else:
# self.cache = cache
# else:
# self.cache = Cache()
def load_config(self, config_file):
""" Load the server configuration
:param config_file: The name of the configuration file
"""
self.conf = IDPConfig()
self.conf.load_file(config_file)
if "subject_data" in self.conf:
# subject information is store in database
# default database is a shelve database which is OK in some setups
dbspec = self.conf["subject_data"]
idb = None
if isinstance(dbspec, basestring):
idb = shelve.open(dbspec, writeback=True)
else: # database spec is a a 2-tuple (type, address)
(typ, addr) = dbspec
if typ == "shelve":
idb = shelve.open(addr, writeback=True)
elif typ == "memcached":
idb = memcache.Client(addr)
if idb is not None:
self.ident = Identifier(idb, self.conf.vo_conf, self.debug,
self.log)
else:
raise Exception("Couldn't open identity database: %s" %
(dbspec,))
else:
self.ident = None
def issuer(self):
""" Return an Issuer precursor """
return saml.Issuer(text=self.conf["entityid"],
format=saml.NAMEID_FORMAT_ENTITY)
def parse_authn_request(self, enc_request, binding=BINDING_HTTP_REDIRECT):
"""Parse a Authentication Request
:param enc_request: The request in its transport format
:param binding: Which binding that was used to transport the message
to this entity.
:return: A dictionary with keys:
consumer_url - as gotten from the SPs entity_id and the metadata
id - the id of the request
sp_entity_id - the entity id of the SP
request - The verified request
"""
response = {}
receiver_addresses = self.conf.endpoint("idp",
"single_sign_on_service",
binding)
authn_request = AuthnRequest(self.sec,
self.conf.attribute_converters(),
receiver_addresses)
authn_request = authn_request.loads(enc_request)
if authn_request:
authn_request = authn_request.verify()
if not authn_request:
return None
response["id"] = authn_request.message.id # put in in_reply_to
sp_entity_id = authn_request.message.issuer.text
# try to find return address in metadata
try:
consumer_url = self.metadata.consumer_url(sp_entity_id)
except KeyError:
if self.log:
self.log.info(
"Failed to find consumer URL for %s" % sp_entity_id)
if self.log: self.log.info(
"entities: %s" % self.metadata.entity.keys())
raise UnknownPrincipal(sp_entity_id)
if not consumer_url: # what to do ?
raise UnsupportedBinding(sp_entity_id)
response["sp_entity_id"] = sp_entity_id
if authn_request.message.assertion_consumer_service_url:
return_destination = \
authn_request.message.assertion_consumer_service_url
if consumer_url != return_destination:
# serious error on someones behalf
if self.log:
self.log.info("%s != %s" % (consumer_url,
return_destination))
else:
print >> sys.stderr, \
"%s != %s" % (consumer_url, return_destination)
raise OtherError("ConsumerURL and return destination mismatch")
response["consumer_url"] = consumer_url
response["request"] = authn_request.message
return response
def wants(self, sp_entity_id):
""" Returns what attributes the SP requiers and which are optional
if any such demands are registered in the Metadata.
:param sp_entity_id: The entity id of the SP
:return: 2-tuple, list of required and list of optional attributes
"""
return self.metadata.requests(sp_entity_id)
def parse_attribute_query(self, xml_string):
""" Parse an attribute query
:param xml_string: The Attribute Query as an XML string
:return: 3-Tuple containing:
subject - identifier of the subject
attribute - which attributes that the requestor wants back
query - the whole query
"""
receiver_addresses = self.conf.endpoint("aa", "attribute_service")
attribute_query = AttributeQuery( self.sec, receiver_addresses)
attribute_query = attribute_query.loads(xml_string)
attribute_query = attribute_query.verify()
# Subject name is a BaseID,NameID or EncryptedID instance
subject = attribute_query.subject_id()
attribute = attribute_query.attribute()
return subject, attribute, attribute_query.message
# ------------------------------------------------------------------------
def _response(self, in_response_to, consumer_url=None, sp_entity_id=None,
identity=None, name_id=None, status=None, sign=False,
policy=Policy(), authn=None, authn_decl=None):
""" Create a Response that adhers to the ??? profile.
:param in_response_to: The session identifier of the request
:param consumer_url: The URL which should receive the response
:param sp_entity_id: The entity identifier of the SP
:param identity: A dictionary with attributes and values that are
expected to be the bases for the assertion in the response.
:param name_id: The identifier of the subject
:param status: The status of the response
:param sign: Whether the assertion should be signed or not
:param policy: The attribute release policy for this instance
:param auth: A 2-tuple denoting the authn class and the authn authority
:return: A Response instance
"""
to_sign = []
if not status:
status = success_status_factory()
_issuer = self.issuer()
response = response_factory(
issuer=_issuer,
in_response_to = in_response_to,
status = status,
)
if consumer_url:
response.destination = consumer_url
if identity:
ast = Assertion(identity)
try:
ast.apply_policy(sp_entity_id, policy, self.metadata)
except MissingValue, exc:
return self.error_response(in_response_to, consumer_url,
sp_entity_id, exc, name_id)
if authn: # expected to be a 2-tuple class+authority
(authn_class, authn_authn) = authn
assertion = ast.construct(sp_entity_id, in_response_to,
consumer_url, name_id,
self.conf.attribute_converters(),
policy, issuer=_issuer,
authn_class=authn_class,
authn_auth=authn_authn)
elif authn_decl:
assertion = ast.construct(sp_entity_id, in_response_to,
consumer_url, name_id,
self.conf.attribute_converters(),
policy, issuer=_issuer,
authn_decl=authn_decl)
else:
assertion = ast.construct(sp_entity_id, in_response_to,
consumer_url, name_id,
self.conf.attribute_converters(),
policy, issuer=_issuer)
if sign:
assertion.signature = pre_signature_part(assertion.id,
self.sec.my_cert, 1)
# Just the assertion or the response and the assertion ?
to_sign = [(class_name(assertion), assertion.id)]
# Store which assertion that has been sent to which SP about which
# subject.
# self.cache.set(assertion.subject.name_id.text,
# sp_entity_id, {"ava": identity, "authn": authn},
# assertion.conditions.not_on_or_after)
response.assertion = assertion
return signed_instance_factory(response, self.sec, to_sign)
# ------------------------------------------------------------------------
def do_response(self, in_response_to, consumer_url,
sp_entity_id, identity=None, name_id=None,
status=None, sign=False, authn=None, authn_decl=None ):
""" Create a response. A layer of indirection.
:param in_response_to: The session identifier of the request
:param consumer_url: The URL which should receive the response
:param sp_entity_id: The entity identifier of the SP
:param identity: A dictionary with attributes and values that are
expected to be the bases for the assertion in the response.
:param name_id: The identifier of the subject
:param status: The status of the response
:param sign: Whether the assertion should be signed or not
:param auth: A 2-tuple denoting the authn class and the authn authority.
:param authn_decl:
:return: A Response instance.
"""
try:
policy = self.conf.idp_policy()
except KeyError:
policy = self.conf.aa_policy()
return self._response(in_response_to, consumer_url,
sp_entity_id, identity, name_id,
status, sign, policy, authn, authn_decl)
# ------------------------------------------------------------------------
def error_response(self, in_response_to, destination, spid, info,
name_id=None, sign=False):
""" Create a error response.
:param in_response_to: The identifier of the message this is a response
to.
:param destination: The intended recipient of this message
:param spid: The entitiy ID of the SP that will get this.
:param info: Either an Exception instance or a 2-tuple consisting of
error code and descriptive text
:return: A Response instance
"""
status = error_status_factory(info)
return self._response(
in_response_to, # in_response_to
destination, # consumer_url
spid, # sp_entity_id
None, # identity
name_id,
status = status,
sign=sign
)
# ------------------------------------------------------------------------
def do_aa_response(self, in_response_to, consumer_url, sp_entity_id,
identity=None, userid="", name_id=None, status=None,
sign=False, _name_id_policy=None):
""" Create an attribute assertion response.
:param in_response_to: The session identifier of the request
:param consumer_url: The URL which should receive the response
:param sp_entity_id: The entity identifier of the SP
:param identity: A dictionary with attributes and values that are
expected to be the bases for the assertion in the response.
:param userid: A identifier of the user
:param name_id: The identifier of the subject
:param status: The status of the response
:param sign: Whether the assertion should be signed or not
:param name_id_policy: Policy for NameID creation.
:return: A Response instance.
"""
name_id = self.ident.construct_nameid(self.conf.aa_policy(), userid,
sp_entity_id, identity)
return self._response(in_response_to, consumer_url,
sp_entity_id, identity, name_id,
status, sign, policy=self.conf.aa_policy())
# ------------------------------------------------------------------------
def authn_response(self, identity, in_response_to, destination,
sp_entity_id, name_id_policy, userid, sign=False,
authn=None, sign_response=False, authn_decl=None):
""" Constructs an AuthenticationResponse
:param identity: Information about an user
:param in_response_to: The identifier of the authentication request
this response is an answer to.
:param destination: Where the response should be sent
:param sp_entity_id: The entity identifier of the Service Provider
:param name_id_policy: ...
:param userid: The subject identifier
:param sign: Whether the assertion should be signed or not. This is
different from signing the response as such.
:param authn: Information about the authentication
:param sign_response: The response can be signed separately from the
assertions.
:param authn_decl:
:return: A XML string representing an authentication response
"""
try:
try:
policy = self.conf.idp_policy()
except KeyError:
policy = self.conf.aa_policy()
name_id = self.ident.construct_nameid(policy, userid, sp_entity_id,
identity, name_id_policy)
except IOError, exc:
response = self.error_response(in_response_to, destination,
sp_entity_id, exc, name_id)
return ("%s" % response).split("\n")
try:
response = self.do_response(
in_response_to, # in_response_to
destination, # consumer_url
sp_entity_id, # sp_entity_id
identity, # identity as dictionary
name_id,
sign=sign, # If the assertion should be signed
authn=authn, # Information about the
# authentication
authn_decl=authn_decl
)
except MissingValue, exc:
response = self.error_response(in_response_to, destination,
sp_entity_id, exc, name_id)
if sign_response:
try:
response.signature = pre_signature_part(response.id,
self.sec.my_cert, 2)
return self.sec.sign_statement_using_xmlsec(response,
class_name(response),
nodeid=response.id)
except Exception, exc:
response = self.error_response(in_response_to, destination,
sp_entity_id, exc, name_id)
return ("%s" % response).split("\n")
else:
return ("%s" % response).split("\n")
def parse_logout_request(self, text, binding=BINDING_SOAP):
"""Parse a Logout Request
:param text: The request in its transport format, if the binding is
HTTP-Redirect or HTTP-Post the text *must* be the value of the
SAMLRequest attribute.
:return: A validated LogoutRequest instance or None if validation
failed.
"""
slo = self.conf.endpoint("idp", "single_logout_service", binding)[0]
try:
slo = self.conf.endpoint("idp", "single_logout_service",
binding)[0]
except IndexError:
if self.log:
self.log.info("enpoints: %s" % (self.conf["service"]["idp"][
"endpoints"]))
self.log.info("binding wanted: %s" % (binding,))
raise
if self.log:
self.log.info("Endpoint: %s" % slo)
req = LogoutRequest(self.sec, slo)
if binding == BINDING_SOAP:
lreq = soap.parse_soap_enveloped_saml_logout_request(text)
try:
req = req.loads(lreq, False) # Got it over SOAP so no base64+zip
except Exception:
return None
else:
try:
req = req.loads(text)
except Exception, exc:
self.log.error("%s" % (exc,))
return None
req = req.verify()
if not req: # Not a valid request
# return a error message with status code element set to
# urn:oasis:names:tc:SAML:2.0:status:Requester
return None
else:
return req
def logout_response(self, request, bindings, status=None,
sign=False):
""" Create a LogoutResponse. What is returned depends on which binding
is used.
:param request: The request this is a response to
:param bindings: Which bindings that can be used to send the response
:param status: The return status of the response operation
:return: A 3-tuple consisting of HTTP return code, HTTP headers and
possibly a message.
"""
sp_entity_id = request.issuer.text.strip()
binding = None
destination = ""
for binding in bindings:
destination = self.conf.logout_service(sp_entity_id, "sp",
binding)
if destination:
break
if not destination:
if self.log:
self.log.error("Not way to return a response !!!")
return ("412 Precondition Failed",
[("Content-type", "text/html")],
["No return way defined"])
# Pick the first
destination = destination[0]
if self.log:
self.log.info("Logout Destination: %s, binding: %s" % (destination,
binding))
if not status:
status = success_status_factory()
mid = sid()
rcode = "200 OK"
# response and packaging differs depending on binding
if binding == BINDING_SOAP:
response = logoutresponse_factory(
sign=sign,
id = mid,
in_response_to = request.id,
status = status,
)
if sign:
to_sign = [(class_name(response), mid)]
response = signed_instance_factory(response, self.sec, to_sign)
(headers, message) = http_soap_message(response)
else:
response = logoutresponse_factory(
sign=sign,
id = mid,
in_response_to = request.id,
status = status,
issuer = self.issuer(),
destination = destination,
sp_entity_id = sp_entity_id,
instant=instant(),
)
if sign:
to_sign = [(class_name(response), mid)]
response = signed_instance_factory(response, self.sec, to_sign)
if self.log:
self.log.info("Response: %s" % (response,))
if binding == BINDING_HTTP_REDIRECT:
(headers, message) = http_redirect_message(response,
destination,
typ="SAMLResponse")
rcode = "302 Found"
else:
(headers, message) = http_post_message(response, destination,
typ="SAMLResponse")
return rcode, headers, message