123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774 |
- # -*- test-case-name: openid.test.test_ax -*-
- """Implements the OpenID Attribute Exchange specification, version 1.0.
- @since: 2.1.0
- """
- __all__ = [
- 'AttributeRequest',
- 'FetchRequest',
- 'FetchResponse',
- 'StoreRequest',
- 'StoreResponse',
- ]
- from openid import extension
- from openid.server.trustroot import TrustRoot
- from openid.message import NamespaceMap, OPENID_NS
- # Use this as the 'count' value for an attribute in a FetchRequest to
- # ask for as many values as the OP can provide.
- UNLIMITED_VALUES = "unlimited"
- # Minimum supported alias length in characters. Here for
- # completeness.
- MINIMUM_SUPPORTED_ALIAS_LENGTH = 32
- def checkAlias(alias):
- """
- Check an alias for invalid characters; raise AXError if any are
- found. Return None if the alias is valid.
- """
- if ',' in alias:
- raise AXError("Alias %r must not contain comma" % (alias,))
- if '.' in alias:
- raise AXError("Alias %r must not contain period" % (alias,))
- class AXError(ValueError):
- """Results from data that does not meet the attribute exchange 1.0
- specification"""
- class NotAXMessage(AXError):
- """Raised when there is no Attribute Exchange mode in the message."""
- def __repr__(self):
- return self.__class__.__name__
- def __str__(self):
- return self.__class__.__name__
- class AXMessage(extension.Extension):
- """Abstract class containing common code for attribute exchange messages
- @cvar ns_alias: The preferred namespace alias for attribute
- exchange messages
- @cvar mode: The type of this attribute exchange message. This must
- be overridden in subclasses.
- """
- # This class is abstract, so it's OK that it doesn't override the
- # abstract method in Extension:
- #
- #pylint:disable-msg=W0223
- ns_alias = 'ax'
- mode = None
- ns_uri = 'http://openid.net/srv/ax/1.0'
- def _checkMode(self, ax_args):
- """Raise an exception if the mode in the attribute exchange
- arguments does not match what is expected for this class.
- @raises NotAXMessage: When there is no mode value in ax_args at all.
- @raises AXError: When mode does not match.
- """
- mode = ax_args.get('mode')
- if mode != self.mode:
- if not mode:
- raise NotAXMessage()
- else:
- raise AXError(
- 'Expected mode %r; got %r' % (self.mode, mode))
- def _newArgs(self):
- """Return a set of attribute exchange arguments containing the
- basic information that must be in every attribute exchange
- message.
- """
- return {'mode':self.mode}
- class AttrInfo(object):
- """Represents a single attribute in an attribute exchange
- request. This should be added to an AXRequest object in order to
- request the attribute.
- @ivar required: Whether the attribute will be marked as required
- when presented to the subject of the attribute exchange
- request.
- @type required: bool
- @ivar count: How many values of this type to request from the
- subject. Defaults to one.
- @type count: int
- @ivar type_uri: The identifier that determines what the attribute
- represents and how it is serialized. For example, one type URI
- representing dates could represent a Unix timestamp in base 10
- and another could represent a human-readable string.
- @type type_uri: str
- @ivar alias: The name that should be given to this alias in the
- request. If it is not supplied, a generic name will be
- assigned. For example, if you want to call a Unix timestamp
- value 'tstamp', set its alias to that value. If two attributes
- in the same message request to use the same alias, the request
- will fail to be generated.
- @type alias: str or NoneType
- """
- # It's OK that this class doesn't have public methods (it's just a
- # holder for a bunch of attributes):
- #
- #pylint:disable-msg=R0903
- def __init__(self, type_uri, count=1, required=False, alias=None):
- self.required = required
- self.count = count
- self.type_uri = type_uri
- self.alias = alias
- if self.alias is not None:
- checkAlias(self.alias)
- def wantsUnlimitedValues(self):
- """
- When processing a request for this attribute, the OP should
- call this method to determine whether all available attribute
- values were requested. If self.count == UNLIMITED_VALUES,
- this returns True. Otherwise this returns False, in which
- case self.count is an integer.
- """
- return self.count == UNLIMITED_VALUES
- def toTypeURIs(namespace_map, alias_list_s):
- """Given a namespace mapping and a string containing a
- comma-separated list of namespace aliases, return a list of type
- URIs that correspond to those aliases.
- @param namespace_map: The mapping from namespace URI to alias
- @type namespace_map: openid.message.NamespaceMap
- @param alias_list_s: The string containing the comma-separated
- list of aliases. May also be None for convenience.
- @type alias_list_s: str or NoneType
- @returns: The list of namespace URIs that corresponds to the
- supplied list of aliases. If the string was zero-length or
- None, an empty list will be returned.
- @raise KeyError: If an alias is present in the list of aliases but
- is not present in the namespace map.
- """
- uris = []
- if alias_list_s:
- for alias in alias_list_s.split(','):
- type_uri = namespace_map.getNamespaceURI(alias)
- if type_uri is None:
- raise KeyError(
- 'No type is defined for attribute name %r' % (alias,))
- else:
- uris.append(type_uri)
- return uris
- class FetchRequest(AXMessage):
- """An attribute exchange 'fetch_request' message. This message is
- sent by a relying party when it wishes to obtain attributes about
- the subject of an OpenID authentication request.
- @ivar requested_attributes: The attributes that have been
- requested thus far, indexed by the type URI.
- @type requested_attributes: {str:AttrInfo}
- @ivar update_url: A URL that will accept responses for this
- attribute exchange request, even in the absence of the user
- who made this request.
- """
- mode = 'fetch_request'
- def __init__(self, update_url=None):
- AXMessage.__init__(self)
- self.requested_attributes = {}
- self.update_url = update_url
- def add(self, attribute):
- """Add an attribute to this attribute exchange request.
- @param attribute: The attribute that is being requested
- @type attribute: C{L{AttrInfo}}
- @returns: None
- @raise KeyError: when the requested attribute is already
- present in this fetch request.
- """
- if attribute.type_uri in self.requested_attributes:
- raise KeyError('The attribute %r has already been requested'
- % (attribute.type_uri,))
- self.requested_attributes[attribute.type_uri] = attribute
- def getExtensionArgs(self):
- """Get the serialized form of this attribute fetch request.
- @returns: The fetch request message parameters
- @rtype: {unicode:unicode}
- """
- aliases = NamespaceMap()
- required = []
- if_available = []
- ax_args = self._newArgs()
- for type_uri, attribute in self.requested_attributes.iteritems():
- if attribute.alias is None:
- alias = aliases.add(type_uri)
- else:
- # This will raise an exception when the second
- # attribute with the same alias is added. I think it
- # would be better to complain at the time that the
- # attribute is added to this object so that the code
- # that is adding it is identified in the stack trace,
- # but it's more work to do so, and it won't be 100%
- # accurate anyway, since the attributes are
- # mutable. So for now, just live with the fact that
- # we'll learn about the error later.
- #
- # The other possible approach is to hide the error and
- # generate a new alias on the fly. I think that would
- # probably be bad.
- alias = aliases.addAlias(type_uri, attribute.alias)
- if attribute.required:
- required.append(alias)
- else:
- if_available.append(alias)
- if attribute.count != 1:
- ax_args['count.' + alias] = str(attribute.count)
- ax_args['type.' + alias] = type_uri
- if required:
- ax_args['required'] = ','.join(required)
- if if_available:
- ax_args['if_available'] = ','.join(if_available)
- return ax_args
- def getRequiredAttrs(self):
- """Get the type URIs for all attributes that have been marked
- as required.
- @returns: A list of the type URIs for attributes that have
- been marked as required.
- @rtype: [str]
- """
- required = []
- for type_uri, attribute in self.requested_attributes.iteritems():
- if attribute.required:
- required.append(type_uri)
- return required
- def fromOpenIDRequest(cls, openid_request):
- """Extract a FetchRequest from an OpenID message
- @param openid_request: The OpenID authentication request
- containing the attribute fetch request
- @type openid_request: C{L{openid.server.server.CheckIDRequest}}
- @rtype: C{L{FetchRequest}} or C{None}
- @returns: The FetchRequest extracted from the message or None, if
- the message contained no AX extension.
- @raises KeyError: if the AuthRequest is not consistent in its use
- of namespace aliases.
- @raises AXError: When parseExtensionArgs would raise same.
- @see: L{parseExtensionArgs}
- """
- message = openid_request.message
- ax_args = message.getArgs(cls.ns_uri)
- self = cls()
- try:
- self.parseExtensionArgs(ax_args)
- except NotAXMessage, err:
- return None
- if self.update_url:
- # Update URL must match the openid.realm of the underlying
- # OpenID 2 message.
- realm = message.getArg(OPENID_NS, 'realm',
- message.getArg(OPENID_NS, 'return_to'))
- if not realm:
- raise AXError(("Cannot validate update_url %r " +
- "against absent realm") % (self.update_url,))
- tr = TrustRoot.parse(realm)
- if not tr.validateURL(self.update_url):
- raise AXError("Update URL %r failed validation against realm %r" %
- (self.update_url, realm,))
- return self
- fromOpenIDRequest = classmethod(fromOpenIDRequest)
- def parseExtensionArgs(self, ax_args):
- """Given attribute exchange arguments, populate this FetchRequest.
- @param ax_args: Attribute Exchange arguments from the request.
- As returned from L{Message.getArgs<openid.message.Message.getArgs>}.
- @type ax_args: dict
- @raises KeyError: if the message is not consistent in its use
- of namespace aliases.
- @raises NotAXMessage: If ax_args does not include an Attribute Exchange
- mode.
- @raises AXError: If the data to be parsed does not follow the
- attribute exchange specification. At least when
- 'if_available' or 'required' is not specified for a
- particular attribute type.
- """
- # Raises an exception if the mode is not the expected value
- self._checkMode(ax_args)
- aliases = NamespaceMap()
- for key, value in ax_args.iteritems():
- if key.startswith('type.'):
- alias = key[5:]
- type_uri = value
- aliases.addAlias(type_uri, alias)
- count_key = 'count.' + alias
- count_s = ax_args.get(count_key)
- if count_s:
- try:
- count = int(count_s)
- if count <= 0:
- raise AXError("Count %r must be greater than zero, got %r" % (count_key, count_s,))
- except ValueError:
- if count_s != UNLIMITED_VALUES:
- raise AXError("Invalid count value for %r: %r" % (count_key, count_s,))
- count = count_s
- else:
- count = 1
- self.add(AttrInfo(type_uri, alias=alias, count=count))
- required = toTypeURIs(aliases, ax_args.get('required'))
- for type_uri in required:
- self.requested_attributes[type_uri].required = True
- if_available = toTypeURIs(aliases, ax_args.get('if_available'))
- all_type_uris = required + if_available
- for type_uri in aliases.iterNamespaceURIs():
- if type_uri not in all_type_uris:
- raise AXError(
- 'Type URI %r was in the request but not '
- 'present in "required" or "if_available"' % (type_uri,))
- self.update_url = ax_args.get('update_url')
- def iterAttrs(self):
- """Iterate over the AttrInfo objects that are
- contained in this fetch_request.
- """
- return self.requested_attributes.itervalues()
- def __iter__(self):
- """Iterate over the attribute type URIs in this fetch_request
- """
- return iter(self.requested_attributes)
- def has_key(self, type_uri):
- """Is the given type URI present in this fetch_request?
- """
- return type_uri in self.requested_attributes
- __contains__ = has_key
- class AXKeyValueMessage(AXMessage):
- """An abstract class that implements a message that has attribute
- keys and values. It contains the common code between
- fetch_response and store_request.
- """
- # This class is abstract, so it's OK that it doesn't override the
- # abstract method in Extension:
- #
- #pylint:disable-msg=W0223
- def __init__(self):
- AXMessage.__init__(self)
- self.data = {}
- def addValue(self, type_uri, value):
- """Add a single value for the given attribute type to the
- message. If there are already values specified for this type,
- this value will be sent in addition to the values already
- specified.
- @param type_uri: The URI for the attribute
- @param value: The value to add to the response to the relying
- party for this attribute
- @type value: unicode
- @returns: None
- """
- try:
- values = self.data[type_uri]
- except KeyError:
- values = self.data[type_uri] = []
- values.append(value)
- def setValues(self, type_uri, values):
- """Set the values for the given attribute type. This replaces
- any values that have already been set for this attribute.
- @param type_uri: The URI for the attribute
- @param values: A list of values to send for this attribute.
- @type values: [unicode]
- """
- self.data[type_uri] = values
- def _getExtensionKVArgs(self, aliases=None):
- """Get the extension arguments for the key/value pairs
- contained in this message.
- @param aliases: An alias mapping. Set to None if you don't
- care about the aliases for this request.
- """
- if aliases is None:
- aliases = NamespaceMap()
- ax_args = {}
- for type_uri, values in self.data.iteritems():
- alias = aliases.add(type_uri)
- ax_args['type.' + alias] = type_uri
- ax_args['count.' + alias] = str(len(values))
- for i, value in enumerate(values):
- key = 'value.%s.%d' % (alias, i + 1)
- ax_args[key] = value
- return ax_args
- def parseExtensionArgs(self, ax_args):
- """Parse attribute exchange key/value arguments into this
- object.
- @param ax_args: The attribute exchange fetch_response
- arguments, with namespacing removed.
- @type ax_args: {unicode:unicode}
- @returns: None
- @raises ValueError: If the message has bad values for
- particular fields
- @raises KeyError: If the namespace mapping is bad or required
- arguments are missing
- """
- self._checkMode(ax_args)
- aliases = NamespaceMap()
- for key, value in ax_args.iteritems():
- if key.startswith('type.'):
- type_uri = value
- alias = key[5:]
- checkAlias(alias)
- aliases.addAlias(type_uri, alias)
- for type_uri, alias in aliases.iteritems():
- try:
- count_s = ax_args['count.' + alias]
- except KeyError:
- value = ax_args['value.' + alias]
- if value == u'':
- values = []
- else:
- values = [value]
- else:
- count = int(count_s)
- values = []
- for i in range(1, count + 1):
- value_key = 'value.%s.%d' % (alias, i)
- value = ax_args[value_key]
- values.append(value)
- self.data[type_uri] = values
- def getSingle(self, type_uri, default=None):
- """Get a single value for an attribute. If no value was sent
- for this attribute, use the supplied default. If there is more
- than one value for this attribute, this method will fail.
- @type type_uri: str
- @param type_uri: The URI for the attribute
- @param default: The value to return if the attribute was not
- sent in the fetch_response.
- @returns: The value of the attribute in the fetch_response
- message, or the default supplied
- @rtype: unicode or NoneType
- @raises ValueError: If there is more than one value for this
- parameter in the fetch_response message.
- @raises KeyError: If the attribute was not sent in this response
- """
- values = self.data.get(type_uri)
- if not values:
- return default
- elif len(values) == 1:
- return values[0]
- else:
- raise AXError(
- 'More than one value present for %r' % (type_uri,))
- def get(self, type_uri):
- """Get the list of values for this attribute in the
- fetch_response.
- XXX: what to do if the values are not present? default
- parameter? this is funny because it's always supposed to
- return a list, so the default may break that, though it's
- provided by the user's code, so it might be okay. If no
- default is supplied, should the return be None or []?
- @param type_uri: The URI of the attribute
- @returns: The list of values for this attribute in the
- response. May be an empty list.
- @rtype: [unicode]
- @raises KeyError: If the attribute was not sent in the response
- """
- return self.data[type_uri]
- def count(self, type_uri):
- """Get the number of responses for a particular attribute in
- this fetch_response message.
- @param type_uri: The URI of the attribute
- @returns: The number of values sent for this attribute
- @raises KeyError: If the attribute was not sent in the
- response. KeyError will not be raised if the number of
- values was zero.
- """
- return len(self.get(type_uri))
- class FetchResponse(AXKeyValueMessage):
- """A fetch_response attribute exchange message
- """
- mode = 'fetch_response'
- def __init__(self, request=None, update_url=None):
- """
- @param request: When supplied, I will use namespace aliases
- that match those in this request. I will also check to
- make sure I do not respond with attributes that were not
- requested.
- @type request: L{FetchRequest}
- @param update_url: By default, C{update_url} is taken from the
- request. But if you do not supply the request, you may set
- the C{update_url} here.
- @type update_url: str
- """
- AXKeyValueMessage.__init__(self)
- self.update_url = update_url
- self.request = request
- def getExtensionArgs(self):
- """Serialize this object into arguments in the attribute
- exchange namespace
- @returns: The dictionary of unqualified attribute exchange
- arguments that represent this fetch_response.
- @rtype: {unicode;unicode}
- """
- aliases = NamespaceMap()
- zero_value_types = []
- if self.request is not None:
- # Validate the data in the context of the request (the
- # same attributes should be present in each, and the
- # counts in the response must be no more than the counts
- # in the request)
- for type_uri in self.data:
- if type_uri not in self.request:
- raise KeyError(
- 'Response attribute not present in request: %r'
- % (type_uri,))
- for attr_info in self.request.iterAttrs():
- # Copy the aliases from the request so that reading
- # the response in light of the request is easier
- if attr_info.alias is None:
- aliases.add(attr_info.type_uri)
- else:
- aliases.addAlias(attr_info.type_uri, attr_info.alias)
- try:
- values = self.data[attr_info.type_uri]
- except KeyError:
- values = []
- zero_value_types.append(attr_info)
- if (attr_info.count != UNLIMITED_VALUES) and \
- (attr_info.count < len(values)):
- raise AXError(
- 'More than the number of requested values were '
- 'specified for %r' % (attr_info.type_uri,))
- kv_args = self._getExtensionKVArgs(aliases)
- # Add the KV args into the response with the args that are
- # unique to the fetch_response
- ax_args = self._newArgs()
- # For each requested attribute, put its type/alias and count
- # into the response even if no data were returned.
- for attr_info in zero_value_types:
- alias = aliases.getAlias(attr_info.type_uri)
- kv_args['type.' + alias] = attr_info.type_uri
- kv_args['count.' + alias] = '0'
- update_url = ((self.request and self.request.update_url)
- or self.update_url)
- if update_url:
- ax_args['update_url'] = update_url
- ax_args.update(kv_args)
- return ax_args
- def parseExtensionArgs(self, ax_args):
- """@see: {Extension.parseExtensionArgs<openid.extension.Extension.parseExtensionArgs>}"""
- super(FetchResponse, self).parseExtensionArgs(ax_args)
- self.update_url = ax_args.get('update_url')
- def fromSuccessResponse(cls, success_response, signed=True):
- """Construct a FetchResponse object from an OpenID library
- SuccessResponse object.
- @param success_response: A successful id_res response object
- @type success_response: openid.consumer.consumer.SuccessResponse
- @param signed: Whether non-signed args should be
- processsed. If True (the default), only signed arguments
- will be processsed.
- @type signed: bool
- @returns: A FetchResponse containing the data from the OpenID
- message, or None if the SuccessResponse did not contain AX
- extension data.
- @raises AXError: when the AX data cannot be parsed.
- """
- self = cls()
- ax_args = success_response.extensionResponse(self.ns_uri, signed)
- try:
- self.parseExtensionArgs(ax_args)
- except NotAXMessage, err:
- return None
- else:
- return self
- fromSuccessResponse = classmethod(fromSuccessResponse)
- class StoreRequest(AXKeyValueMessage):
- """A store request attribute exchange message representation
- """
- mode = 'store_request'
- def __init__(self, aliases=None):
- """
- @param aliases: The namespace aliases to use when making this
- store request. Leave as None to use defaults.
- """
- super(StoreRequest, self).__init__()
- self.aliases = aliases
- def getExtensionArgs(self):
- """
- @see: L{Extension.getExtensionArgs<openid.extension.Extension.getExtensionArgs>}
- """
- ax_args = self._newArgs()
- kv_args = self._getExtensionKVArgs(self.aliases)
- ax_args.update(kv_args)
- return ax_args
- class StoreResponse(AXMessage):
- """An indication that the store request was processed along with
- this OpenID transaction.
- """
- SUCCESS_MODE = 'store_response_success'
- FAILURE_MODE = 'store_response_failure'
- def __init__(self, succeeded=True, error_message=None):
- AXMessage.__init__(self)
- if succeeded and error_message is not None:
- raise AXError('An error message may only be included in a '
- 'failing fetch response')
- if succeeded:
- self.mode = self.SUCCESS_MODE
- else:
- self.mode = self.FAILURE_MODE
- self.error_message = error_message
- def succeeded(self):
- """Was this response a success response?"""
- return self.mode == self.SUCCESS_MODE
- def getExtensionArgs(self):
- """@see: {Extension.getExtensionArgs<openid.extension.Extension.getExtensionArgs>}"""
- ax_args = self._newArgs()
- if not self.succeeded() and self.error_message:
- ax_args['error'] = self.error_message
- return ax_args
|