repoze.who-2.2/0000775000175000017500000000000012145723513013366 5ustar tseavertseaverrepoze.who-2.2/setup.cfg0000664000175000017500000000033212145723513015205 0ustar tseavertseaver[easy_install] zip_ok = false [nosetests] cover-package = repoze.who nocapture = 1 cover-erase = 1 [egg_info] tag_build = tag_date = 0 tag_svn_revision = 0 [aliases] dev = develop easy_install repoze.who[testing] repoze.who-2.2/PKG-INFO0000664000175000017500000007517012145723513014475 0ustar tseavertseaverMetadata-Version: 1.0 Name: repoze.who Version: 2.2 Summary: repoze.who is an identification and authentication framework for WSGI. Home-page: http://www.repoze.org Author: Agendaless Consulting Author-email: repoze-dev@lists.repoze.org License: BSD-derived (http://www.repoze.org/LICENSE.txt) Description: ``repoze.who`` -- WSGI Authentication Middleware / API ====================================================== Overview -------- ``repoze.who`` is an identification and authentication framework for arbitrary WSGI applications. ``repoze.who`` can be configured either as WSGI middleware or as an API for use by an application. ``repoze.who`` is inspired by Zope 2's Pluggable Authentication Service (PAS) (but ``repoze.who`` is not dependent on Zope in any way; it is useful for any WSGI application). It provides no facility for authorization (ensuring whether a user can or cannot perform the operation implied by the request). This is considered to be the domain of the WSGI application. See the ``docs`` subdirectory of this package (also available at least provisionally at http://static.repoze.org/whodocs) for more information. repoze.who Changelog ==================== 2.2 (2013-05-17) ---------------- - Parse INI-file configuration using ``SafeConfigParser``: allows escaping the ``'%'`` so that e.g. a query template using for a DB-API connection using ``pyformat`` preserves the template. - Added support for Python 3.3, PyPy. 2.1 (2013-03-20) ---------------- - ``_compat`` module: tolerate missing ``CONTENT_TYPE`` key in the WSGI environment. Thanks to Dag Hoidal for the patch. - ``htpasswd`` plugin: add a ``sha1_check`` checker function (the ``crypt`` module is not available on Windows). Thanks to Chandrashekar Jayaraman for the patch. - Documentation typo fixes from Carlos de la Guardia and Atsushi Odagiri. 2.1b1 (2012-11-05) ------------------ - Ported to Py3k using the "compatible subset" mode. - Dropped support for Python < 2.6.x. - Dropped dependency on Paste (forking some code from it). - Added dependency on WebOb instead. Thanks to Atsushi Odagiri (aodag) for the initial effort. 2.0 (2011-09-28) ---------------- - ``auth_tkt`` plugin: strip any port number from the 'Domain' of generated cookies. http://bugs.repoze.org/issue66 - Further harden middleware, calling ``close()`` on the iterable even if raising an exception for a missing challenger. http://bugs.repoze.org/issue174 2.0b1 (2011-05-24) ------------------ - Enabled standard use of logging module's configuration mechanism. See http://docs.python.org/dev/howto/logging.html#configuring-logging-for-a-library Thanks to jgoldsmith for the patch: http://bugs.repoze.org/issue178 - ``repoze.who.plugins.htpasswd``: defend against timing-based attacks. 2.0a4 (2011-02-02) ------------------ - Ensure that the middleware calls ``close()`` (if it exists) on the iterable returned from thw wrapped application, as required by PEP 333. http://bugs.repoze.org/issue174 - Make ``make_api_factory_with_config`` tolerant of invalid filenames / content for the config file: in such cases, the API factory will have *no* configured plugins or policies: it will only be useful for retrieving the API from an environment populated by middleware. - Fix bug in ``repoze.who.api`` where the ``remember()`` or ``forget()`` methods could return a None if the identifier plugin returned a None. - Fix ``auth_tkt`` plugin to not hand over tokens as strings to paste. See http://lists.repoze.org/pipermail/repoze-dev/2010-November/003680.html - Fix ``auth_tkt`` plugin to add "secure" and "HttpOnly" to cookies when configured with ``secure=True``: these attributes prevent the browser from sending cookies over insecure channels, which could be vulnerable to some XSS attacks. - Avoid propagating unicode 'max_age' value into cookie headers. See https://bugs.launchpad.net/bugs/674123 . - Added a single-file example BFG application demonstrating the use of the new 'login' and 'logout' methods of the API object. - Add ``login`` and ``logout`` methods to the ``repoze.who.api.API`` object, as a convenience for application-driven login / logout code, which would otherwise need to use private methods of the API, and reach down into its plugins. 2.0a3 (2010-09-30) ------------------ - Deprecated the following plugins, moving their modules, tests, and docs to a new project, ``repoze.who.deprecatedplugins``: - ``repoze.who.plugins.cookie.InsecureCookiePlugin`` - ``repoze.who.plugins.form.FormPlugin`` - ``repoze.who.plugins.form.RedirectingFormPlugin`` - Made the ``repoze.who.plugins.cookie.InsecureCookiePlugin`` take a ``charset`` argument, and use to to encode / decode login and password. See http://bugs.repoze.org/issue155 - Updated ``repoze.who.restrict`` to return headers as a list, to keep ``wsgiref`` from complaining. - Helped default request classifier cope with xml submissions with an explicit charset defined: http://bugs.repoze.org/issue145 (Lorenzo M. Catucci) - Corrected the handling of type and subtype when matching an XML post to ``xmlpost`` in the default classifier, which, according to RFC 2045, must be matched case-insensitively: http://bugs.repoze.org/issue145 (Lorenzo M. Catucci) - Added ``repoze.who.config:make_api_factory_with_config``, a convenience method for applications which want to set up their own API Factory from a configuration file. - Fixed example call to ``repoze.who.config:make_middleware_with_config`` (added missing ``global_config`` argument). See http://bugs.repoze.org/issue114 2.0a2 (2010-03-25) ------------------ Bugs Fixed ~~~~~~~~~~ - Fixed failure to pass substution values in log message string formatting for ``repoze.who.api:API.challenge``. Fix included adding tests for all logging done by the API object. See http://bugs.repoze.org/issue122 Backward Incompatibilities ~~~~~~~~~~~~~~~~~~~~~~~~~~ - Adjusted logging level for some lower-level details from ``info`` to ``debug``. 2.0a1 (2010-02-24) ------------------ Features ~~~~~~~~ - Restored the ability to create the middleware using the old ``classifier`` argument. That argument is now a deprecated-but-will-work-forever alias for ``request_classifier``. - The ``auth_tkt`` plugin now implements the ``IAuthenticator`` interface, and should normally be used both as an ``IIdentifier`` and an ``IAuthenticator``. - Factored out the API of the middleware object to make it useful from within the application. Applications using ``repoze.who``` now fall into one of three catgeories: - "middleware-only" applications are configured with middleware, and use either ``REMOTE_USER`` or ``repoze.who.identity`` from the environment to determing the authenticated user. - "bare metal" applications use no ``repoze.who`` middleware at all: instead, they configure and an ``APIFactory`` object at startup, and use it to create an ``API`` object when needed on a per-request basis. - "hybrid" applications are configured with ``repoze.who`` middleware, but use a new library function to fetch the ``API`` object from the environ, e.g. to permit calling ``remember`` after a signup or successful login. Bugs Fixed ~~~~~~~~~~ - Fix http://bugs.repoze.org/issue102: when no challengers existed, logging would cause an exception. - Remove ``ez_setup.py`` and dependency on it in setup.py (support distribute). Backward Incompatibilities ~~~~~~~~~~~~~~~~~~~~~~~~~~ - The middleware used to allow identifier plugins to "pre-authenticate" an identity. This feature is no longer supported: the ``auth_tkt`` plugin, which used to use the feature, is now configured to work as an authenticator plugin (as well as an identifier). - The ``repoze.who.middleware:PluggableAuthenticationMiddleware`` class no longer has the following (non-API) methods (now made API methods of the ``repoze.who.api:API`` class): - ``add_metadata`` - ``authenticate`` - ``challenge`` - ``identify`` - The following (non-API) functions moved from ``repoze.who.middleware`` to ``repoze.who.api``: - ``make_registries`` - ``match_classification`` - ``verify`` 1.0.18 (2009-11-05) ------------------- - Issue #104: AuthTkt plugin was passing an invalid cookie value in headers from ``forget``, and was not setting the ``Max-Age`` and ``Expires`` attributes of those cookies. 1.0.17 (2009-11-05) ------------------- - Fixed the ``repoze.who.plugins.form.make_plugin`` factory's ``formcallable`` argument handling, to allow passing in a dotted name (e.g., from a config file). 1.0.16 (2009-11-04) ------------------- - Exposed ``formcallable`` argument for ``repoze.who.plugins.form.FormPlugin`` to the callers of the ``repoze.who.plugins.form.make_plugin`` factory. Thanks to Roland Hedburg for the report. - Fixed an issue that caused the following symptom when using the ini configuration parser:: TypeError: _makePlugin() got multiple values for keyword argument 'name' See http://bugs.repoze.org/issue92 for more details. Thanks to vaab for the bug report and initial fix. 1.0.15 (2009-06-25) ------------------- - If the form post value ``max_age`` exists while in the ``identify`` method is handling the ``login_handler_path``, pass the max_age value in the returned identity dictionary as ``max_age``. See the below bullet point for why. - If the ``identity`` dict passed to the ``auth_tkt`` ``remember`` method contains a ``max_age`` key with a string (or integer) value, treat it as a cue to set the ``Max-Age`` and ``Expires`` headers in the returned cookies. The cookie ``Max-Age`` is set to the value and the ``Expires`` is computed from the current time. 1.0.14 (2009-06-17) ------------------- - Fix test breakage on Windows. See http://bugs.repoze.org/issue79 . - Documented issue with using ``include_ip`` setting in the ``auth_tkt`` plugin. See http://bugs.repoze.org/issue81 . - Added 'passthrough_challenge_decider', which avoids re-challenging 401 responses which have been "pre-challenged" by the application. - One-hundred percent unit test coverage. - Add ``timeout`` and ``reissue_time`` arguments to the auth_tkt identifier plugin, courtesty of Paul Johnston. - Add a ``userid_checker`` argument to the auth_tkt identifier plugin, courtesty of Gustavo Narea. If ``userid_checker`` is provided, it must be a dotted Python name that resolves to a function which accepts a userid and returns a boolean True or False, indicating whether that user exists in a database. This is a workaround. Due to a design bug in repoze.who, the only way who can check for user existence is to use one or more IAuthenticator plugin ``authenticate`` methods. If an IAuthenticator's ``authenticate`` method returns true, it means that the user exists. However most IAuthenticator plugins expect *both* a username and a password, and will return False unconditionally if both aren't supplied. This means that an authenticator can't be used to check if the user "only" exists. The identity provided by an auth_tkt does not contain a password to check against. The actual design bug in repoze.who is this: when a user presents credentials from an auth_tkt, he is considered "preauthenticated". IAuthenticator.authenticate is just never called for a "preauthenticated" identity, which works fine, but it means that the user will be considered authenticated even if you deleted the user's record from whatever database you happen to be using. However, if you use a userid_checker, you can ensure that a user exists for the auth_tkt supplied userid. If the userid_checker returns False, the auth_tkt credentials are considered "no good". 1.0.13 (2009-04-24) ------------------- - Added a paragraph to ``IAuthenticator`` docstring, documenting that plugins are allowed to add keys to the ``identity`` dictionary (e.g., to save a second database query in an ``IMetadataProvider`` plugin). - Patch supplied for issue #71 (http://bugs.repoze.org/issue71) whereby a downstream app can return a generator, relying on an upstream component to call start_response. We do this because the challenge decider needs the status and headers to decide what to do. 1.0.12 (2009-04-19) ------------------- - auth_tkt plugin tried to append REMOTE_USER_TOKENS data to existing tokens data returned by auth_tkt.parse_tkt; this was incorrect; just overwrite. - Extended auth_tkt plugin factory to allow passing secret in a separate file from the main config file. See http://bugs.repoze.org/issue40 . 1.0.11 (2009-04-10) ------------------- - Fix auth_tkt plugin; cookie values are now quoted, making it possible to put spaces and other whitespace, etc in usernames. (thanks to Michael Pedersen). - Fix corner case issue of an exception raised when attempting to log when there are no identifiers or authenticators. 1.0.10 (2009-01-23) ------------------- - The RedirectingFormPlugin now passes along SetCookie headers set into the response by the application within the NotFound response (fixes TG2 "flash" issue). 1.0.9 (2008-12-18) ------------------ - The RedirectingFormPlugin now attempts to find a header named ``X-Authentication-Failure-Reason`` among the response headers set by the application when a challenge is issued. If a value for this header exists (and is non-blank), the value is attached to the redirect URL's query string as the ``reason`` parameter (or a user-settable key). This makes it possible for downstream applications to issue a response that initiates a challenge with this header and subsequently display the reason in the login form rendered as a result of the challenge. 1.0.8 (2008-12-13) ------------------ - The ``PluggableAuthenticationMiddleware`` constructor accepts a ``log_stream`` argument, which is typically a file. After this release, it can also be a PEP 333 ``Logger`` instance; if it is a PEP 333 ``Logger`` instance, this logger will be used as the repoze.who logger (instead of one being constructed by the middleware, as was previously always the case). When the ``log_stream`` argument is a PEP 333 Logger object, the ``log_level`` argument is ignored. 1.0.7 (2008-08-28) ------------------ - ``repoze.who`` and ``repoze.who.plugins`` were not added to the ``namespace_packages`` list in setup.py, potentially making 1.0.6 a brownbag release, given that making these packages namespace packages was the only reason for its release. 1.0.6 (2008-08-28) ------------------ - Make repoze.who and repoze.who.plugins into namespace packages mainly so we can allow plugin authors to distribute packages in the repoze.who.plugins namespace. 1.0.5 (2008-08-23) ------------------ - Fix auth_tkt plugin to set the same cookies in its ``remember`` method that it does in its ``forget`` method. Previously, logging out and relogging back in to a site that used auth_tkt identifier plugin was slightly dicey and would only work sometimes. - The FormPlugin plugin has grown a redirect-on-unauthorized feature. Any response from a downstream application that causes a challenge and includes a Location header will cause a redirect to the value of the Location header. 1.0.4 (2008-08-22) ------------------ - Added a key to the '[general]' config section: ``remote_user_key``. If you use this key in the config file, it tells who to 1) not perform any authentication if it exists in the environment during ingress and 2) to set the key in the environment for the downstream app to use as the REMOTE_USER variable. The default is ``REMOTE_USER``. - Using unicode user ids in combination with the auth_tkt plugin would cause problems under mod_wsgi. - Allowed 'cookie_path' argument to InsecureCookiePlugin (and config constructor). Thanks to Gustavo Narea. 1.0.3 (2008-08-16) ------------------ - A bug in the middleware's ``authenticate`` method made it impossible to authenticate a user with a userid that was null (e.g. 0, False), which are valid identifiers. The only invalid userid is now None. - Applied patch from Olaf Conradi which logs an error when an invalid filename is passed to the HTPasswdPlugin. 1.0.2 (2008-06-16) ------------------ - Fix bug found by Chris Perkins: the auth_tkt plugin's "remember" method didn't handle userids which are Python "long" instances properly. Symptom: TypeError: cannot concatenate 'str' and 'long' objects in "paste.auth.auth_tkt". - Added predicate-based "restriction" middleware support (repoze.who.restrict), allowing configuratio-driven authorization as a WSGI filter. One example predicate, 'authenticated_predicate', is supplied, which requires that the user be authenticated either via 'REMOTE_USER' or via 'repoze.who.identity'. To use the filter to restrict access:: [filter:authenticated_only] use = egg:repoze.who#authenticated or:: [filter:some_predicate] use = egg:repoze.who#predicate predicate = my.module:some_predicate some_option = a value 1.0.1 (2008-05-24) ------------------ - Remove dependency-link to dist.repoze.org to prevent easy_install from inserting that path into its search paths (the dependencies are available from PyPI). 1.0 (2008-05-04) ----------------- - The plugin at plugins.form.FormPlugin didn't redirect properly after collecting identification information. Symptom: a downstream app would receive a POST request with a blank body, which would sometimes result in a Bad Request error. - Fixed interface declarations of 'classifiers.default_request_classifier' and 'classifiers.default_password_compare'. - Added actual config-driven middleware factory, 'config.make_middleware_with_config' - Removed fossilized 'who_conf' argument from plugin factory functions. - Added ConfigParser-based WhoConfig, implementing the spec outlined at http://www.plope.com/static/misc/sphinxtest/intro.html#middleware-configuration-via-config-file, with the following changes: - "Bare" plugins (requiring no configuration options) may be specified as either egg entry points (e.g., 'egg:distname#entry_point_name') or as dotted-path-with-colon (e.g., 'dotted.name:object_id'). - Therefore, the separator between a plugin and its classifier is now a semicolon, rather than a colon. E.g.:: [plugins:id_plugin] use = egg:another.package#identify_with_frobnatz frobnatz = baz [identifiers] plugins = egg:my.egg#identify;browser dotted.name:identifier id_plugin 0.9.1 (2008-04-27) ------------------ - Fix auth_tkt plugin to be able to encode and decode integer user ids. 0.9 (2008-04-01) ---------------- - Fix bug introduced in FormPlugin in 0.8 release (rememberer headers not set). - Add PATH_INFO to started and ended log info. - Add a SQLMetadataProviderPlugin (in plugins/sql). - Change constructor of SQLAuthenticatorPlugin: it now accepts only "query", "conn_factory", and "compare_fn". The old constructor accepted a DSN, but some database systems don't use DBAPI DSNs. The new constructor accepts no DSN; the conn_factory is assumed to do all the work to make a connection, including knowing the DSN if one is required. The "conn_factory" should return something that, when called with no arguments, returns a database connection. - The "make_plugin" helper in plugins/sql has been renamed "make_authenticator_plugin". When called, this helper will return a SQLAuthenticatorPlugin. A bit of helper logic in the "make_authenticator_plugin" allows a connection factory to be computed. The top-level callable referred to by conn_factory in this helper should return a function that, when called with no arguments, returns a datbase connection. The top-level callable itself is called with "who_conf" (global who configuration) and any number of non-top-level keyword arguments as they are passed into the helper, to allow for a DSN or URL or whatever to be passed in. - A "make_metatata_plugin" helper has been added to plugins/sql. When called, this will make a SQLMetadataProviderPlugin. See the implementation for details. It is similar to the "make_authenticator_plugin" helper. 0.8 (2008-03-27) ---------------- - Add a RedirectingFormIdentifier plugin. This plugin is willing to redirect to an external (or downstream application) login form to perform identification. The external login form must post to the "login_handler_path" of the plugin (optimally with a "came_from" value to tell the plugin where to redirect the response to if the authentication works properly). The "logout_handler_path" of this plugin can be visited to perform a logout. The "came_from" value also works there. - Identifier plugins are now permitted to set a key in the environment named 'repoze.who.application' on ingress (in 'identify'). If an identifier plugin does so, this application is used instead of the "normal" downstream application. This feature was added to more simply support the redirecting form identifier plugin. 0.7 (2008-03-26) ---------------- - Change the IMetadataProvider interface: this interface used to have a "metadata" method which returned a dictionary. This method is not part of that API anymore. It's been replaced with an "add_metadata" method which has the signature:: def add_metadata(environ, identity): """ Add metadata to the identity (which is a dictionary) """ The return value is ignored. IMetadataProvider plugins are now assumed to be responsible for 'scribbling' directly on the identity that is passed in (it's a dictionary). The user id can always be retrieved from the identity via identity['repoze.who.userid'] for metadata plugins that rely on that value. 0.6 (2008-03-20) ---------------- - Renaming: repoze.pam is now repoze.who - Bump ez_setup.py version. - Add IMetadataProvider plugin type. Chris says 'Whit rules'. 0.5 (2008-03-09) ---------------- - Allow "remote user key" (default: REMOTE_USER) to be overridden (pass in remote_user_key to middleware constructor). - Allow form plugin to override the default form. - API change: IIdentifiers are no longer required to put both 'login' and 'password' in a returned identity dictionary. Instead, an IIdentifier can place arbitrary key/value pairs in the identity dictionary (or return an empty dictionary). - API return value change: the "failure" identity which IIdentifiers return is now None rather than an empty dictionary. - The IAuthenticator interface now specifies that IAuthenticators must not raise an exception when evaluating an identity that does not have "expected" key/value pairs (e.g. when an IAuthenticator that expects login and password inspects an identity returned by an IP-based auth system which only puts the IP address in the identity); instead they fail gracefully by returning None. - Add (cookie) "auth_tkt" identification plugin. - Stamp identity dictionaries with a userid by placing a key named 'repoze.pam.userid' into the identity for each authenticated identity. - If an IIdentifier plugin inserts a 'repoze.pam.userid' key into the identity dictionary, consider this identity "preauthenticated". No authenticator plugins will be asked to authenticate this identity. This is designed for things like the recently added auth_tkt plugin, which embeds the user id into the ticket. This effectively alllows an IIdentifier plugin to become an IAuthenticator plugin when breaking apart the responsibility into two separate plugins is "make-work". Preauthenticated identities will be selected first when deciding which identity to use for any given request. - Insert a 'repoze.pam.identity' key into the WSGI environment on ingress if an identity is found. Its value will be the identity dictionary related to the identity selected by repoze.pam on ingress. Downstream consumers are allowed to mutate this dictionary; this value is passed to "remember" and "forget", so its main use is to do a "credentials reset"; e.g. a user has changed his username or password within the application, but we don't want to force him to log in again after he does so. 0.4 (03-07-2008) ---------------- - Allow plugins to specify a classifiers list per interface (instead of a single classifiers list per plugin). 0.3 (03-05-2008) ---------------- - Make SQLAuthenticatorPlugin's default_password_compare use hexdigest sha instead of base64'ed binary sha for simpler conversion. 0.2 (03-04-2008) ---------------- - Added SQLAuthenticatorPlugin (see plugins/sql.py). 0.1 (02-27-2008) ---------------- - Initial release (no configuration file support yet). Keywords: web application server wsgi zope Platform: UNKNOWN Classifier: Intended Audience :: Developers Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.6 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.2 Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Internet :: WWW/HTTP Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content Classifier: Topic :: Internet :: WWW/HTTP :: WSGI Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application repoze.who-2.2/tox.ini0000644000175000017500000000146012136473266014707 0ustar tseavertseaver[tox] envlist = py26,py27,py32,py33,pypy,cover,docs [testenv] commands = python setup.py test -q deps = zope.interface WebOb virtualenv [testenv:cover] basepython = python2.6 commands = nosetests --with-xunit --with-xcoverage deps = zope.interface WebOb virtualenv nose coverage nosexcover # we separate coverage into its own testenv because a) "last run wins" wrt # cobertura jenkins reporting and b) pypy and jython can't handle any # combination of versions of coverage and nosexcover that i can find. [testenv:docs] basepython = python2.6 commands = sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest deps = Sphinx repoze.sphinx.autointerface repoze.who-2.2/.gitignore0000644000175000017500000000012312127573122015347 0ustar tseavertseaver*.pyc *.egg-info .coverage docs/.build .tox coverage.xml nosetests.xml docs/_build repoze.who-2.2/setup.py0000664000175000017500000000560412145524232015102 0ustar tseavertseaver############################################################################## # # Copyright (c) 2007-2009 Agendaless Consulting and Contributors. # All Rights Reserved. # # This software is subject to the provisions of the BSD-like license at # http://www.repoze.org/LICENSE.txt. A copy of the license should accompany # this distribution. THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL # EXPRESS OR IMPLIED WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, # THE IMPLIED WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND # FITNESS FOR A PARTICULAR PURPOSE # ############################################################################## import os from setuptools import setup, find_packages here = os.path.abspath(os.path.dirname(__file__)) README = open(os.path.join(here, 'README.rst')).read() CHANGES = open(os.path.join(here, 'CHANGES.rst')).read() tests_require = ['WebOb', 'zope.interface'] testing_extras = tests_require + ['nose', 'coverage'] docs_extras = tests_require + ['Sphinx', 'repoze.sphinx.autointerface'] setup(name='repoze.who', version='2.2', description=('repoze.who is an identification and authentication ' 'framework for WSGI.'), long_description='\n\n'.join([README, CHANGES]), classifiers=[ "Intended Audience :: Developers", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", ], keywords='web application server wsgi zope', author="Agendaless Consulting", author_email="repoze-dev@lists.repoze.org", url="http://www.repoze.org", license="BSD-derived (http://www.repoze.org/LICENSE.txt)", packages=find_packages(), include_package_data=True, namespace_packages=['repoze', 'repoze.who', 'repoze.who.plugins'], zip_safe=False, tests_require = tests_require, install_requires=['WebOb', 'zope.interface', 'setuptools'], test_suite="repoze.who", entry_points = """\ [paste.filter_app_factory] test = repoze.who.middleware:make_test_middleware config = repoze.who.config:make_middleware_with_config predicate = repoze.who.restrict:make_predicate_restriction authenticated = repoze.who.restrict:make_authenticated_restriction """, extras_require = { 'testing': testing_extras, 'docs': docs_extras, }, ) repoze.who-2.2/.bzrignore0000644000175000017500000000004311564772531015373 0ustar tseavertseaver.coverage *.egg-info docs/.build/* repoze.who-2.2/repoze/0000775000175000017500000000000012145723513014672 5ustar tseavertseaverrepoze.who-2.2/repoze/__init__.py0000644000175000017500000000011111530747412016773 0ustar tseavertseaver# repoze package __import__('pkg_resources').declare_namespace(__name__) repoze.who-2.2/repoze/who/0000775000175000017500000000000012145723513015467 5ustar tseavertseaverrepoze.who-2.2/repoze/who/_compat.py0000664000175000017500000000741712136471603017474 0ustar tseavertseavertry: STRING_TYPES = (str, unicode) except NameError: #pragma NO COVER Python >= 3.0 STRING_TYPES = (str,) try: u = unicode except NameError: #pragma NO COVER Python >= 3.0 u = str b = bytes else: #pragma NO COVER Python < 3.0 b = str import base64 if 'decodebytes' in base64.__dict__: #pragma NO COVER Python >= 3.0 decodebytes = base64.decodebytes encodebytes = base64.encodebytes def decodestring(value): return base64.decodebytes(bytes(value, 'ascii')).decode('ascii') def encodestring(value): return base64.encodebytes(bytes(value, 'ascii')).decode('ascii') else: #pragma NO COVER Python < 3.0 decodebytes = base64.decodestring encodebytes = base64.encodestring decodestring = base64.decodestring encodestring = base64.encodestring try: from urllib.parse import parse_qs except ImportError: #pragma NO COVER Python < 3.0 from cgi import parse_qs from cgi import parse_qsl else: #pragma NO COVER Python >= 3.0 from urllib.parse import parse_qsl try: from ConfigParser import SafeConfigParser except ImportError: #pragma NO COVER Python >= 3.0 from configparser import SafeConfigParser from configparser import ParsingError else: #pragma NO COVER Python < 3.0 from ConfigParser import ParsingError try: from Cookie import SimpleCookie except ImportError: #pragma NO COVER Python >= 3.0 from http.cookies import SimpleCookie from http.cookies import CookieError else: #pragma NO COVER Python < 3.0 from Cookie import CookieError try: from itertools import izip_longest except ImportError: #pragma NO COVER Python >= 3.0 from itertools import zip_longest as izip_longest try: from StringIO import StringIO except ImportError: #pragma NO COVER Python >= 3.0 from io import StringIO try: from urllib import urlencode except ImportError: #pragma NO COVER Python >= 3.0 from urllib.parse import urlencode from urllib.parse import quote as url_quote from urllib.parse import unquote as url_unquote else: #pragma NO COVER Python < 3.0 from urllib import quote as url_quote from urllib import unquote as url_unquote try: from urlparse import urlparse except ImportError: #pragma NO COVER Python >= 3.0 from urllib.parse import urlparse from urllib.parse import urlunparse else: #pragma NO COVER Python < 3.0 from urlparse import urlunparse import wsgiref.util import wsgiref.headers def REQUEST_METHOD(environ): return environ['REQUEST_METHOD'] def CONTENT_TYPE(environ): return environ.get('CONTENT_TYPE', '') def USER_AGENT(environ): return environ.get('HTTP_USER_AGENT') def AUTHORIZATION(environ): return environ.get('HTTP_AUTHORIZATION', '') def get_cookies(environ): header = environ.get('HTTP_COOKIE', '') if 'paste.cookies' in environ: cookies, check_header = environ['paste.cookies'] if check_header == header: return cookies cookies = SimpleCookie() try: cookies.load(header) except CookieError: #pragma NO COVER (can't see how to provoke this) pass environ['paste.cookies'] = (cookies, header) return cookies def construct_url(environ): return wsgiref.util.request_uri(environ) def header_value(environ, key): headers = wsgiref.headers.Headers(environ) values = headers.get(key) if not values: return "" if isinstance(values, list): #pragma NO COVER can't be true under Py3k. return ",".join(values) else: return values def must_decode(value): if type(value) is b: try: return value.decode('utf-8') except UnicodeDecodeError: return value.decode('latin1') return value def must_encode(value): if type(value) is u: return value.encode('utf-8') return value repoze.who-2.2/repoze/who/__init__.py0000644000175000017500000000014111530747412017573 0ustar tseavertseaver# repoze.who package __import__('pkg_resources').declare_namespace(__name__) #pragma NO COVERAGE repoze.who-2.2/repoze/who/config.py0000644000175000017500000001665512136471606017324 0ustar tseavertseaver""" Configuration parser """ import logging from pkg_resources import EntryPoint import sys import warnings from repoze.who.api import APIFactory from repoze.who.interfaces import IAuthenticator from repoze.who.interfaces import IChallengeDecider from repoze.who.interfaces import IChallenger from repoze.who.interfaces import IIdentifier from repoze.who.interfaces import IMetadataProvider from repoze.who.interfaces import IPlugin from repoze.who.interfaces import IRequestClassifier from repoze.who.middleware import PluggableAuthenticationMiddleware from repoze.who._compat import StringIO from repoze.who._compat import SafeConfigParser from repoze.who._compat import ParsingError def _resolve(name): if name: return EntryPoint.parse('x=%s' % name).load(False) class WhoConfig: def __init__(self, here): self.here = here self.request_classifier = None self.challenge_decider = None self.plugins = {} self.identifiers = [] self.authenticators = [] self.challengers = [] self.mdproviders = [] self.remote_user_key = 'REMOTE_USER' def _makePlugin(self, name, iface, options=None): if options is None: options = {} obj = _resolve(name) if not iface.providedBy(obj): obj = obj(**options) return obj def _getPlugin(self, name, iface): obj = self.plugins.get(name) if obj is None: obj = self._makePlugin(name, iface) return obj def _parsePluginSequence(self, attr, proptext, iface): lines = proptext.split() for line in lines: if ';' in line: plugin_name, classifier = line.split(';') else: plugin_name = line classifier = None plugin = self._getPlugin(plugin_name, iface) if classifier is not None: classifications = getattr(plugin, 'classifications', None) if classifications is None: classifications = plugin.classifications = {} classifications[iface] = classifier attr.append((plugin_name, plugin)) def parse(self, text): if getattr(text, 'readline', None) is None: text = StringIO(text) cp = SafeConfigParser(defaults={'here': self.here}) try: cp.read_file(text) except AttributeError: #pragma NO COVER Python < 3.0 cp.readfp(text) for s_id in [x for x in cp.sections() if x.startswith('plugin:')]: plugin_id = s_id[len('plugin:'):] options = dict(cp.items(s_id)) if 'use' in options: name = options.pop('use') del options['here'] obj = self._makePlugin(name, IPlugin, options) self.plugins[plugin_id] = obj if 'general' in cp.sections(): general = dict(cp.items('general')) rc = general.get('request_classifier') if rc is not None: rc = self._getPlugin(rc, IRequestClassifier) self.request_classifier = rc cd = general.get('challenge_decider') if cd is not None: cd = self._getPlugin(cd, IChallengeDecider) self.challenge_decider = cd ru = general.get('remote_user_key') if ru is not None: self.remote_user_key = ru if 'identifiers' in cp.sections(): identifiers = dict(cp.items('identifiers')) self._parsePluginSequence(self.identifiers, identifiers['plugins'], IIdentifier, ) if 'authenticators' in cp.sections(): authenticators = dict(cp.items('authenticators')) self._parsePluginSequence(self.authenticators, authenticators['plugins'], IAuthenticator, ) if 'challengers' in cp.sections(): challengers = dict(cp.items('challengers')) self._parsePluginSequence(self.challengers, challengers['plugins'], IChallenger, ) if 'mdproviders' in cp.sections(): mdproviders = dict(cp.items('mdproviders')) self._parsePluginSequence(self.mdproviders, mdproviders['plugins'], IMetadataProvider, ) class NullHandler(logging.Handler): def emit(self, record): pass _LEVELS = {'debug': logging.DEBUG, 'info': logging.INFO, 'warning': logging.WARNING, 'error': logging.ERROR, } def make_api_factory_with_config(global_conf, config_file, remote_user_key = 'REMOTE_USER', logger=None, ): identifiers = authenticators = challengers = mdproviders = () request_classifier = None challenge_decider = None parser = WhoConfig(global_conf['here']) try: opened = open(config_file) except IOError: warnings.warn('Non-existent who config file: %s' % config_file, stacklevel=2) else: try: try: parser.parse(opened) except ParsingError: warnings.warn('Invalid who config file: %s' % config_file, stacklevel=2) else: identifiers = parser.identifiers authenticators = parser.authenticators challengers = parser.challengers mdproviders = parser.mdproviders request_classifier = parser.request_classifier challenge_decider = parser.challenge_decider finally: opened.close() return APIFactory(identifiers, authenticators, challengers, mdproviders, request_classifier, challenge_decider, remote_user_key, logger, ) def make_middleware_with_config(app, global_conf, config_file, log_file=None, log_level=None): parser = WhoConfig(global_conf['here']) with open(config_file) as f: parser.parse(f) log_stream = None if log_level is None: log_level = logging.INFO elif not isinstance(log_level, int): log_level = _LEVELS[log_level.lower()] if log_file is not None: if log_file.lower() == 'stdout': log_stream = sys.stdout else: log_stream = open(log_file, 'wb') else: log_stream = logging.getLogger('repoze.who') log_stream.addHandler(NullHandler()) log_stream.setLevel(log_level or 0) return PluggableAuthenticationMiddleware( app, parser.identifiers, parser.authenticators, parser.challengers, parser.mdproviders, parser.request_classifier, parser.challenge_decider, log_stream, log_level, parser.remote_user_key, ) repoze.who-2.2/repoze/who/api.py0000644000175000017500000003405511731706026016617 0ustar tseavertseaverfrom zope.interface import implementer from repoze.who.interfaces import IAPI from repoze.who.interfaces import IAPIFactory from repoze.who.interfaces import IIdentifier from repoze.who.interfaces import IAuthenticator from repoze.who.interfaces import IChallenger from repoze.who.interfaces import IMetadataProvider def get_api(environ): return environ.get('repoze.who.api') @implementer(IAPIFactory) class APIFactory(object): def __init__(self, identifiers=(), authenticators=(), challengers=(), mdproviders=(), request_classifier=None, challenge_decider=None, remote_user_key = 'REMOTE_USER', logger=None, ): self.identifiers = identifiers self.authenticators = authenticators self.challengers = challengers self.mdproviders = mdproviders self.request_classifier = request_classifier self.challenge_decider = challenge_decider self.remote_user_key = remote_user_key self.logger = logger def __call__(self, environ): """ See IAPIFactory. """ api = environ.get('repoze.who.api') if api is None: api = environ['repoze.who.api'] = API(environ, self.identifiers, self.authenticators, self.challengers, self.mdproviders, self.request_classifier, self.challenge_decider, self.remote_user_key, self.logger, ) return api def verify(plugin, iface): from zope.interface.verify import verifyObject verifyObject(iface, plugin, tentative=True) def make_registries(identifiers, authenticators, challengers, mdproviders): from zope.interface.verify import BrokenImplementation interface_registry = {} name_registry = {} for supplied, iface in [ (identifiers, IIdentifier), (authenticators, IAuthenticator), (challengers, IChallenger), (mdproviders, IMetadataProvider)]: for name, value in supplied: try: verify(value, iface) except BrokenImplementation as why: why = str(why) raise ValueError(str(name) + ': ' + why) L = interface_registry.setdefault(iface, []) L.append(value) name_registry[name] = value return interface_registry, name_registry def match_classification(iface, plugins, classification): result = [] for plugin in plugins: plugin_classifications = getattr(plugin, 'classifications', {}) iface_classifications = plugin_classifications.get(iface) if not iface_classifications: # good for any result.append(plugin) continue if classification in iface_classifications: result.append(plugin) return result @implementer(IAPI) class API(object): def __init__(self, environ, identifiers, authenticators, challengers, mdproviders, request_classifier, challenge_decider, remote_user_key, logger, ): self.environ = environ (self.interface_registry, self.name_registry) = make_registries(identifiers, authenticators, challengers, mdproviders) self.identifiers = identifiers self.authenticators = authenticators self.challengers = challengers self.mdproviders = mdproviders self.challenge_decider = challenge_decider self.remote_user_key = remote_user_key self.logger = logger classification = self.classification = (request_classifier and request_classifier(environ)) logger and logger.info('request classification: %s' % classification) def authenticate(self): ids = self._identify() # ids will be list of tuples: [ (IIdentifier, identity) ] if ids: auth_ids = self._authenticate(ids) # auth_ids will be a list of five-tuples in the form # ( (auth_rank, id_rank), authenticator, identifier, identity, # userid ) # # When sorted, its first element will represent the "best" # identity for this request. if auth_ids: auth_ids.sort() best = auth_ids[0] rank, authenticator, identifier, identity, userid = best identity = Identity(identity) # dont show contents at print identity['authenticator'] = authenticator identity['identifier'] = identifier # allow IMetadataProvider plugins to scribble on the identity self._add_metadata(identity) # add the identity to the environment; a downstream # application can mutate it to do an 'identity reset' # as necessary, e.g. identity['login'] = 'foo', # identity['password'] = 'bar' self.environ['repoze.who.identity'] = identity # set the REMOTE_USER self.environ[self.remote_user_key] = userid return identity self.logger and self.logger.info( 'no identities found, not authenticating') def challenge(self, status='403 Forbidden', app_headers=()): """ See IAPI. """ identity = self.environ.get('repoze.who.identity', {}) identifier = identity.get('identifier') logger = self.logger forget_headers = [] if identifier: id_forget_headers = identifier.forget(self.environ, identity) if id_forget_headers is not None: forget_headers.extend(id_forget_headers) logger and logger.info('forgetting via headers from %s: %s' % (identifier, forget_headers)) candidates = self.interface_registry.get(IChallenger, ()) logger and logger.debug('challengers registered: %s' % repr(candidates)) plugins = match_classification(IChallenger, candidates, self.classification) logger and logger.debug('challengers matched for ' 'classification "%s": %s' % (self.classification, plugins)) for plugin in plugins: app = plugin.challenge(self.environ, status, app_headers, forget_headers) if app is not None: # new WSGI application logger and logger.info( 'challenger plugin %s "challenge" returned an app' % ( plugin)) return app # signifies no challenge logger and logger.info('no challenge app returned') return None def remember(self, identity=None): """ See IAPI. """ headers = () if identity is None: identity = self.environ.get('repoze.who.identity', {}) identifier = identity.get('identifier') if identifier: got_headers = identifier.remember(self.environ, identity) if got_headers: headers = got_headers logger = self.logger logger and logger.info('remembering via headers from %s: %s' % (identifier, headers)) return headers def forget(self, identity=None): """ See IAPI. """ headers = () if identity is None: identity = self.environ.get('repoze.who.identity', {}) identifier = identity.get('identifier') if identifier: got_headers = identifier.forget(self.environ, identity) if got_headers: headers = got_headers logger = self.logger logger and logger.info('forgetting via headers from %s: %s' % (identifier, headers)) return headers def login(self, credentials, identifier_name=None): """ See IAPI. """ authenticated = identity = plugin = None headers = [] # Filter identifiers using 'identifier_name', if provided. if identifier_name is not None: identifiers = [(name, plugin) for name, plugin in self.identifiers if name == identifier_name] else: identifiers = self.identifiers # First pass: for each identifier, pretend that it was the source # of the credentials, and try to authenticate. for name, identifier in identifiers: authenticated = self._authenticate([(identifier, credentials)]) if authenticated: # and therefore can remember it rank, plugin, identifier, identity, userid = authenticated[0] break # Second pass to allow identifiers which passed on auth to participate # in remember / forget. for name, identifier in identifiers: if identity is not None: i_headers = identifier.remember(self.environ, identity) else: i_headers = identifier.forget(self.environ, None) if i_headers is not None: headers.extend(i_headers) return identity, headers def logout(self, identifier_name=None): """ See IAPI. """ authenticated = None headers = [] # Filter identifiers using 'identifier_name', if provided. if identifier_name is not None: identifiers = [(name, plugin) for name, plugin in self.identifiers if name == identifier_name] else: identifiers = self.identifiers for name, identifier in identifiers: headers.extend(identifier.forget(self.environ, None)) # we need to remove the identity for hybrid middleware/api usages to # work correctly: middleware calls ``remember`` unconditionally "on # the way out", and if an identity is found, competing login headers # will be set. if 'repoze.who.identity' in self.environ: del self.environ['repoze.who.identity'] return headers def _identify(self): """ See IAPI. """ logger = self.logger candidates = self.interface_registry.get(IIdentifier, ()) logger and self.logger.debug('identifier plugins registered: %s' % (candidates,)) plugins = match_classification(IIdentifier, candidates, self.classification) logger and self.logger.debug( 'identifier plugins matched for ' 'classification "%s": %s' % (self.classification, plugins)) results = [] for plugin in plugins: identity = plugin.identify(self.environ) if identity is not None: logger and logger.debug( 'identity returned from %s: %s' % (plugin, identity)) results.append((plugin, identity)) else: logger and logger.debug( 'no identity returned from %s (%s)' % (plugin, identity)) logger and logger.debug('identities found: %s' % (results,)) return results def _authenticate(self, identities): """ See IAPI. """ logger = self.logger candidates = self.interface_registry.get(IAuthenticator, []) logger and self.logger.debug('authenticator plugins registered: %s' % candidates) plugins = match_classification(IAuthenticator, candidates, self.classification) logger and self.logger.debug( 'authenticator plugins matched for ' 'classification "%s": %s' % (self.classification, plugins)) auth_rank = 0 results = [] for plugin in plugins: identifier_rank = 0 for identifier, identity in identities: userid = plugin.authenticate(self.environ, identity) if userid is not None: logger and logger.debug( 'userid returned from %s: "%s"' % (plugin, userid)) # stamp the identity with the userid identity['repoze.who.userid'] = userid rank = (auth_rank, identifier_rank) results.append( (rank, plugin, identifier, identity, userid) ) else: logger and logger.debug( 'no userid returned from %s: (%s)' % ( plugin, userid)) identifier_rank += 1 auth_rank += 1 logger and logger.debug('identities authenticated: %s' % (results,)) return results def _add_metadata(self, identity): """ See IAPI. """ candidates = self.interface_registry.get(IMetadataProvider, ()) plugins = match_classification(IMetadataProvider, candidates, self.classification) for plugin in plugins: plugin.add_metadata(self.environ, identity) class Identity(dict): """ dict subclass: prevent members from being rendered during print """ def __repr__(self): return '' % id(self) __str__ = __repr__ repoze.who-2.2/repoze/who/restrict.py0000644000175000017500000000222011731706026017672 0ustar tseavertseaver# Authorization middleware from pkg_resources import EntryPoint from repoze.who._compat import STRING_TYPES def authenticated_predicate(): def _predicate(environ): return 'REMOTE_USER' in environ or 'repoze.who.identity' in environ return _predicate class PredicateRestriction: def __init__(self, app, predicate, enabled=True, **kw): self.app = app self.enabled = enabled options = kw.copy() self.predicate = predicate(**options) def __call__(self, environ, start_response): if self.enabled: if not self.predicate(environ): start_response('401 Unauthorized', []) return [] return self.app(environ, start_response) def make_authenticated_restriction(app, global_config, enabled=True): return PredicateRestriction(app, authenticated_predicate, enabled) def make_predicate_restriction(app, global_config, predicate, enabled=True, **kw): if isinstance(predicate, STRING_TYPES): predicate = EntryPoint.parse('x=%s' % predicate).load(False) return PredicateRestriction(app, predicate, enabled, **kw) repoze.who-2.2/repoze/who/plugins/0000775000175000017500000000000012145723513017150 5ustar tseavertseaverrepoze.who-2.2/repoze/who/plugins/__init__.py0000644000175000017500000000015111530747412021255 0ustar tseavertseaver# repoze.who.plugins package __import__('pkg_resources').declare_namespace(__name__) #pragma NO COVERAGE repoze.who-2.2/repoze/who/plugins/sql.py0000644000175000017500000001023311731706026020316 0ustar tseavertseaverfrom zope.interface import implementer from repoze.who.interfaces import IAuthenticator from repoze.who.interfaces import IMetadataProvider def default_password_compare(cleartext_password, stored_password_hash): try: from hashlib import sha1 except ImportError: # Python < 2.5 #pragma NO COVERAGE from sha import new as sha1 #pragma NO COVERAGE # the stored password is stored as '{SHA}'. # or as a cleartext password (no {SHA} prefix) if stored_password_hash.startswith('{SHA}'): stored_password_hash = stored_password_hash[5:] if not isinstance(cleartext_password, type(b'')): cleartext_password = cleartext_password.encode('utf-8') digest = sha1(cleartext_password).hexdigest() else: digest = cleartext_password if stored_password_hash == digest: return True return False def make_psycopg_conn_factory(**kw): # convenience (I always seem to use Postgres) def conn_factory(): #pragma NO COVERAGE import psycopg2 #pragma NO COVERAGE return psycopg2.connect(kw['repoze.who.dsn']) #pragma NO COVERAGE return conn_factory #pragma NO COVERAGE @implementer(IAuthenticator) class SQLAuthenticatorPlugin: def __init__(self, query, conn_factory, compare_fn): # statement should be pyformat dbapi binding-style, e.g. # "select user_id, password from users where login=%(login)s" self.query = query self.conn_factory = conn_factory self.compare_fn = compare_fn or default_password_compare self.conn = None # IAuthenticator def authenticate(self, environ, identity): if not 'login' in identity: return None if not self.conn: self.conn = self.conn_factory() curs = self.conn.cursor() curs.execute(self.query, identity) result = curs.fetchone() curs.close() if result: user_id, password = result if self.compare_fn(identity['password'], password): return user_id @implementer(IMetadataProvider) class SQLMetadataProviderPlugin: def __init__(self, name, query, conn_factory, filter): self.name = name self.query = query self.conn_factory = conn_factory self.filter = filter self.conn = None # IMetadataProvider def add_metadata(self, environ, identity): if self.conn is None: self.conn = self.conn_factory() curs = self.conn.cursor() # can't use dots in names in python string formatting :-( identity['__userid'] = identity['repoze.who.userid'] curs.execute(self.query, identity) result = curs.fetchall() if self.filter: result = self.filter(result) curs.close() del identity['__userid'] identity[self.name] = result def make_authenticator_plugin(query=None, conn_factory=None, compare_fn=None, **kw): from repoze.who.utils import resolveDotted if query is None: raise ValueError('query must be specified') if conn_factory is None: raise ValueError('conn_factory must be specified') try: conn_factory = resolveDotted(conn_factory)(**kw) except Exception as why: raise ValueError('conn_factory could not be resolved: %s' % why) if compare_fn is not None: compare_fn = resolveDotted(compare_fn) return SQLAuthenticatorPlugin(query, conn_factory, compare_fn) def make_metadata_plugin(name=None, query=None, conn_factory=None, filter=None, **kw): from repoze.who.utils import resolveDotted if name is None: raise ValueError('name must be specified') if query is None: raise ValueError('query must be specified') if conn_factory is None: raise ValueError('conn_factory must be specified') try: conn_factory = resolveDotted(conn_factory)(**kw) except Exception as why: raise ValueError('conn_factory could not be resolved: %s' % why) if filter is not None: filter = resolveDotted(filter) return SQLMetadataProviderPlugin(name, query, conn_factory, filter) repoze.who-2.2/repoze/who/plugins/auth_tkt.py0000644000175000017500000002100111731706026021335 0ustar tseavertseaverimport datetime from codecs import utf_8_decode from codecs import utf_8_encode import os import time from zope.interface import implementer from repoze.who.interfaces import IIdentifier from repoze.who.interfaces import IAuthenticator from repoze.who._compat import get_cookies import repoze.who._auth_tkt as auth_tkt from repoze.who._compat import STRING_TYPES from repoze.who._compat import u _NOW_TESTING = None # unit tests can replace def _now(): #pragma NO COVERAGE if _NOW_TESTING is not None: return _NOW_TESTING return datetime.datetime.now() @implementer(IIdentifier, IAuthenticator) class AuthTktCookiePlugin(object): userid_type_decoders = {'int': int, 'unicode': lambda x: utf_8_decode(x)[0], } userid_type_encoders = {int: ('int', str), } try: userid_type_encoders[long] = ('int', str) except NameError: #pragma NO COVER Python >= 3.0 pass try: userid_type_encoders[unicode] = ('unicode', lambda x: utf_8_encode(x)[0]) except NameError: #pragma NO COVER Python >= 3.0 pass def __init__(self, secret, cookie_name='auth_tkt', secure=False, include_ip=False, timeout=None, reissue_time=None, userid_checker=None): self.secret = secret self.cookie_name = cookie_name self.include_ip = include_ip self.secure = secure if timeout and ( (not reissue_time) or (reissue_time > timeout) ): raise ValueError('When timeout is specified, reissue_time must ' 'be set to a lower value') self.timeout = timeout self.reissue_time = reissue_time self.userid_checker = userid_checker # IIdentifier def identify(self, environ): cookies = get_cookies(environ) cookie = cookies.get(self.cookie_name) if cookie is None or not cookie.value: return None if self.include_ip: remote_addr = environ['REMOTE_ADDR'] else: remote_addr = '0.0.0.0' try: timestamp, userid, tokens, user_data = auth_tkt.parse_ticket( self.secret, cookie.value, remote_addr) except auth_tkt.BadTicket: return None if self.timeout and ( (timestamp + self.timeout) < time.time() ): return None userid_typename = 'userid_type:' user_data_info = user_data.split('|') for datum in filter(None, user_data_info): if datum.startswith(userid_typename): userid_type = datum[len(userid_typename):] decoder = self.userid_type_decoders.get(userid_type) if decoder: userid = decoder(userid) environ['REMOTE_USER_TOKENS'] = tokens environ['REMOTE_USER_DATA'] = user_data environ['AUTH_TYPE'] = 'cookie' identity = {} identity['timestamp'] = timestamp identity['repoze.who.plugins.auth_tkt.userid'] = userid identity['tokens'] = tokens identity['userdata'] = user_data return identity # IIdentifier def forget(self, environ, identity): # return a set of expires Set-Cookie headers return self._get_cookies(environ, 'INVALID', 0) # IIdentifier def remember(self, environ, identity): if self.include_ip: remote_addr = environ['REMOTE_ADDR'] else: remote_addr = '0.0.0.0' cookies = get_cookies(environ) old_cookie = cookies.get(self.cookie_name) existing = cookies.get(self.cookie_name) old_cookie_value = getattr(existing, 'value', None) max_age = identity.get('max_age', None) timestamp, userid, tokens, userdata = None, '', (), '' if old_cookie_value: try: timestamp,userid,tokens,userdata = auth_tkt.parse_ticket( self.secret, old_cookie_value, remote_addr) except auth_tkt.BadTicket: pass tokens = tuple(tokens) who_userid = identity['repoze.who.userid'] who_tokens = tuple(identity.get('tokens', ())) who_userdata = identity.get('userdata', '') encoding_data = self.userid_type_encoders.get(type(who_userid)) if encoding_data: encoding, encoder = encoding_data who_userid = encoder(who_userid) # XXX we are discarding the userdata passed in the identity? who_userdata = 'userid_type:%s' % encoding old_data = (userid, tokens, userdata) new_data = (who_userid, who_tokens, who_userdata) if old_data != new_data or (self.reissue_time and ( (timestamp + self.reissue_time) < time.time() )): ticket = auth_tkt.AuthTicket( self.secret, who_userid, remote_addr, tokens=who_tokens, user_data=who_userdata, cookie_name=self.cookie_name, secure=self.secure) new_cookie_value = ticket.cookie_value() if old_cookie_value != new_cookie_value: # return a set of Set-Cookie headers return self._get_cookies(environ, new_cookie_value, max_age) # IAuthenticator def authenticate(self, environ, identity): userid = identity.get('repoze.who.plugins.auth_tkt.userid') if userid is None: return None if self.userid_checker and not self.userid_checker(userid): return None identity['repoze.who.userid'] = userid return userid def _get_cookies(self, environ, value, max_age=None): if max_age is not None: max_age = int(max_age) later = _now() + datetime.timedelta(seconds=max_age) # Wdy, DD-Mon-YY HH:MM:SS GMT expires = later.strftime('%a, %d %b %Y %H:%M:%S') # the Expires header is *required* at least for IE7 (IE7 does # not respect Max-Age) max_age = "; Max-Age=%s; Expires=%s" % (max_age, expires) else: max_age = '' secure = '' if self.secure: secure = '; secure; HttpOnly' cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME')) cur_domain = cur_domain.split(':')[0] # drop port wild_domain = '.' + cur_domain cookies = [ ('Set-Cookie', '%s="%s"; Path=/%s%s' % ( self.cookie_name, value, max_age, secure)), ('Set-Cookie', '%s="%s"; Path=/; Domain=%s%s%s' % ( self.cookie_name, value, cur_domain, max_age, secure)), ('Set-Cookie', '%s="%s"; Path=/; Domain=%s%s%s' % ( self.cookie_name, value, wild_domain, max_age, secure)) ] return cookies def __repr__(self): return '<%s %s>' % (self.__class__.__name__, id(self)) #pragma NO COVERAGE def _bool(value): if isinstance(value, STRING_TYPES): return value.lower() in ('yes', 'true', '1') return value def make_plugin(secret=None, secretfile=None, cookie_name='auth_tkt', secure=False, include_ip=False, timeout=None, reissue_time=None, userid_checker=None, ): from repoze.who.utils import resolveDotted if (secret is None and secretfile is None): raise ValueError("One of 'secret' or 'secretfile' must not be None.") if (secret is not None and secretfile is not None): raise ValueError("Specify only one of 'secret' or 'secretfile'.") if secretfile: secretfile = os.path.abspath(os.path.expanduser(secretfile)) if not os.path.exists(secretfile): raise ValueError("No such 'secretfile': %s" % secretfile) with open(secretfile) as f: secret = f.read().strip() if timeout: timeout = int(timeout) if reissue_time: reissue_time = int(reissue_time) if userid_checker is not None: userid_checker = resolveDotted(userid_checker) plugin = AuthTktCookiePlugin(secret, cookie_name, _bool(secure), _bool(include_ip), timeout, reissue_time, userid_checker, ) return plugin repoze.who-2.2/repoze/who/plugins/tests/0000775000175000017500000000000012145723513020312 5ustar tseavertseaverrepoze.who-2.2/repoze/who/plugins/tests/test_basicauth.py0000644000175000017500000001243411731706026023670 0ustar tseavertseaverimport unittest class TestBasicAuthPlugin(unittest.TestCase): def _getTargetClass(self): from repoze.who.plugins.basicauth import BasicAuthPlugin return BasicAuthPlugin def _makeOne(self, *arg, **kw): plugin = self._getTargetClass()(*arg, **kw) return plugin def failUnless(self, predicate, message=''): self.assertTrue(predicate, message) # Nannies go home! def failIf(self, predicate, message=''): self.assertFalse(predicate, message) # Nannies go home! def _makeEnviron(self, kw=None): from wsgiref.util import setup_testing_defaults environ = {} setup_testing_defaults(environ) if kw is not None: environ.update(kw) return environ def test_implements(self): from zope.interface.verify import verifyClass from repoze.who.interfaces import IChallenger from repoze.who.interfaces import IIdentifier klass = self._getTargetClass() verifyClass(IChallenger, klass) verifyClass(IIdentifier, klass) def test_challenge(self): plugin = self._makeOne('realm') environ = self._makeEnviron() result = plugin.challenge(environ, '401 Unauthorized', [], []) self.assertNotEqual(result, None) app_iter = result(environ, lambda *arg: None) items = [] for item in app_iter: items.append(item) response = b''.join(items).decode('utf-8') self.failUnless(response.startswith('401 Unauthorized')) def test_identify_noauthinfo(self): plugin = self._makeOne('realm') environ = self._makeEnviron() creds = plugin.identify(environ) self.assertEqual(creds, None) def test_identify_nonbasic(self): plugin = self._makeOne('realm') environ = self._makeEnviron({'HTTP_AUTHORIZATION':'Digest abc'}) creds = plugin.identify(environ) self.assertEqual(creds, None) def test_identify_basic_badencoding(self): plugin = self._makeOne('realm') environ = self._makeEnviron({'HTTP_AUTHORIZATION':'Basic abc'}) creds = plugin.identify(environ) self.assertEqual(creds, None) def test_identify_basic_badrepr(self): from repoze.who._compat import encodebytes plugin = self._makeOne('realm') value = encodebytes(b'foo').decode('ascii') environ = self._makeEnviron({'HTTP_AUTHORIZATION':'Basic %s' % value}) creds = plugin.identify(environ) self.assertEqual(creds, None) def test_identify_basic_ok(self): from repoze.who._compat import encodebytes plugin = self._makeOne('realm') value = encodebytes(b'foo:bar').decode('ascii') environ = self._makeEnviron({'HTTP_AUTHORIZATION':'Basic %s' % value}) creds = plugin.identify(environ) self.assertEqual(creds, {'login':'foo', 'password':'bar'}) def test_identify_basic_ok_utf8_values(self): from repoze.who._compat import encodebytes LOGIN = b'b\xc3\xa2tard' PASSWD = b'l\xc3\xa0 demain' plugin = self._makeOne('realm') value = encodebytes(b':'.join((LOGIN, PASSWD))).decode('ascii') environ = self._makeEnviron({'HTTP_AUTHORIZATION':'Basic %s' % value}) creds = plugin.identify(environ) self.assertEqual(creds, {'login': LOGIN.decode('utf-8'), 'password': PASSWD.decode('utf-8')}) def test_identify_basic_ok_latin1_values(self): from repoze.who._compat import encodebytes LOGIN = b'b\xe2tard' PASSWD = b'l\xe0 demain' plugin = self._makeOne('realm') value = encodebytes(b':'.join((LOGIN, PASSWD))).decode('ascii') environ = self._makeEnviron({'HTTP_AUTHORIZATION':'Basic %s' % value}) creds = plugin.identify(environ) self.assertEqual(creds, {'login': LOGIN.decode('latin1'), 'password': PASSWD.decode('latin1')}) def test_remember(self): plugin = self._makeOne('realm') creds = {} environ = self._makeEnviron() result = plugin.remember(environ, creds) self.assertEqual(result, None) def test_forget(self): plugin = self._makeOne('realm') creds = {'login':'foo', 'password':'password'} environ = self._makeEnviron() result = plugin.forget(environ, creds) self.assertEqual(result, [('WWW-Authenticate', 'Basic realm="realm"')] ) def test_challenge_forgetheaders_includes(self): plugin = self._makeOne('realm') creds = {'login':'foo', 'password':'password'} environ = self._makeEnviron() forget = plugin._get_wwwauth() result = plugin.challenge(environ, '401 Unauthorized', [], forget) self.assertTrue(forget[0] in result.headers.items()) def test_challenge_forgetheaders_omits(self): plugin = self._makeOne('realm') creds = {'login':'foo', 'password':'password'} environ = self._makeEnviron() forget = plugin._get_wwwauth() result = plugin.challenge(environ, '401 Unauthorized', [], []) self.assertTrue(forget[0] in result.headers.items()) def test_factory(self): from repoze.who.plugins.basicauth import make_plugin plugin = make_plugin('realm') self.assertEqual(plugin.realm, 'realm') repoze.who-2.2/repoze/who/plugins/tests/__init__.py0000644000175000017500000000001111530747412022412 0ustar tseavertseaver#package repoze.who-2.2/repoze/who/plugins/tests/test_sql.py0000644000175000017500000002271511731706026022527 0ustar tseavertseaverimport unittest class _Base(unittest.TestCase): def failUnless(self, predicate, message=''): self.assertTrue(predicate, message) # Nannies go home! def failIf(self, predicate, message=''): self.assertFalse(predicate, message) # Nannies go home! class TestSQLAuthenticatorPlugin(_Base): def _getTargetClass(self): from repoze.who.plugins.sql import SQLAuthenticatorPlugin return SQLAuthenticatorPlugin def _makeOne(self, *arg, **kw): plugin = self._getTargetClass()(*arg, **kw) return plugin def _makeEnviron(self, kw=None): environ = {} environ['wsgi.version'] = (1,0) if kw is not None: environ.update(kw) return environ def test_implements(self): from zope.interface.verify import verifyClass from repoze.who.interfaces import IAuthenticator klass = self._getTargetClass() verifyClass(IAuthenticator, klass, tentative=True) def test_authenticate_noresults(self): dummy_factory = DummyConnectionFactory([]) plugin = self._makeOne('select foo from bar', dummy_factory, compare_succeed) environ = self._makeEnviron() identity = {'login':'foo', 'password':'bar'} result = plugin.authenticate(environ, identity) self.assertEqual(result, None) self.assertEqual(dummy_factory.query, 'select foo from bar') self.assertEqual(dummy_factory.closed, True) def test_authenticate_comparefail(self): dummy_factory = DummyConnectionFactory([ ['userid', 'password'] ]) plugin = self._makeOne('select foo from bar', dummy_factory, compare_fail) environ = self._makeEnviron() identity = {'login':'fred', 'password':'bar'} result = plugin.authenticate(environ, identity) self.assertEqual(result, None) self.assertEqual(dummy_factory.query, 'select foo from bar') self.assertEqual(dummy_factory.closed, True) def test_authenticate_comparesuccess(self): dummy_factory = DummyConnectionFactory([ ['userid', 'password'] ]) plugin = self._makeOne('select foo from bar', dummy_factory, compare_succeed) environ = self._makeEnviron() identity = {'login':'fred', 'password':'bar'} result = plugin.authenticate(environ, identity) self.assertEqual(result, 'userid') self.assertEqual(dummy_factory.query, 'select foo from bar') self.assertEqual(dummy_factory.closed, True) def test_authenticate_nologin(self): dummy_factory = DummyConnectionFactory([ ['userid', 'password'] ]) plugin = self._makeOne('select foo from bar', dummy_factory, compare_succeed) environ = self._makeEnviron() identity = {} result = plugin.authenticate(environ, identity) self.assertEqual(result, None) self.assertEqual(dummy_factory.query, None) self.assertEqual(dummy_factory.closed, False) class TestDefaultPasswordCompare(_Base): def _getFUT(self): from repoze.who.plugins.sql import default_password_compare return default_password_compare def _get_sha_hex_digest(self, clear='password'): try: from hashlib import sha1 except ImportError: from sha import new as sha1 if not isinstance(clear, type(b'')): clear = clear.encode('utf-8') return sha1(clear).hexdigest() def test_shaprefix_success(self): stored = '{SHA}' + self._get_sha_hex_digest() compare = self._getFUT() result = compare('password', stored) self.assertEqual(result, True) def test_shaprefix_w_unicode_cleartext(self): from repoze.who._compat import u stored = '{SHA}' + self._get_sha_hex_digest() compare = self._getFUT() result = compare(u('password'), stored) self.assertEqual(result, True) def test_shaprefix_fail(self): stored = '{SHA}' + self._get_sha_hex_digest() compare = self._getFUT() result = compare('notpassword', stored) self.assertEqual(result, False) def test_noprefix_success(self): stored = 'password' compare = self._getFUT() result = compare('password', stored) self.assertEqual(result, True) def test_noprefix_fail(self): stored = 'password' compare = self._getFUT() result = compare('notpassword', stored) self.assertEqual(result, False) class TestSQLMetadataProviderPlugin(_Base): def _getTargetClass(self): from repoze.who.plugins.sql import SQLMetadataProviderPlugin return SQLMetadataProviderPlugin def _makeOne(self, *arg, **kw): klass = self._getTargetClass() return klass(*arg, **kw) def test_implements(self): from zope.interface.verify import verifyClass from repoze.who.interfaces import IMetadataProvider klass = self._getTargetClass() verifyClass(IMetadataProvider, klass, tentative=True) def test_add_metadata(self): dummy_factory = DummyConnectionFactory([ [1, 2, 3] ]) def dummy_filter(results): return results plugin = self._makeOne('md', 'select foo from bar', dummy_factory, dummy_filter) environ = {} identity = {'repoze.who.userid':1} plugin.add_metadata(environ, identity) self.assertEqual(dummy_factory.closed, True) self.assertEqual(identity['md'], [ [1,2,3] ]) self.assertEqual(dummy_factory.query, 'select foo from bar') self.failIf('__userid' in identity) class TestMakeSQLAuthenticatorPlugin(_Base): def _getFUT(self): from repoze.who.plugins.sql import make_authenticator_plugin return make_authenticator_plugin def test_noquery(self): f = self._getFUT() self.assertRaises(ValueError, f, None, 'conn', 'compare') def test_no_connfactory(self): f = self._getFUT() self.assertRaises(ValueError, f, 'statement', None, 'compare') def test_bad_connfactory(self): f = self._getFUT() self.assertRaises(ValueError, f, 'statement', 'does.not:exist', None) def test_connfactory_specd(self): f = self._getFUT() plugin = f('statement', 'repoze.who.plugins.tests.test_sql:make_dummy_connfactory', None) self.assertEqual(plugin.query, 'statement') self.assertEqual(plugin.conn_factory, DummyConnFactory) from repoze.who.plugins.sql import default_password_compare self.assertEqual(plugin.compare_fn, default_password_compare) def test_comparefunc_specd(self): f = self._getFUT() plugin = f('statement', 'repoze.who.plugins.tests.test_sql:make_dummy_connfactory', 'repoze.who.plugins.tests.test_sql:make_dummy_connfactory') self.assertEqual(plugin.query, 'statement') self.assertEqual(plugin.conn_factory, DummyConnFactory) self.assertEqual(plugin.compare_fn, make_dummy_connfactory) class TestMakeSQLMetadataProviderPlugin(_Base): def _getFUT(self): from repoze.who.plugins.sql import make_metadata_plugin return make_metadata_plugin def test_no_name(self): f = self._getFUT() self.assertRaises(ValueError, f) def test_no_query(self): f = self._getFUT() self.assertRaises(ValueError, f, 'name', None, None) def test_no_connfactory(self): f = self._getFUT() self.assertRaises(ValueError, f, 'name', 'statement', None) def test_bad_connfactory(self): f = self._getFUT() self.assertRaises(ValueError, f, 'name', 'statement', 'does.not:exist', None) def test_connfactory_specd(self): f = self._getFUT() plugin = f('name', 'statement', 'repoze.who.plugins.tests.test_sql:make_dummy_connfactory', None) self.assertEqual(plugin.name, 'name') self.assertEqual(plugin.query, 'statement') self.assertEqual(plugin.conn_factory, DummyConnFactory) self.assertEqual(plugin.filter, None) def test_comparefn_specd(self): f = self._getFUT() plugin = f('name', 'statement', 'repoze.who.plugins.tests.test_sql:make_dummy_connfactory', 'repoze.who.plugins.tests.test_sql:make_dummy_connfactory') self.assertEqual(plugin.name, 'name') self.assertEqual(plugin.query, 'statement') self.assertEqual(plugin.conn_factory, DummyConnFactory) self.assertEqual(plugin.filter, make_dummy_connfactory) class DummyConnectionFactory: # acts as all of: a factory, a connection, and a cursor closed = False query = None def __init__(self, results): self.results = results def __call__(self): return self def cursor(self): return self def execute(self, query, *arg): self.query = query self.bindargs = arg def fetchall(self): return self.results def fetchone(self): if self.results: return self.results[0] return [] def close(self): self.closed = True def compare_fail(cleartext, stored): return False def compare_succeed(cleartext, stored): return True class _DummyConnFactory: pass DummyConnFactory = _DummyConnFactory() def make_dummy_connfactory(**kw): return DummyConnFactory repoze.who-2.2/repoze/who/plugins/tests/test_redirector.py0000644000175000017500000004023111731706026024063 0ustar tseavertseaverimport unittest class _Base(unittest.TestCase): def failUnless(self, predicate, message=''): self.assertTrue(predicate, message) # Nannies go home! def failIf(self, predicate, message=''): self.assertFalse(predicate, message) # Nannies go home! class TestRedirectorPlugin(_Base): def _getTargetClass(self): from repoze.who.plugins.redirector import RedirectorPlugin return RedirectorPlugin def _makeOne(self, login_url='http://example.com/login.html', came_from_param=None, reason_param=None, reason_header=None, ): return self._getTargetClass()(login_url, came_from_param=came_from_param, reason_param=reason_param, reason_header=reason_header) def _makeEnviron(self, login=None, password=None, came_from=None, path_info='/', identifier=None, max_age=None): from repoze.who._compat import StringIO fields = [] if login: fields.append(('login', login)) if password: fields.append(('password', password)) if came_from: fields.append(('came_from', came_from)) if max_age: fields.append(('max_age', max_age)) if identifier is None: credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials) content_type, body = encode_multipart_formdata(fields) environ = {'wsgi.version': (1,0), 'wsgi.input': StringIO(body), 'wsgi.url_scheme':'http', 'SERVER_NAME': 'www.example.com', 'SERVER_PORT': '80', 'CONTENT_TYPE': content_type, 'CONTENT_LENGTH': len(body), 'REQUEST_METHOD': 'POST', 'repoze.who.plugins': {'cookie':identifier}, 'QUERY_STRING': 'default=1', 'PATH_INFO': path_info, } return environ def test_class_conforms_to_IChallenger(self): from zope.interface.verify import verifyClass from repoze.who.interfaces import IChallenger verifyClass(IChallenger, self._getTargetClass()) def test_instance_conforms_to_IChallenger(self): from zope.interface.verify import verifyObject from repoze.who.interfaces import IChallenger verifyObject(IChallenger, self._makeOne()) def test_ctor_w_reason_param_wo_reason_header(self): self.assertRaises(ValueError, self._makeOne, reason_param='reason', reason_header=None) def test_ctor_wo_reason_param_w_reason_header(self): self.assertRaises(ValueError, self._makeOne, reason_param=None, reason_header='X-Reason') def test_challenge(self): from ..._compat import parse_qsl from ..._compat import urlparse plugin = self._makeOne(came_from_param='came_from', reason_param='reason', reason_header='X-Authorization-Failure-Reason', ) environ = self._makeEnviron() app = plugin.challenge(environ, '401 Unauthorized', [('app', '1')], [('forget', '1')]) sr = DummyStartResponse() result = b''.join(app(environ, sr)).decode('ascii') self.failUnless(result.startswith('302 Found')) self.assertEqual(sr.headers[0][0], 'forget') self.assertEqual(sr.headers[0][1], '1') self.assertEqual(sr.headers[1][0], 'Location') url = sr.headers[1][1] parts = urlparse(url) parts_qsl = parse_qsl(parts[4]) self.assertEqual(len(parts_qsl), 1) came_from_key, came_from_value = parts_qsl[0] self.assertEqual(parts[0], 'http') self.assertEqual(parts[1], 'example.com') self.assertEqual(parts[2], '/login.html') self.assertEqual(parts[3], '') self.assertEqual(came_from_key, 'came_from') self.assertEqual(came_from_value, 'http://www.example.com/?default=1') headers = sr.headers self.assertEqual(sr.headers[2][0], 'Content-Length') self.assertEqual(sr.headers[2][1], '165') self.assertEqual(sr.headers[3][0], 'Content-Type') self.assertEqual(sr.headers[3][1], 'text/plain; charset=UTF-8') self.assertEqual(sr.status, '302 Found') def test_challenge_with_reason_header(self): from ..._compat import parse_qsl from ..._compat import urlparse plugin = self._makeOne(came_from_param='came_from', reason_param='reason', reason_header='X-Authorization-Failure-Reason', ) environ = self._makeEnviron() app = plugin.challenge( environ, '401 Unauthorized', [('X-Authorization-Failure-Reason', 'you are ugly')], [('forget', '1')]) sr = DummyStartResponse() result = b''.join(app(environ, sr)).decode('ascii') self.failUnless(result.startswith('302 Found')) self.assertEqual(sr.headers[1][0], 'Location') url = sr.headers[1][1] parts = urlparse(url) parts_qsl = parse_qsl(parts[4]) self.assertEqual(len(parts_qsl), 2) parts_qsl.sort() came_from_key, came_from_value = parts_qsl[0] reason_key, reason_value = parts_qsl[1] self.assertEqual(parts[0], 'http') self.assertEqual(parts[1], 'example.com') self.assertEqual(parts[2], '/login.html') self.assertEqual(parts[3], '') self.assertEqual(came_from_key, 'came_from') self.assertEqual(came_from_value, 'http://www.example.com/?default=1') self.assertEqual(reason_key, 'reason') self.assertEqual(reason_value, 'you are ugly') def test_challenge_with_custom_reason_header(self): from ..._compat import parse_qsl from ..._compat import urlparse plugin = self._makeOne(came_from_param='came_from', reason_param='reason', reason_header='X-Custom-Auth-Failure', ) environ = self._makeEnviron() environ['came_from'] = 'http://example.com/came_from' app = plugin.challenge( environ, '401 Unauthorized', [('X-Authorization-Failure-Reason', 'you are ugly')], [('forget', '1')]) sr = DummyStartResponse() result = b''.join(app(environ, sr)).decode('ascii') self.failUnless(result.startswith('302 Found')) self.assertEqual(sr.headers[1][0], 'Location') url = sr.headers[1][1] parts = urlparse(url) parts_qsl = parse_qsl(parts[4]) self.assertEqual(len(parts_qsl), 1) came_from_key, came_from_value = parts_qsl[0] self.assertEqual(parts[0], 'http') self.assertEqual(parts[1], 'example.com') self.assertEqual(parts[2], '/login.html') self.assertEqual(parts[3], '') self.assertEqual(came_from_key, 'came_from') self.assertEqual(came_from_value, 'http://www.example.com/?default=1') def test_challenge_w_reason_no_reason_param_no_came_from_param(self): from ..._compat import parse_qsl from ..._compat import urlparse plugin = self._makeOne() environ = self._makeEnviron() app = plugin.challenge( environ, '401 Unauthorized', [('X-Authorization-Failure-Reason', 'you are ugly')], [('forget', '1')]) sr = DummyStartResponse() result = b''.join(app(environ, sr)).decode('ascii') self.failUnless(result.startswith('302 Found')) self.assertEqual(sr.headers[0][0], "forget") self.assertEqual(sr.headers[0][1], "1") self.assertEqual(sr.headers[1][0], 'Location') url = sr.headers[1][1] parts = urlparse(url) parts_qsl = parse_qsl(parts[4]) self.assertEqual(len(parts_qsl), 0) self.assertEqual(parts[0], 'http') self.assertEqual(parts[1], 'example.com') self.assertEqual(parts[2], '/login.html') self.assertEqual(parts[3], '') def test_challenge_w_reason_no_reason_param_w_came_from_param(self): from ..._compat import parse_qsl from ..._compat import urlparse plugin = self._makeOne(came_from_param='came_from', ) environ = self._makeEnviron() environ['came_from'] = 'http://example.com/came_from' app = plugin.challenge( environ, '401 Unauthorized', [('X-Authorization-Failure-Reason', 'you are ugly')], [('forget', '1')]) sr = DummyStartResponse() result = b''.join(app(environ, sr)).decode('ascii') self.failUnless(result.startswith('302 Found')) self.assertEqual(sr.headers[1][0], 'Location') url = sr.headers[1][1] parts = urlparse(url) parts_qsl = parse_qsl(parts[4]) self.assertEqual(len(parts_qsl), 1) came_from_key, came_from_value = parts_qsl[0] self.assertEqual(parts[0], 'http') self.assertEqual(parts[1], 'example.com') self.assertEqual(parts[2], '/login.html') self.assertEqual(parts[3], '') self.assertEqual(came_from_key, 'came_from') self.assertEqual(came_from_value, 'http://www.example.com/?default=1') def test_challenge_with_reason_and_custom_reason_param(self): from ..._compat import parse_qsl from ..._compat import urlparse plugin = self._makeOne(came_from_param='came_from', reason_param='auth_failure', reason_header='X-Custom-Auth-Failure', ) environ = self._makeEnviron() app = plugin.challenge( environ, '401 Unauthorized', [('X-Authorization-Failure-Reason', 'wrong reason'), ('X-Custom-Auth-Failure', 'you are ugly')], [('forget', '1')]) sr = DummyStartResponse() result = b''.join(app(environ, sr)).decode('ascii') self.failUnless(result.startswith('302 Found')) self.assertEqual(sr.headers[1][0], 'Location') url = sr.headers[1][1] parts = urlparse(url) parts_qsl = parse_qsl(parts[4]) self.assertEqual(len(parts_qsl), 2) parts_qsl.sort() reason_key, reason_value = parts_qsl[0] came_from_key, came_from_value = parts_qsl[1] self.assertEqual(parts[0], 'http') self.assertEqual(parts[1], 'example.com') self.assertEqual(parts[2], '/login.html') self.assertEqual(parts[3], '') self.assertEqual(came_from_key, 'came_from') self.assertEqual(came_from_value, 'http://www.example.com/?default=1') self.assertEqual(reason_key, 'auth_failure') self.assertEqual(reason_value, 'you are ugly') def test_challenge_wo_reason_w_came_from_param(self): from ..._compat import parse_qsl from ..._compat import urlparse plugin = self._makeOne(came_from_param='came_from') environ = self._makeEnviron() app = plugin.challenge( environ, '401 Unauthorized', [], [('forget', '1')]) sr = DummyStartResponse() result = b''.join(app(environ, sr)).decode('ascii') self.failUnless(result.startswith('302 Found')) self.assertEqual(sr.headers[1][0], 'Location') url = sr.headers[1][1] parts = urlparse(url) parts_qsl = parse_qsl(parts[4]) self.assertEqual(len(parts_qsl), 1) came_from_key, came_from_value = parts_qsl[0] self.assertEqual(parts[0], 'http') self.assertEqual(parts[1], 'example.com') self.assertEqual(parts[2], '/login.html') self.assertEqual(parts[3], '') self.assertEqual(came_from_key, 'came_from') self.assertEqual(came_from_value, 'http://www.example.com/?default=1') def test_challenge_with_setcookie_from_app(self): plugin = self._makeOne(came_from_param='came_from', reason_param='reason', reason_header='X-Authorization-Failure-Reason', ) environ = self._makeEnviron() app = plugin.challenge( environ, '401 Unauthorized', [('app', '1'), ('set-cookie','a'), ('set-cookie','b')], []) sr = DummyStartResponse() result = b''.join(app(environ, sr)).decode('ascii') self.failUnless(result.startswith('302 Found')) self.assertEqual(sr.headers[0][0], 'set-cookie') self.assertEqual(sr.headers[0][1], 'a') self.assertEqual(sr.headers[1][0], 'set-cookie') self.assertEqual(sr.headers[1][1], 'b') class Test_make_redirecting_plugin(_Base): def _callFUT(self, *args, **kw): from repoze.who.plugins.redirector import make_plugin return make_plugin(*args, **kw) def test_no_login_url_raises(self): self.assertRaises(ValueError, self._callFUT, None) def test_defaults(self): plugin = self._callFUT('/go_there') self.assertEqual(plugin.login_url, '/go_there') self.assertEqual(plugin.came_from_param, None) self.assertEqual(plugin.reason_param, None) self.assertEqual(plugin.reason_header, None) def test_explicit_came_from_param(self): plugin = self._callFUT('/go_there', came_from_param='whence') self.assertEqual(plugin.login_url, '/go_there') self.assertEqual(plugin.came_from_param, 'whence') self.assertEqual(plugin.reason_param, None) self.assertEqual(plugin.reason_header, None) def test_explicit_reason_param(self): plugin = self._callFUT('/go_there', reason_param='why') self.assertEqual(plugin.login_url, '/go_there') self.assertEqual(plugin.came_from_param, None) self.assertEqual(plugin.reason_param, 'why') self.assertEqual(plugin.reason_header, 'X-Authorization-Failure-Reason') def test_explicit_reason_header_param_no_reason_param_raises(self): self.assertRaises(Exception, self._callFUT, '/go_there', reason_header='X-Reason') def test_explicit_reason_header_param(self): plugin = self._callFUT('/go_there', reason_param='why', reason_header='X-Reason') self.assertEqual(plugin.login_url, '/go_there') self.assertEqual(plugin.came_from_param, None) self.assertEqual(plugin.reason_param, 'why') self.assertEqual(plugin.reason_header, 'X-Reason') class DummyIdentifier: forgotten = False remembered = False def __init__(self, credentials=None, remember_headers=None, forget_headers=None, replace_app=None): self.credentials = credentials self.remember_headers = remember_headers self.forget_headers = forget_headers self.replace_app = replace_app def identify(self, environ): if self.replace_app: environ['repoze.who.application'] = self.replace_app return self.credentials def forget(self, environ, identity): self.forgotten = identity return self.forget_headers def remember(self, environ, identity): self.remembered = identity return self.remember_headers class DummyStartResponse: def __call__(self, status, headers, exc_info=None): self.status = status self.headers = headers self.exc_info = exc_info return [] def encode_multipart_formdata(fields): BOUNDARY = '----------ThIs_Is_tHe_bouNdaRY_$' CRLF = '\r\n' L = [] for (key, value) in fields: L.append('--' + BOUNDARY) L.append('Content-Disposition: form-data; name="%s"' % key) L.append('') L.append(value) L.append('--' + BOUNDARY + '--') L.append('') body = CRLF.join(L) content_type = 'multipart/form-data; boundary=%s' % BOUNDARY return content_type, body repoze.who-2.2/repoze/who/plugins/tests/fixtures/0000775000175000017500000000000012145723513022163 5ustar tseavertseaverrepoze.who-2.2/repoze/who/plugins/tests/fixtures/test.htpasswd0000644000175000017500000000002411530747412024714 0ustar tseavertseaverbadline chrism:pass repoze.who-2.2/repoze/who/plugins/tests/fixtures/__init__.py0000644000175000017500000000002411530747412024267 0ustar tseavertseaver# this is a package repoze.who-2.2/repoze/who/plugins/tests/fixtures/testapp.py0000644000175000017500000000323711530747412024221 0ustar tseavertseaverdef tack_environ(environ, msg): import pprint penv = pprint.pformat(environ) return msg + '\n\n' + penv def deny(start_response, environ, msg): ct = 'text/plain' msg = tack_environ(environ, msg) cl = str(len(msg)) start_response('401 Unauthorized', [ ('Content-Type', ct), ('Content-Length', cl) ], ) def allow(start_response, environ, msg): ct = 'text/plain' msg = tack_environ(environ, msg) cl = str(len(msg)) start_response('200 OK', [ ('Content-Type', ct), ('Content-Length', cl) ], ) return [msg] def app(environ, start_response): path_info = environ['PATH_INFO'] remote_user = environ.get('REMOTE_USER') if path_info.endswith('/shared'): if not remote_user: return deny(start_response, environ, 'You cant do that') else: return allow(start_response, environ, 'Welcome to the shared area, %s' % remote_user) elif path_info.endswith('/admin'): if remote_user != 'admin': return deny(start_response, environ, 'Only admin can do that') else: return allow(start_response, environ, 'Hello, admin!') elif path_info.endswith('/chris'): if remote_user != 'chris': return deny(start_response, environ, 'Only chris can do that') else: return allow(start_response, environ, 'Hello, chris!') else: return allow(start_response, environ, 'Unprotected page') def make_app(global_config, **kw): return app repoze.who-2.2/repoze/who/plugins/tests/test_authtkt.py0000644000175000017500000006354311731706026023420 0ustar tseavertseaverimport unittest class TestAuthTktCookiePlugin(unittest.TestCase): tempdir = None _now_testing = None def setUp(self): pass def tearDown(self): if self.tempdir is not None: import shutil shutil.rmtree(self.tempdir) if self._now_testing is not None: self._setNowTesting(self._now_testing) def failUnless(self, predicate, message=''): self.assertTrue(predicate, message) # Nannies go home! def failIf(self, predicate, message=''): self.assertFalse(predicate, message) # Nannies go home! def _getTargetClass(self): from repoze.who.plugins.auth_tkt import AuthTktCookiePlugin return AuthTktCookiePlugin def _makeEnviron(self, kw=None): from wsgiref.util import setup_testing_defaults environ = {} setup_testing_defaults(environ) if kw is not None: environ.update(kw) environ['REMOTE_ADDR'] = '1.1.1.1' environ['HTTP_HOST'] = 'localhost' return environ def _makeOne(self, secret='s33kr3t', *arg, **kw): plugin = self._getTargetClass()(secret, *arg, **kw) return plugin def _makeTicket(self, userid='userid', remote_addr='0.0.0.0', tokens = [], userdata='userdata', cookie_name='auth_tkt', secure=False, time=None): #from paste.auth import auth_tkt import repoze.who._auth_tkt as auth_tkt ticket = auth_tkt.AuthTicket( 'secret', userid, remote_addr, tokens=tokens, user_data=userdata, time=time, cookie_name=cookie_name, secure=secure) return ticket.cookie_value() def _setNowTesting(self, value): from repoze.who.plugins import auth_tkt auth_tkt._NOW_TESTING, self._now_testing = value, auth_tkt._NOW_TESTING def test_class_conforms_to_IIdentifier(self): from zope.interface.verify import verifyClass from repoze.who.interfaces import IIdentifier klass = self._getTargetClass() verifyClass(IIdentifier, klass) def test_instance_conforms_to_IIdentifier(self): from zope.interface.verify import verifyObject from repoze.who.interfaces import IIdentifier klass = self._getTargetClass() verifyObject(IIdentifier, self._makeOne()) def test_class_conforms_to_IAuthenticator(self): from zope.interface.verify import verifyClass from repoze.who.interfaces import IAuthenticator klass = self._getTargetClass() verifyClass(IAuthenticator, klass) def test_instance_conforms_to_IAuthenticator(self): from zope.interface.verify import verifyObject from repoze.who.interfaces import IAuthenticator klass = self._getTargetClass() verifyObject(IAuthenticator, self._makeOne()) def test_timeout_no_reissue(self): self.assertRaises(ValueError, self._makeOne, 'userid', timeout=1) def test_timeout_lower_than_reissue(self): self.assertRaises(ValueError, self._makeOne, 'userid', timeout=1, reissue_time=2) def test_identify_nocookie(self): plugin = self._makeOne('secret') environ = self._makeEnviron() result = plugin.identify(environ) self.assertEqual(result, None) def test_identify_good_cookie_include_ip(self): plugin = self._makeOne('secret', include_ip=True) val = self._makeTicket(remote_addr='1.1.1.1') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % val}) result = plugin.identify(environ) self.assertEqual(len(result), 4) self.assertEqual(result['tokens'], ['']) self.assertEqual(result['repoze.who.plugins.auth_tkt.userid'], 'userid') self.assertEqual(result['userdata'], 'userdata') self.failUnless('timestamp' in result) self.assertEqual(environ['REMOTE_USER_TOKENS'], ['']) self.assertEqual(environ['REMOTE_USER_DATA'],'userdata') self.assertEqual(environ['AUTH_TYPE'],'cookie') def test_identify_good_cookie_dont_include_ip(self): plugin = self._makeOne('secret', include_ip=False) val = self._makeTicket() environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % val}) result = plugin.identify(environ) self.assertEqual(len(result), 4) self.assertEqual(result['tokens'], ['']) self.assertEqual(result['repoze.who.plugins.auth_tkt.userid'], 'userid') self.assertEqual(result['userdata'], 'userdata') self.failUnless('timestamp' in result) self.assertEqual(environ['REMOTE_USER_TOKENS'], ['']) self.assertEqual(environ['REMOTE_USER_DATA'],'userdata') self.assertEqual(environ['AUTH_TYPE'],'cookie') def test_identify_good_cookie_int_useridtype(self): plugin = self._makeOne('secret', include_ip=False) val = self._makeTicket(userid='1', userdata='userid_type:int') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % val}) result = plugin.identify(environ) self.assertEqual(len(result), 4) self.assertEqual(result['tokens'], ['']) self.assertEqual(result['repoze.who.plugins.auth_tkt.userid'], 1) self.assertEqual(result['userdata'], 'userid_type:int') self.failUnless('timestamp' in result) self.assertEqual(environ['REMOTE_USER_TOKENS'], ['']) self.assertEqual(environ['REMOTE_USER_DATA'],'userid_type:int') self.assertEqual(environ['AUTH_TYPE'],'cookie') def test_identify_good_cookie_unknown_useridtype(self): plugin = self._makeOne('secret', include_ip=False) val = self._makeTicket(userid='userid', userdata='userid_type:unknown') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % val}) result = plugin.identify(environ) self.assertEqual(len(result), 4) self.assertEqual(result['tokens'], ['']) self.assertEqual(result['repoze.who.plugins.auth_tkt.userid'], 'userid') self.assertEqual(result['userdata'], 'userid_type:unknown') self.failUnless('timestamp' in result) self.assertEqual(environ['REMOTE_USER_TOKENS'], ['']) self.assertEqual(environ['REMOTE_USER_DATA'],'userid_type:unknown') self.assertEqual(environ['AUTH_TYPE'],'cookie') def test_identify_bad_cookie(self): plugin = self._makeOne('secret', include_ip=True) environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=bogus'}) result = plugin.identify(environ) self.assertEqual(result, None) def test_identify_bad_cookie_expired(self): import time plugin = self._makeOne('secret', timeout=2, reissue_time=1) val = self._makeTicket(userid='userid', time=time.time()-3) environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % val}) result = plugin.identify(environ) self.assertEqual(result, None) def test_identify_with_checker_and_existing_account(self): plugin = self._makeOne('secret', userid_checker=dummy_userid_checker) val = self._makeTicket(userid='existing') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % val}) result = plugin.identify(environ) self.assertEqual(len(result), 4) self.assertEqual(result['tokens'], ['']) self.assertEqual(result['repoze.who.plugins.auth_tkt.userid'], 'existing') self.assertEqual(result['userdata'], 'userdata') self.failUnless('timestamp' in result) self.assertEqual(environ['REMOTE_USER_TOKENS'], ['']) self.assertEqual(environ['REMOTE_USER_DATA'],'userdata') self.assertEqual(environ['AUTH_TYPE'],'cookie') def test_remember_creds_same(self): plugin = self._makeOne('secret') val = self._makeTicket(userid='userid') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % val}) result = plugin.remember(environ, {'repoze.who.userid':'userid', 'userdata':'userdata'}) self.assertEqual(result, None) def test_remember_creds_secure(self): plugin = self._makeOne('secret', secure=True) val = self._makeTicket(userid='userid', secure=True) environ = self._makeEnviron() result = plugin.remember(environ, {'repoze.who.userid':'userid', 'userdata':'userdata'}) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'secure; ' 'HttpOnly' % val)) self.assertEqual(result[1], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=localhost; ' 'secure; HttpOnly' % val)) self.assertEqual(result[2], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=.localhost; ' 'secure; HttpOnly' % val)) def test_remember_creds_different(self): plugin = self._makeOne('secret') old_val = self._makeTicket(userid='userid') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val}) new_val = self._makeTicket(userid='other', userdata='userdata') result = plugin.remember(environ, {'repoze.who.userid':'other', 'userdata':'userdata'}) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/' % new_val)) self.assertEqual(result[1], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=localhost' % new_val)) self.assertEqual(result[2], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=.localhost' % new_val)) def test_remember_creds_different_strips_port(self): plugin = self._makeOne('secret') old_val = self._makeTicket(userid='userid') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val, 'HTTP_HOST': 'localhost:8080', }) new_val = self._makeTicket(userid='other', userdata='userdata') result = plugin.remember(environ, {'repoze.who.userid':'other', 'userdata':'userdata'}) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/' % new_val)) self.assertEqual(result[1], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=localhost' % new_val)) self.assertEqual(result[2], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=.localhost' % new_val)) def test_remember_creds_different_include_ip(self): plugin = self._makeOne('secret', include_ip=True) old_val = self._makeTicket(userid='userid', remote_addr='1.1.1.1') environ = self._makeEnviron({'HTTP_COOKIE': 'auth_tkt=%s' % old_val}) new_val = self._makeTicket(userid='other', userdata='userdata', remote_addr='1.1.1.1') result = plugin.remember(environ, {'repoze.who.userid':'other', 'userdata':'userdata'}) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/' % new_val)) self.assertEqual(result[1], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=localhost' % new_val)) self.assertEqual(result[2], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=.localhost' % new_val)) def test_remember_creds_different_bad_old_cookie(self): plugin = self._makeOne('secret') old_val = 'BOGUS' environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val}) new_val = self._makeTicket(userid='other', userdata='userdata') result = plugin.remember(environ, {'repoze.who.userid':'other', 'userdata':'userdata'}) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/' % new_val)) self.assertEqual(result[1], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=localhost' % new_val)) self.assertEqual(result[2], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=.localhost' % new_val)) def test_remember_creds_different_with_tokens(self): plugin = self._makeOne('secret') old_val = self._makeTicket(userid='userid') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val}) new_val = self._makeTicket(userid='userid', userdata='userdata', tokens=['foo', 'bar'], ) result = plugin.remember(environ, {'repoze.who.userid': 'userid', 'userdata': 'userdata', 'tokens': ['foo', 'bar'], }) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/' % new_val)) self.assertEqual(result[1], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; Domain=localhost' % new_val)) self.assertEqual(result[2], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=.localhost' % new_val)) def test_remember_creds_different_with_tuple_tokens(self): plugin = self._makeOne('secret') old_val = self._makeTicket(userid='userid') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val}) new_val = self._makeTicket(userid='userid', userdata='userdata', tokens=['foo', 'bar'], ) result = plugin.remember(environ, {'repoze.who.userid': 'userid', 'userdata': 'userdata', 'tokens': ('foo', 'bar'), }) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/' % new_val)) self.assertEqual(result[1], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=localhost' % new_val)) self.assertEqual(result[2], ('Set-Cookie', 'auth_tkt="%s"; ' 'Path=/; ' 'Domain=.localhost' % new_val)) def test_remember_creds_different_int_userid(self): plugin = self._makeOne('secret') old_val = self._makeTicket(userid='userid') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val}) new_val = self._makeTicket(userid='1', userdata='userid_type:int') result = plugin.remember(environ, {'repoze.who.userid':1, 'userdata':''}) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; Path=/' % new_val)) def test_remember_creds_different_long_userid(self): try: long except NameError: #pragma NO COVER Python >= 3.0 return plugin = self._makeOne('secret') old_val = self._makeTicket(userid='userid') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val}) new_val = self._makeTicket(userid='1', userdata='userid_type:int') result = plugin.remember(environ, {'repoze.who.userid':long(1), 'userdata':''}) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; Path=/' % new_val)) def test_remember_creds_different_unicode_userid(self): plugin = self._makeOne('secret') old_val = self._makeTicket(userid='userid') environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val}) userid = b'\xc2\xa9'.decode('utf-8') if type(b'') == type(''): userdata = 'userid_type:unicode' else: # XXX userdata = '' new_val = self._makeTicket(userid=userid.encode('utf-8'), userdata=userdata) result = plugin.remember(environ, {'repoze.who.userid':userid, 'userdata':''}) self.assertEqual(type(result[0][1]), str) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; Path=/' % new_val)) def test_remember_creds_reissue(self): import time plugin = self._makeOne('secret', reissue_time=1) old_val = self._makeTicket(userid='userid', userdata='', time=time.time()-2) environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val}) new_val = self._makeTicket(userid='userid', userdata='') result = plugin.remember(environ, {'repoze.who.userid':'userid', 'userdata':''}) self.assertEqual(type(result[0][1]), str) self.assertEqual(len(result), 3) self.assertEqual(result[0], ('Set-Cookie', 'auth_tkt="%s"; Path=/' % new_val)) def test_remember_max_age(self): plugin = self._makeOne('secret') environ = {'HTTP_HOST':'example.com'} tkt = self._makeTicket(userid='chris', userdata='') result = plugin.remember(environ, {'repoze.who.userid':'chris', 'max_age':'500'}) name,value = result.pop(0) self.assertEqual('Set-Cookie', name) self.failUnless( value.startswith('auth_tkt="%s"; Path=/; Max-Age=500' % tkt), value) self.failUnless('; Expires=' in value) name,value = result.pop(0) self.assertEqual('Set-Cookie', name) self.failUnless( value.startswith( 'auth_tkt="%s"; Path=/; Domain=example.com; Max-Age=500' % tkt), value) self.failUnless('; Expires=' in value) name,value = result.pop(0) self.assertEqual('Set-Cookie', name) self.failUnless( value.startswith( 'auth_tkt="%s"; Path=/; Domain=.example.com; Max-Age=500' % tkt), value) self.failUnless('; Expires=' in value) def test_forget(self): from datetime import datetime now = datetime(2009, 11, 5, 16, 15, 22) self._setNowTesting(now) plugin = self._makeOne('secret') environ = self._makeEnviron() headers = plugin.forget(environ, None) self.assertEqual(len(headers), 3) header = headers[0] name, value = header self.assertEqual(name, 'Set-Cookie') self.assertEqual(value, 'auth_tkt="INVALID"; Path=/; ' 'Max-Age=0; Expires=Thu, 05 Nov 2009 16:15:22' ) header = headers[1] name, value = header self.assertEqual(name, 'Set-Cookie') self.assertEqual(value, 'auth_tkt="INVALID"; Path=/; Domain=localhost; ' 'Max-Age=0; Expires=Thu, 05 Nov 2009 16:15:22' ) header = headers[2] name, value = header self.assertEqual(name, 'Set-Cookie') self.assertEqual(value, 'auth_tkt="INVALID"; Path=/; Domain=.localhost; ' 'Max-Age=0; Expires=Thu, 05 Nov 2009 16:15:22' ) def test_authenticate_non_auth_tkt_credentials(self): plugin = self._makeOne() self.assertEqual(plugin.authenticate(environ={}, identity={}), None) def test_authenticate_without_checker(self): plugin = self._makeOne() identity = {'repoze.who.plugins.auth_tkt.userid': 'phred'} self.assertEqual(plugin.authenticate({}, identity), 'phred') def test_authenticate_with_checker_and_non_existing_account(self): plugin = self._makeOne('secret', userid_checker=dummy_userid_checker) identity = {'repoze.who.plugins.auth_tkt.userid': 'phred'} self.assertEqual(plugin.authenticate({}, identity), None) def test_authenticate_with_checker_and_existing_account(self): plugin = self._makeOne('secret', userid_checker=dummy_userid_checker) identity = {'repoze.who.plugins.auth_tkt.userid': 'existing'} self.assertEqual(plugin.authenticate({}, identity), 'existing') def test_factory_wo_secret_wo_secretfile_raises_ValueError(self): from repoze.who.plugins.auth_tkt import make_plugin self.assertRaises(ValueError, make_plugin) def test_factory_w_secret_w_secretfile_raises_ValueError(self): from repoze.who.plugins.auth_tkt import make_plugin self.assertRaises(ValueError, make_plugin, 'secret', 'secretfile') def test_factory_w_bad_secretfile_raises_ValueError(self): from repoze.who.plugins.auth_tkt import make_plugin self.assertRaises(ValueError, make_plugin, secretfile='nonesuch.txt') def test_factory_w_secret(self): from repoze.who.plugins.auth_tkt import make_plugin plugin = make_plugin('secret') self.assertEqual(plugin.cookie_name, 'auth_tkt') self.assertEqual(plugin.secret, 'secret') self.assertEqual(plugin.include_ip, False) self.assertEqual(plugin.secure, False) def test_factory_w_secretfile(self): import os from tempfile import mkdtemp from repoze.who.plugins.auth_tkt import make_plugin tempdir = self.tempdir = mkdtemp() path = os.path.join(tempdir, 'who.secret') secret = open(path, 'w') secret.write('s33kr1t\n') secret.flush() secret.close() plugin = make_plugin(secretfile=path) self.assertEqual(plugin.secret, 's33kr1t') def test_factory_with_timeout_and_reissue_time(self): from repoze.who.plugins.auth_tkt import make_plugin plugin = make_plugin('secret', timeout=5, reissue_time=1) self.assertEqual(plugin.timeout, 5) self.assertEqual(plugin.reissue_time, 1) def test_factory_with_userid_checker(self): from repoze.who.plugins.auth_tkt import make_plugin plugin = make_plugin( 'secret', userid_checker='repoze.who.plugins.auth_tkt:make_plugin') self.assertEqual(plugin.userid_checker, make_plugin) def test_remember_max_age_unicode(self): from repoze.who._compat import u plugin = self._makeOne('secret') environ = {'HTTP_HOST':'example.com'} tkt = self._makeTicket(userid='chris', userdata='') result = plugin.remember(environ, {'repoze.who.userid': 'chris', 'max_age': u('500')}) name, value = result.pop(0) self.assertEqual('Set-Cookie', name) self.failUnless(isinstance(value, str)) self.failUnless( value.startswith('auth_tkt="%s"; Path=/; Max-Age=500' % tkt), (value, tkt)) self.failUnless('; Expires=' in value) name,value = result.pop(0) self.assertEqual('Set-Cookie', name) self.failUnless( value.startswith( 'auth_tkt="%s"; Path=/; Domain=example.com; Max-Age=500' % tkt), value) self.failUnless('; Expires=' in value) name,value = result.pop(0) self.assertEqual('Set-Cookie', name) self.failUnless( value.startswith( 'auth_tkt="%s"; Path=/; Domain=.example.com; Max-Age=500' % tkt), value) self.failUnless('; Expires=' in value) def dummy_userid_checker(userid): return userid == 'existing' repoze.who-2.2/repoze/who/plugins/tests/test_htpasswd.py0000664000175000017500000001400712047060075023560 0ustar tseavertseaverimport unittest class TestHTPasswdPlugin(unittest.TestCase): def _getTargetClass(self): from repoze.who.plugins.htpasswd import HTPasswdPlugin return HTPasswdPlugin def _makeOne(self, *arg, **kw): plugin = self._getTargetClass()(*arg, **kw) return plugin def _makeEnviron(self, kw=None): environ = {} environ['wsgi.version'] = (1,0) if kw is not None: environ.update(kw) return environ def failUnless(self, predicate, message=''): self.assertTrue(predicate, message) # Nannies go home! def failIf(self, predicate, message=''): self.assertFalse(predicate, message) # Nannies go home! def test_implements(self): from zope.interface.verify import verifyClass from repoze.who.interfaces import IAuthenticator klass = self._getTargetClass() verifyClass(IAuthenticator, klass) def test_authenticate_nocreds(self): from repoze.who._compat import StringIO io = StringIO() plugin = self._makeOne(io, None) environ = self._makeEnviron() creds = {} result = plugin.authenticate(environ, creds) self.assertEqual(result, None) def test_authenticate_nolines(self): from repoze.who._compat import StringIO io = StringIO() def check(password, hashed): return True plugin = self._makeOne(io, check) environ = self._makeEnviron() creds = {'login':'chrism', 'password':'pass'} result = plugin.authenticate(environ, creds) self.assertEqual(result, None) def test_authenticate_nousermatch(self): from repoze.who._compat import StringIO io = StringIO('nobody:foo') def check(password, hashed): return True plugin = self._makeOne(io, check) environ = self._makeEnviron() creds = {'login':'chrism', 'password':'pass'} result = plugin.authenticate(environ, creds) self.assertEqual(result, None) def test_authenticate_match(self): from repoze.who._compat import StringIO io = StringIO('chrism:pass') def check(password, hashed): return True plugin = self._makeOne(io, check) environ = self._makeEnviron() creds = {'login':'chrism', 'password':'pass'} result = plugin.authenticate(environ, creds) self.assertEqual(result, 'chrism') def test_authenticate_badline(self): from repoze.who._compat import StringIO io = StringIO('badline\nchrism:pass') def check(password, hashed): return True plugin = self._makeOne(io, check) environ = self._makeEnviron() creds = {'login':'chrism', 'password':'pass'} result = plugin.authenticate(environ, creds) self.assertEqual(result, 'chrism') def test_authenticate_filename(self): import os here = os.path.abspath(os.path.dirname(__file__)) htpasswd = os.path.join(here, 'fixtures', 'test.htpasswd') def check(password, hashed): return True plugin = self._makeOne(htpasswd, check) environ = self._makeEnviron() creds = {'login':'chrism', 'password':'pass'} result = plugin.authenticate(environ, creds) self.assertEqual(result, 'chrism') def test_authenticate_bad_filename_logs_to_repoze_who_logger(self): import os here = os.path.abspath(os.path.dirname(__file__)) htpasswd = os.path.join(here, 'fixtures', 'test.htpasswd.nonesuch') def check(password, hashed): return True plugin = self._makeOne(htpasswd, check) environ = self._makeEnviron() class DummyLogger: warnings = [] def warn(self, msg): self.warnings.append(msg) logger = environ['repoze.who.logger'] = DummyLogger() creds = {'login':'chrism', 'password':'pass'} result = plugin.authenticate(environ, creds) self.assertEqual(result, None) self.assertEqual(len(logger.warnings), 1) self.failUnless('could not open htpasswd' in logger.warnings[0]) def test_crypt_check(self): import sys # win32 does not have a crypt library, don't # fail here if "win32" == sys.platform: return from crypt import crypt salt = '123' hashed = crypt('password', salt) from repoze.who.plugins.htpasswd import crypt_check self.assertEqual(crypt_check('password', hashed), True) self.assertEqual(crypt_check('notpassword', hashed), False) def test_sha1_check(self): from base64 import standard_b64encode from hashlib import sha1 from repoze.who._compat import must_encode from repoze.who.plugins.htpasswd import sha1_check encrypted_string = standard_b64encode(sha1( must_encode("password")).digest()) self.assertEqual(sha1_check('password', "%s%s" % ("{SHA}", encrypted_string)), True) self.assertEqual(sha1_check('notpassword', "%s%s" % ("{SHA}", encrypted_string)), False) def test_plain_check(self): from repoze.who.plugins.htpasswd import plain_check self.failUnless(plain_check('password', 'password')) self.failIf(plain_check('notpassword', 'password')) def test_factory_no_filename_raises(self): from repoze.who.plugins.htpasswd import make_plugin self.assertRaises(ValueError, make_plugin) def test_factory_no_check_fn_raises(self): from repoze.who.plugins.htpasswd import make_plugin self.assertRaises(ValueError, make_plugin, 'foo') def test_factory(self): from repoze.who.plugins.htpasswd import make_plugin from repoze.who.plugins.htpasswd import crypt_check plugin = make_plugin('foo', 'repoze.who.plugins.htpasswd:crypt_check') self.assertEqual(plugin.filename, 'foo') self.assertEqual(plugin.check, crypt_check) repoze.who-2.2/repoze/who/plugins/basicauth.py0000644000175000017500000000434611731706026021472 0ustar tseavertseaverimport binascii from webob.exc import HTTPUnauthorized from zope.interface import implementer from repoze.who.interfaces import IIdentifier from repoze.who.interfaces import IChallenger from repoze.who._compat import AUTHORIZATION from repoze.who._compat import decodebytes from repoze.who._compat import must_decode @implementer(IIdentifier, IChallenger) class BasicAuthPlugin(object): def __init__(self, realm): self.realm = realm # IIdentifier def identify(self, environ): authorization = AUTHORIZATION(environ) if type(authorization) != type(b''): # this header *must* be base64-encoded ASCII authorization = authorization.encode('ascii') try: authmeth, auth = authorization.split(b' ', 1) except ValueError: # not enough values to unpack return None if authmeth.lower() == b'basic': try: auth = auth.strip() auth = decodebytes(auth) except binascii.Error: # can't decode return None try: login, password = auth.split(b':', 1) except ValueError: # not enough values to unpack return None auth = {'login': must_decode(login), 'password': must_decode(password)} return auth return None # IIdentifier def remember(self, environ, identity): # we need to do nothing here; the browser remembers the basic # auth info as a result of the user typing it in. pass def _get_wwwauth(self): head = [('WWW-Authenticate', 'Basic realm="%s"' % self.realm)] return head # IIdentifier def forget(self, environ, identity): return self._get_wwwauth() # IChallenger def challenge(self, environ, status, app_headers, forget_headers): head = self._get_wwwauth() if head[0] not in forget_headers: head = head + forget_headers return HTTPUnauthorized(headers=head) def __repr__(self): return '<%s %s>' % (self.__class__.__name__, id(self)) #pragma NO COVERAGE def make_plugin(realm='basic'): plugin = BasicAuthPlugin(realm) return plugin repoze.who-2.2/repoze/who/plugins/htpasswd.py0000664000175000017500000000717512047060060021361 0ustar tseavertseaverimport itertools from zope.interface import implementer from repoze.who.interfaces import IAuthenticator from repoze.who.utils import resolveDotted from repoze.who._compat import izip_longest def _padding_for_file_lines(): yield 'aaaaaa:bbbbbb' @implementer(IAuthenticator) class HTPasswdPlugin(object): def __init__(self, filename, check): self.filename = filename self.check = check # IAuthenticatorPlugin def authenticate(self, environ, identity): # NOW HEAR THIS!!! # # This method is *intentionally* slower than would be ideal because # it is trying to avoid leaking information via timing attacks # (number of users, length of user IDs, length of passwords, etc.). # # Do *not* try to optimize anything away here. try: login = identity['login'] password = identity['password'] except KeyError: return None if hasattr(self.filename, 'seek'): # assumed to have a readline self.filename.seek(0) f = self.filename must_close = False else: try: f = open(self.filename, 'r') must_close = True except IOError: environ['repoze.who.logger'].warn('could not open htpasswd ' 'file %s' % self.filename) return None result = None maybe_user = None to_check = 'ABCDEF0123456789' # Try not to reveal how many users we have. # XXX: the max count here should be configurable ;( lines = itertools.chain(f, _padding_for_file_lines()) for line in itertools.islice(lines, 0, 1000): try: username, hashed = line.rstrip().split(':', 1) except ValueError: continue if _same_string(username, login): # Don't bail early: leaks information!! maybe_user = username to_check = hashed if must_close: f.close() # Check *something* here, to mitigate a timing attack. password_ok = self.check(password, to_check) # Check our flags: if both are OK, we found a match. if password_ok and maybe_user: result = maybe_user return result def __repr__(self): return '<%s %s>' % (self.__class__.__name__, id(self)) #pragma NO COVERAGE PADDING = ' ' * 1000 def _same_string(x, y): # Attempt at isochronous string comparison. mismatches = filter(None, [a != b for a, b, ignored in izip_longest(x, y, PADDING)]) if type(mismatches) != list: #pragma NO COVER Python >= 3.0 mismatches = list(mismatches) return len(mismatches) == 0 def crypt_check(password, hashed): from crypt import crypt salt = hashed[:2] return _same_string(hashed, crypt(password, salt)) def sha1_check(password, hashed): from hashlib import sha1 from base64 import standard_b64encode from repoze.who._compat import must_encode encrypted_string = standard_b64encode(sha1(must_encode(password)).digest()) return _same_string(hashed, "%s%s" % ("{SHA}", encrypted_string)) def plain_check(password, hashed): return _same_string(password, hashed) def make_plugin(filename=None, check_fn=None): if filename is None: raise ValueError('filename must be specified') if check_fn is None: raise ValueError('check_fn must be specified') check = resolveDotted(check_fn) return HTPasswdPlugin(filename, check) repoze.who-2.2/repoze/who/plugins/redirector.py0000644000175000017500000000621511731706026021666 0ustar tseavertseaverfrom webob.exc import HTTPFound from zope.interface import implementer from repoze.who.interfaces import IChallenger from repoze.who._compat import construct_url from repoze.who._compat import header_value from repoze.who._compat import parse_qs from repoze.who._compat import u from repoze.who._compat import urlencode from repoze.who._compat import urlparse from repoze.who._compat import urlunparse @implementer(IChallenger) class RedirectorPlugin(object): """ Plugin for issuing challenges as redirects to a configured URL. o If the ``reason_param`` option is configured, and the application has supplied an ``X-Authorization-Failure-Reason`` header, the plugin includes that reason in the query string of the redirected URL. """ def __init__(self, login_url, came_from_param='came_from', reason_param='reason', reason_header='X-Authorization-Failure-Reason', ): self.login_url = login_url self.came_from_param = came_from_param if ((reason_param is None and reason_header is not None) or (reason_param is not None and reason_header is None)): raise ValueError( "Must supply both 'reason_header' and 'reason_param', " "or neither one.") self.reason_param = reason_param self.reason_header = reason_header self._login_url_parts = list(urlparse(login_url)) # IChallenger def challenge(self, environ, status, app_headers, forget_headers): if self.reason_param is not None or self.came_from_param is not None: url_parts = self._login_url_parts[:] query = url_parts[4] query_elements = parse_qs(query) if self.reason_param is not None: reason = header_value(app_headers, self.reason_header) if reason: query_elements[self.reason_param] = reason if self.came_from_param is not None: query_elements[self.came_from_param] = construct_url(environ) url_parts[4] = urlencode(query_elements, doseq=True) login_url = urlunparse(url_parts) else: login_url = self.login_url headers = [('Location', login_url)] + forget_headers cookies = [(h,v) for (h,v) in app_headers if h.lower() == 'set-cookie'] headers += cookies return HTTPFound(headers=headers) def make_plugin(login_url, came_from_param=None, reason_param=None, reason_header=None, ): if login_url in (u(''), b'', None): raise ValueError("No 'login_url'") if reason_header is not None and reason_param is None: raise Exception("Can't set 'reason_header' without 'reason_param'.") if reason_header is None and reason_param is not None: reason_header='X-Authorization-Failure-Reason' return RedirectorPlugin(login_url, came_from_param=came_from_param, reason_param=reason_param, reason_header=reason_header, ) repoze.who-2.2/repoze/who/middleware.py0000644000175000017500000002303211731706026020154 0ustar tseavertseaverimport logging import sys from repoze.who.api import APIFactory from repoze.who.interfaces import IChallenger from repoze.who._compat import StringIO _STARTED = '-- repoze.who request started (%s) --' _ENDED = '-- repoze.who request ended (%s) --' class PluggableAuthenticationMiddleware(object): def __init__(self, app, identifiers, authenticators, challengers, mdproviders, request_classifier = None, challenge_decider = None, log_stream = None, log_level = logging.INFO, remote_user_key = 'REMOTE_USER', classifier = None ): if challenge_decider is None: raise ValueError('challenge_decider is required') if request_classifier is not None and classifier is not None: raise ValueError( 'Only one of request_classifier and classifier is allowed') if request_classifier is None: if classifier is None: raise ValueError( 'Either request_classifier or classifier is required') request_classifier = classifier self.app = app logger = self.logger = None if isinstance(log_stream, logging.Logger): logger = self.logger = log_stream elif log_stream: handler = logging.StreamHandler(log_stream) fmt = '%(asctime)s %(message)s' formatter = logging.Formatter(fmt) handler.setFormatter(formatter) logger = self.logger = logging.Logger('repoze.who') logger.addHandler(handler) logger.setLevel(log_level) self.remote_user_key = remote_user_key self.api_factory = APIFactory(identifiers, authenticators, challengers, mdproviders, request_classifier, challenge_decider, remote_user_key, logger ) def __call__(self, environ, start_response): if self.remote_user_key in environ: # act as a pass through if REMOTE_USER (or whatever) is # already set return self.app(environ, start_response) api = self.api_factory(environ) environ['repoze.who.plugins'] = api.name_registry # BBB? environ['repoze.who.logger'] = self.logger environ['repoze.who.application'] = self.app logger = self.logger path_info = environ.get('PATH_INFO', None) logger and logger.info(_STARTED % path_info) identity = None identity = api.authenticate() # allow identifier plugins to replace the downstream # application (to do redirection and unauthorized themselves # mostly) app = environ.pop('repoze.who.application') if app is not self.app: logger and logger.info( 'static downstream application replaced with %s' % app) wrapper = StartResponseWrapper(start_response) app_iter = app(environ, wrapper.wrap_start_response) # The challenge decider almost(?) always needs information from the # response. The WSGI spec (PEP 333) states that a WSGI application # must call start_response by the iterable's first iteration. If # start_response hasn't been called, we'll wrap it in a way that # triggers that call. if not wrapper.called: app_iter = wrap_generator(app_iter) if api.challenge_decider(environ, wrapper.status, wrapper.headers): logger and logger.info('challenge required') close = getattr(app_iter, 'close', _no_op) challenge_app = api.challenge(wrapper.status, wrapper.headers) if challenge_app is not None: logger and logger.info('executing challenge app') if app_iter: list(app_iter) # unwind the original app iterator # PEP 333 requires that we call the original iterator's # 'close' method, if it exists, before releasing it. close() # replace the downstream app with the challenge app app_iter = challenge_app(environ, start_response) else: logger and logger.info('configuration error: no challengers') close() raise RuntimeError('no challengers found') else: logger and logger.info('no challenge required') remember_headers = api.remember(identity) wrapper.finish_response(remember_headers) logger and logger.info(_ENDED % path_info) return app_iter def _no_op(): pass def wrap_generator(result): """\ This function returns a generator that behaves exactly the same as the original. It's only difference is it pulls the first iteration off and caches it to trigger any immediate side effects (in a WSGI world, this ensures start_response is called). """ # PEP 333 requires that we call the original iterator's # 'close' method, if it exists, before releasing it. close = getattr(result, 'close', lambda: None) # Neat trick to pull the first iteration only. We need to do this outside # of the generator function to ensure it is called. for iter in result: first = iter break # Wrapper yields the first iteration, then passes result's iterations # directly up. def wrapper(): yield first for iter in result: # We'll let result's StopIteration bubble up directly. yield iter close() return wrapper() class StartResponseWrapper(object): def __init__(self, start_response): self.start_response = start_response self.status = None self.headers = [] self.exc_info = None self.buffer = StringIO() # A WSGI app may delay calling start_response until the first iteration # of its generator. We track this so we know whether or not we need to # trigger an iteration before examining the response. self.called = False def wrap_start_response(self, status, headers, exc_info=None): self.headers = headers self.status = status self.exc_info = exc_info # The response has been initiated, so we have a valid code. self.called = True return self.buffer.write def finish_response(self, extra_headers): if not extra_headers: extra_headers = [] headers = self.headers + extra_headers write = self.start_response(self.status, headers, self.exc_info) if write: self.buffer.seek(0) value = self.buffer.getvalue() if value: write(value) if hasattr(write, 'close'): write.close() def make_test_middleware(app, global_conf): """ Functionally equivalent to [plugin:redirector] use = repoze.who.plugins.redirector.RedirectorPlugin login_url = /login.html [plugin:auth_tkt] use = repoze.who.plugins.auth_tkt:AuthTktCookiePlugin secret = SEEKRIT cookie_name = oatmeal [plugin:basicauth] use = repoze.who.plugins.basicauth.BasicAuthPlugin realm = repoze.who [plugin:htpasswd] use = repoze.who.plugins.htpasswd.HTPasswdPlugin filename = <...> check_fn = repoze.who.plugins.htpasswd:crypt_check [general] request_classifier = repoze.who.classifiers:default_request_classifier challenge_decider = repoze.who.classifiers:default_challenge_decider [identifiers] plugins = authtkt basicauth [authenticators] plugins = authtkt htpasswd [challengers] plugins = redirector:browser basicauth """ # be able to test without a config file from repoze.who.plugins.basicauth import BasicAuthPlugin from repoze.who.plugins.auth_tkt import AuthTktCookiePlugin from repoze.who.plugins.redirector import RedirectorPlugin from repoze.who.plugins.htpasswd import HTPasswdPlugin io = StringIO() for name, password in [ ('admin', 'admin'), ('chris', 'chris') ]: io.write('%s:%s\n' % (name, password)) io.seek(0) def cleartext_check(password, hashed): return password == hashed #pragma NO COVERAGE htpasswd = HTPasswdPlugin(io, cleartext_check) basicauth = BasicAuthPlugin('repoze.who') auth_tkt = AuthTktCookiePlugin('secret', 'auth_tkt') redirector = RedirectorPlugin('/login.html') redirector.classifications = {IChallenger: ['browser']} # only for browser identifiers = [('auth_tkt', auth_tkt), ('basicauth', basicauth), ] authenticators = [('htpasswd', htpasswd)] challengers = [('redirector', redirector), ('basicauth', basicauth)] mdproviders = [] from repoze.who.classifiers import default_request_classifier from repoze.who.classifiers import default_challenge_decider log_stream = None import os if os.environ.get('WHO_LOG'): log_stream = sys.stdout middleware = PluggableAuthenticationMiddleware( app, identifiers, authenticators, challengers, mdproviders, default_request_classifier, default_challenge_decider, log_stream = log_stream, log_level = logging.DEBUG ) return middleware repoze.who-2.2/repoze/who/interfaces.py0000644000175000017500000002317011530747412020166 0ustar tseavertseaverfrom zope.interface import Interface class IAPIFactory(Interface): def __call__(environ): """ environ -> IRepozeWhoAPI """ class IAPI(Interface): """ Facade for stateful invocation of underlying plugins. """ def authenticate(): """ -> {identity} o Return an authenticated identity mapping, extracted from the request environment. o If no identity can be authenticated, return None. o Identity will include at least a 'repoze.who.userid' key, as well as any keys added by metadata plugins. """ def challenge(status='403 Forbidden', app_headers=()): """ -> wsgi application o Return a WSGI application which represents a "challenge" (request for credentials) in response to the current request. """ def remember(identity=None): """ -> [headers] O Return a sequence of response headers which suffice to remember the given identity. o If 'identity' is not passed, use the identity in the environment. """ def forget(identity=None): """ -> [headers] O Return a sequence of response headers which suffice to destroy any credentials used to establish an identity. o If 'identity' is not passed, use the identity in the environment. """ def login(credentials, identifier_name=None): """ -> (identity, headers) o This is an API for browser-based application login forms. o If 'identifier_name' is passed, use it to look up the identifier; othewise, use the first configured identifier. o Attempt to authenticate 'credentials' as though the identifier had extracted them. o On success, 'identity' will be authenticated mapping, and 'headers' will be "remember" headers. o On failure, 'identity' will be None, and response_headers will be "forget" headers. """ def logout(identifier_name=None): """ -> (headers) o This is an API for browser-based application logout. o If 'identifier_name' is passed, use it to look up the identifier; othewise, use the first configured identifier. o Returned headers will be "forget" headers. """ class IPlugin(Interface): pass class IRequestClassifier(IPlugin): """ On ingress: classify a request. """ def __call__(environ): """ environ -> request classifier string This interface is responsible for returning a string value representing a request classification. o 'environ' is the WSGI environment. """ class IChallengeDecider(IPlugin): """ On egress: decide whether a challenge needs to be presented to the user. """ def __call__(environ, status, headers): """ args -> True | False o 'environ' is the WSGI environment. o 'status' is the HTTP status as returned by the downstream WSGI application. o 'headers' are the headers returned by the downstream WSGI application. This interface is responsible for returning True if a challenge needs to be presented to the user, False otherwise. """ class IIdentifier(IPlugin): """ On ingress: Extract credentials from the WSGI environment and turn them into an identity. On egress (remember): Conditionally set information in the response headers allowing the remote system to remember this identity. On egress (forget): Conditionally set information in the response headers allowing the remote system to forget this identity (during a challenge). """ def identify(environ): """ On ingress: environ -> { k1 : v1 , ... , kN : vN } | None o 'environ' is the WSGI environment. o If credentials are found, the returned identity mapping will contain an arbitrary set of key/value pairs. If the identity is based on a login and password, the environment is recommended to contain at least 'login' and 'password' keys as this provides compatibility between the plugin and existing authenticator plugins. If the identity can be 'preauthenticated' (e.g. if the userid is embedded in the identity, such as when we're using ticket-based authentication), the plugin should set the userid in the special 'repoze.who.userid' key; no authenticators will be asked to authenticate the identity thereafer. o Return None to indicate that the plugin found no appropriate credentials. o Only IIdentifier plugins which match one of the the current request's classifications will be asked to perform identification. o An identifier plugin is permitted to add a key to the environment named 'repoze.who.application', which should be an arbitrary WSGI application. If an identifier plugin does so, this application is used instead of the downstream application set up within the middleware. This feature is useful for identifier plugins which need to perform redirection to obtain credentials. If two identifier plugins add a 'repoze.who.application' WSGI application to the environment, the last one consulted will"win". """ def remember(environ, identity): """ On egress (no challenge required): args -> [ (header-name, header-value), ...] | None Return a list of headers suitable for allowing the requesting system to remember the identification information (e.g. a Set-Cookie header). Return None if no headers need to be set. These headers will be appended to any headers returned by the downstream application. """ def forget(environ, identity): """ On egress (challenge required): args -> [ (header-name, header-value), ...] | None Return a list of headers suitable for allowing the requesting system to forget the identification information (e.g. a Set-Cookie header with an expires date in the past). Return None if no headers need to be set. These headers will be included in the response provided by the challenge app. """ class IAuthenticator(IPlugin): """ On ingress: validate the identity and return a user id or None. """ def authenticate(environ, identity): """ identity -> 'userid' | None o 'environ' is the WSGI environment. o 'identity' will be a dictionary (with arbitrary keys and values). o The IAuthenticator should return a single user id (optimally a string) if the identity can be authenticated. If the identify cannot be authenticated, the IAuthenticator should return None. Each instance of a registered IAuthenticator plugin that matches the request classifier will be called N times during a single request, where N is the number of identities found by any IIdentifierPlugin instances. An authenticator must not raise an exception if it is provided an identity dictionary that it does not understand (e.g. if it presumes that 'login' and 'password' are keys in the dictionary, it should check for the existence of these keys before attempting to do anything; if they don't exist, it should return None). An authenticator is permitted to add extra keys to the 'identity' dictionary (e.g., to save metadata from a database query, rather than requiring a separate query from an IMetadataProvider plugin). """ class IChallenger(IPlugin): """ On egress: Conditionally initiate a challenge to the user to provide credentials. Only challenge plugins which match one of the the current response's classifications will be asked to perform a challenge. """ def challenge(environ, status, app_headers, forget_headers): """ args -> WSGI application or None o 'environ' is the WSGI environment. o 'status' is the status written into start_response by the downstream application. o 'app_headers' is the headers list written into start_response by the downstream application. o 'forget_headers' is a list of headers which must be passed back in the response in order to perform credentials reset (logout). These come from the 'forget' method of IIdentifier plugin used to do the request's identification. Examine the values passed in and return a WSGI application (a callable which accepts environ and start_response as its two positional arguments, ala PEP 333) which causes a challenge to be performed. Return None to forego performing a challenge. """ class IMetadataProvider(IPlugin): """On ingress: When an identity is authenticated, metadata providers may scribble on the identity dictionary arbitrarily. Return values from metadata providers are ignored. """ def add_metadata(environ, identity): """ Add metadata to the identity (which is a dictionary). One value is always guaranteed to be in the dictionary when add_metadata is called: 'repoze.who.userid', representing the user id of the identity. Availability and composition of other keys will depend on the identifier plugin which created the identity. """ repoze.who-2.2/repoze/who/tests/0000775000175000017500000000000012145723513016631 5ustar tseavertseaverrepoze.who-2.2/repoze/who/tests/__init__.py0000644000175000017500000000001111530747412020731 0ustar tseavertseaver#package repoze.who-2.2/repoze/who/tests/test_classifiers.py0000644000175000017500000001115711731706026022554 0ustar tseavertseaverimport unittest class _Base(unittest.TestCase): def failUnless(self, predicate, message=''): self.assertTrue(predicate, message) # Nannies go home! def failIf(self, predicate, message=''): self.assertFalse(predicate, message) # Nannies go home! class TestDefaultRequestClassifier(_Base): def _getFUT(self): from repoze.who.classifiers import default_request_classifier return default_request_classifier def _makeEnviron(self, kw=None): from wsgiref.util import setup_testing_defaults environ = {} setup_testing_defaults(environ) if kw is not None: environ.update(kw) return environ def test_conforms_to_IRequestClassifier(self): from repoze.who.interfaces import IRequestClassifier self.failUnless(IRequestClassifier.providedBy(self._getFUT())) def test_classify_dav_method(self): classifier = self._getFUT() environ = self._makeEnviron({'REQUEST_METHOD':'COPY'}) result = classifier(environ) self.assertEqual(result, 'dav') def test_classify_dav_useragent(self): classifier = self._getFUT() environ = self._makeEnviron({'HTTP_USER_AGENT':'WebDrive'}) result = classifier(environ) self.assertEqual(result, 'dav') def test_classify_xmlpost(self): classifier = self._getFUT() environ = self._makeEnviron({'CONTENT_TYPE':'text/xml', 'REQUEST_METHOD':'POST'}) result = classifier(environ) self.assertEqual(result, 'xmlpost') def test_classify_xmlpost_uppercase(self): """RFC 2045, Sec. 5.1: The type, subtype, and parameter names are not case sensitive""" classifier = self._getFUT() environ = self._makeEnviron({'CONTENT_TYPE':'TEXT/XML', 'REQUEST_METHOD':'POST'}) result = classifier(environ) self.assertEqual(result, 'xmlpost') def test_classify_rich_xmlpost(self): """RFC 2046, sec. 4.1.2: A critical parameter that may be specified in the Content-Type field for "text/plain" data is the character set.""" classifier = self._getFUT() environ = self._makeEnviron({'CONTENT_TYPE':'text/xml; charset=UTF-8 (some comment)', 'REQUEST_METHOD':'POST'}) result = classifier(environ) self.assertEqual(result, 'xmlpost') def test_classify_browser(self): classifier = self._getFUT() environ = self._makeEnviron({'CONTENT_TYPE':'text/xml', 'REQUEST_METHOD':'GET'}) result = classifier(environ) self.assertEqual(result, 'browser') class TestDefaultChallengeDecider(_Base): def _getFUT(self): from repoze.who.classifiers import default_challenge_decider return default_challenge_decider def _makeEnviron(self, kw=None): environ = {} environ['wsgi.version'] = (1,0) if kw is not None: environ.update(kw) return environ def test_conforms_to_IChallengeDecider(self): from repoze.who.interfaces import IChallengeDecider self.failUnless(IChallengeDecider.providedBy(self._getFUT())) def test_challenges_on_401(self): decider = self._getFUT() self.failUnless(decider({}, '401 Unauthorized', [])) def test_doesnt_challenges_on_non_401(self): decider = self._getFUT() self.failIf(decider({}, '200 Ok', [])) class TestPassthroughChallengeDecider(_Base): def _getFUT(self): from repoze.who.classifiers import passthrough_challenge_decider return passthrough_challenge_decider def _makeEnviron(self, kw=None): environ = {} environ['wsgi.version'] = (1,0) if kw is not None: environ.update(kw) return environ def test_conforms_to_IChallengeDecider(self): from repoze.who.interfaces import IChallengeDecider self.failUnless(IChallengeDecider.providedBy(self._getFUT())) def test_challenges_on_bare_401(self): decider = self._getFUT() self.failUnless(decider({}, '401 Unauthorized', [])) def test_doesnt_challenges_on_non_401(self): decider = self._getFUT() self.failIf(decider({}, '200 Ok', [])) def test_doesnt_challenges_on_401_with_WWW_Authenticate(self): decider = self._getFUT() self.failIf(decider({}, '401 Ok', [('WWW-Authenticate', 'xxx')])) def test_doesnt_challenges_on_401_with_text_html(self): decider = self._getFUT() self.failIf(decider({}, '401 Ok', [('Content-Type', 'text/html')])) repoze.who-2.2/repoze/who/tests/test__auth_tkt.py0000644000175000017500000002162311731706026022226 0ustar tseavertseaverimport unittest class _Base(unittest.TestCase): def failUnless(self, predicate, message=''): self.assertTrue(predicate, message) # Nannies go home! def failIf(self, predicate, message=''): self.assertFalse(predicate, message) # Nannies go home! class AuthTicketTests(_Base): def _getTargetClass(self): from .._auth_tkt import AuthTicket return AuthTicket def _makeOne(self, *args, **kw): return self._getTargetClass()(*args, **kw) def test_ctor_defaults(self): from .. import _auth_tkt with _Monkey(_auth_tkt, time_mod=_Timemod): tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4') self.assertEqual(tkt.secret, 'SEEKRIT') self.assertEqual(tkt.userid, 'USERID') self.assertEqual(tkt.ip, '1.2.3.4') self.assertEqual(tkt.tokens, '') self.assertEqual(tkt.user_data, '') self.assertEqual(tkt.time, _WHEN) self.assertEqual(tkt.cookie_name, 'auth_tkt') self.assertEqual(tkt.secure, False) def test_ctor_explicit(self): tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', tokens=('a', 'b'), user_data='DATA', time=_WHEN, cookie_name='oatmeal', secure=True) self.assertEqual(tkt.secret, 'SEEKRIT') self.assertEqual(tkt.userid, 'USERID') self.assertEqual(tkt.ip, '1.2.3.4') self.assertEqual(tkt.tokens, 'a,b') self.assertEqual(tkt.user_data, 'DATA') self.assertEqual(tkt.time, _WHEN) self.assertEqual(tkt.cookie_name, 'oatmeal') self.assertEqual(tkt.secure, True) def test_digest(self): from .._auth_tkt import calculate_digest tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', tokens=('a', 'b'), user_data='DATA', time=_WHEN, cookie_name='oatmeal', secure=True) digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID', 'a,b', 'DATA') self.assertEqual(tkt.digest(), digest) def test_cookie_value_wo_tokens_or_userdata(self): from .._auth_tkt import calculate_digest tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', time=_WHEN) digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID', '', '') self.assertEqual(tkt.cookie_value(), '%s%08xUSERID!' % (digest, _WHEN)) def test_cookie_value_w_tokens_and_userdata(self): from .._auth_tkt import calculate_digest tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', tokens=('a', 'b'), user_data='DATA', time=_WHEN) digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID', 'a,b', 'DATA') self.assertEqual(tkt.cookie_value(), '%s%08xUSERID!a,b!DATA' % (digest, _WHEN)) def test_cookie_not_secure_wo_tokens_or_userdata(self): from .._auth_tkt import calculate_digest from .._compat import encodestring tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', time=_WHEN, cookie_name='oatmeal') digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID', '', '') cookie = tkt.cookie() self.assertEqual(cookie['oatmeal'].value, encodestring('%s%08xUSERID!' % (digest, _WHEN) ).strip()) self.assertEqual(cookie['oatmeal']['path'], '/') self.assertEqual(cookie['oatmeal']['secure'], '') def test_cookie_secure_w_tokens_and_userdata(self): from .._auth_tkt import calculate_digest from .._compat import encodestring tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', tokens=('a', 'b'), user_data='DATA', time=_WHEN, cookie_name='oatmeal', secure=True) digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID', 'a,b', 'DATA') cookie = tkt.cookie() self.assertEqual(cookie['oatmeal'].value, encodestring('%s%08xUSERID!a,b!DATA' % (digest, _WHEN) ).strip()) self.assertEqual(cookie['oatmeal']['path'], '/') self.assertEqual(cookie['oatmeal']['secure'], 'true') class BadTicketTests(_Base): def _getTargetClass(self): from .._auth_tkt import BadTicket return BadTicket def _makeOne(self, *args, **kw): return self._getTargetClass()(*args, **kw) def test_wo_expected(self): exc = self._makeOne('message') self.assertEqual(exc.args, ('message',)) self.assertEqual(exc.expected, None) def test_w_expected(self): exc = self._makeOne('message', 'foo') self.assertEqual(exc.args, ('message',)) self.assertEqual(exc.expected, 'foo') class Test_parse_ticket(_Base): def _callFUT(self, secret='SEEKRIT', ticket=None, ip='1.2.3.4'): from .._auth_tkt import parse_ticket return parse_ticket(secret, ticket, ip) def test_bad_timestamp(self): from .._auth_tkt import BadTicket TICKET = '12345678901234567890123456789012XXXXXXXXuserid!' try: self._callFUT(ticket=TICKET) except BadTicket as e: self.failUnless(e.args[0].startswith( 'Timestamp is not a hex integer:')) else: self.fail('Did not raise') def test_no_bang_after_userid(self): from .._auth_tkt import BadTicket TICKET = '1234567890123456789012345678901201020304userid' try: self._callFUT(ticket=TICKET) except BadTicket as e: self.assertEqual(e.args[0], 'userid is not followed by !') else: self.fail('Did not raise') def test_wo_tokens_or_data_bad_digest(self): from .._auth_tkt import BadTicket TICKET = '1234567890123456789012345678901201020304userid!' try: self._callFUT(ticket=TICKET) except BadTicket as e: self.assertEqual(e.args[0], 'Digest signature is not correct') else: self.fail('Did not raise') def test_wo_tokens_or_data_ok_digest(self): from .._auth_tkt import calculate_digest digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID', '', '') TICKET = '%s%08xUSERID!' % (digest, _WHEN) timestamp, userid, tokens, user_data = self._callFUT(ticket=TICKET) self.assertEqual(timestamp, _WHEN) self.assertEqual(userid, 'USERID') self.assertEqual(tokens, ['']) self.assertEqual(user_data, '') def test_w_tokens_and_data_ok_digest(self): from .._auth_tkt import calculate_digest digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID', 'a,b', 'DATA') TICKET = '%s%08xUSERID!a,b!DATA' % (digest, _WHEN) timestamp, userid, tokens, user_data = self._callFUT(ticket=TICKET) self.assertEqual(timestamp, _WHEN) self.assertEqual(userid, 'USERID') self.assertEqual(tokens, ['a', 'b']) self.assertEqual(user_data, 'DATA') class Test_helpers(_Base): # calculate_digest is not very testable, and fully exercised throug callers. def test_ints_to_bytes(self): from struct import pack from .._auth_tkt import ints2bytes self.assertEqual(ints2bytes([1, 2, 3, 4]), pack('>BBBB', 1, 2, 3, 4)) def test_encode_ip_timestamp(self): from struct import pack from .._auth_tkt import encode_ip_timestamp self.assertEqual(encode_ip_timestamp('1.2.3.4', _WHEN), pack('>BBBBL', 1, 2, 3, 4, _WHEN)) def test_maybe_encode_bytes(self): from .._auth_tkt import maybe_encode foo = b'foo' self.failUnless(maybe_encode(foo) is foo) def test_maybe_encode_native_string(self): from .._auth_tkt import maybe_encode foo = 'foo' self.assertEqual(maybe_encode(foo), b'foo') def test_maybe_encode_unicode(self): from .._auth_tkt import maybe_encode from .._compat import u foo = u('foo') self.assertEqual(maybe_encode(foo), b'foo') _WHEN = 1234567 class _Timemod(object): @staticmethod def time(): return _WHEN class _Monkey(object): def __init__(self, module, **replacements): self.module = module self.orig = {} self.replacements = replacements def __enter__(self): for k, v in self.replacements.items(): orig = getattr(self.module, k, self) if orig is not self: self.orig[k] = orig setattr(self.module, k, v) def __exit__(self, *exc_info): for k, v in self.replacements.items(): if k in self.orig: setattr(self.module, k, self.orig[k]) else: #pragma NO COVERSGE delattr(self.module, k) repoze.who-2.2/repoze/who/tests/test_api.py0000644000175000017500000015147311731706026021024 0ustar tseavertseaverimport unittest class _Base(unittest.TestCase): def failUnless(self, predicate, message=''): self.assertTrue(predicate, message) # Nannies go home! def failIf(self, predicate, message=''): self.assertFalse(predicate, message) # Nannies go home! class Test_get_api(_Base): def _callFUT(self, environ): from repoze.who.api import get_api return get_api(environ) def test___call___empty_environ(self): environ = {} api = self._callFUT(environ) self.failUnless(api is None) def test___call___w_api_in_environ(self): expected = object() environ = {'repoze.who.api': expected} api = self._callFUT(environ) self.failUnless(api is expected) class APIFactoryTests(_Base): def _getTargetClass(self): from repoze.who.api import APIFactory return APIFactory def _makeOne(self, plugins=None, identifiers=None, authenticators=None, challengers=None, mdproviders=None, request_classifier=None, challenge_decider=None, remote_user_key=None, logger=None, ): if plugins is None: plugins = {} if identifiers is None: identifiers = () if authenticators is None: authenticators = () if challengers is None: challengers = () if mdproviders is None: mdproviders = () return self._getTargetClass()(identifiers, authenticators, challengers, mdproviders, request_classifier, challenge_decider, remote_user_key, logger, ) def test_class_conforms_to_IAPIFactory(self): from zope.interface.verify import verifyClass from repoze.who.interfaces import IAPIFactory verifyClass(IAPIFactory, self._getTargetClass()) def test_instance_conforms_to_IAPIFactory(self): from zope.interface.verify import verifyObject from repoze.who.interfaces import IAPIFactory verifyObject(IAPIFactory, self._makeOne()) def test_ctor_defaults(self): factory = self._makeOne() self.assertEqual(len(factory.identifiers), 0) self.assertEqual(len(factory.authenticators), 0) self.assertEqual(len(factory.challengers), 0) self.assertEqual(len(factory.mdproviders), 0) self.assertEqual(factory.request_classifier, None) self.assertEqual(factory.challenge_decider, None) self.assertEqual(factory.logger, None) def test___call___empty_environ(self): from repoze.who.api import API environ = {} factory = self._makeOne() api = factory(environ) self.failUnless(isinstance(api, API)) self.failUnless(environ['repoze.who.api'] is api) def test___call___w_api_in_environ(self): expected = object() environ = {'repoze.who.api': expected} factory = self._makeOne() api = factory(environ) self.failUnless(api is expected) class TestMakeRegistries(_Base): def _callFUT(self, identifiers, authenticators, challengers, mdproviders): from repoze.who.api import make_registries return make_registries(identifiers, authenticators, challengers, mdproviders) def test_empty(self): iface_reg, name_reg = self._callFUT([], [], [], []) self.assertEqual(iface_reg, {}) self.assertEqual(name_reg, {}) def test_brokenimpl(self): self.assertRaises(ValueError, self._callFUT, [(None, object())], [], [], []) def test_ok(self): from repoze.who.interfaces import IIdentifier from repoze.who.interfaces import IAuthenticator from repoze.who.interfaces import IChallenger from repoze.who.interfaces import IMetadataProvider credentials1 = {'login':'chris', 'password':'password'} dummy_id1 = DummyIdentifier(credentials1) credentials2 = {'login':'chris', 'password':'password'} dummy_id2 = DummyIdentifier(credentials2) identifiers = [ ('id1', dummy_id1), ('id2', dummy_id2) ] dummy_auth = DummyAuthenticator(None) authenticators = [ ('auth', dummy_auth) ] dummy_challenger = DummyChallenger(None) challengers = [ ('challenger', dummy_challenger) ] dummy_mdprovider = DummyMDProvider() mdproviders = [ ('mdprovider', dummy_mdprovider) ] iface_reg, name_reg = self._callFUT(identifiers, authenticators, challengers, mdproviders) self.assertEqual(iface_reg[IIdentifier], [dummy_id1, dummy_id2]) self.assertEqual(iface_reg[IAuthenticator], [dummy_auth]) self.assertEqual(iface_reg[IChallenger], [dummy_challenger]) self.assertEqual(iface_reg[IMetadataProvider], [dummy_mdprovider]) self.assertEqual(name_reg['id1'], dummy_id1) self.assertEqual(name_reg['id2'], dummy_id2) self.assertEqual(name_reg['auth'], dummy_auth) self.assertEqual(name_reg['challenger'], dummy_challenger) self.assertEqual(name_reg['mdprovider'], dummy_mdprovider) class TestMatchClassification(_Base): def _getFUT(self): from repoze.who.api import match_classification return match_classification def test_match_classification(self): f = self._getFUT() from repoze.who.interfaces import IIdentifier from repoze.who.interfaces import IChallenger from repoze.who.interfaces import IAuthenticator multi1 = DummyMultiPlugin() multi2 = DummyMultiPlugin() multi1.classifications = {IIdentifier:('foo', 'bar'), IChallenger:('buz',), IAuthenticator:None} multi2.classifications = {IIdentifier:('foo', 'baz', 'biz')} plugins = (multi1, multi2) # specific self.assertEqual(f(IIdentifier, plugins, 'foo'), [multi1, multi2]) self.assertEqual(f(IIdentifier, plugins, 'bar'), [multi1]) self.assertEqual(f(IIdentifier, plugins, 'biz'), [multi2]) # any for multi2 self.assertEqual(f(IChallenger, plugins, 'buz'), [multi1, multi2]) # any for either self.assertEqual(f(IAuthenticator, plugins, 'buz'), [multi1, multi2]) class APITests(_Base): def _getTargetClass(self): from repoze.who.api import API return API def _makeOne(self, environ=None, identifiers=None, authenticators=None, challengers=None, request_classifier=None, mdproviders=None, challenge_decider=None, remote_user_key=None, logger=None ): if environ is None: environ = {} if identifiers is None: identifiers = [] if authenticators is None: authenticators = [] if challengers is None: challengers = [] if request_classifier is None: request_classifier = DummyRequestClassifier() if mdproviders is None: mdproviders = [] if challenge_decider is None: challenge_decider = DummyChallengeDecider() api = self._getTargetClass()(environ, identifiers, authenticators, challengers, mdproviders, request_classifier, challenge_decider, remote_user_key, logger, ) return api def _makeEnviron(self): from wsgiref.util import setup_testing_defaults environ = {} setup_testing_defaults(environ) return environ def test_class_conforms_to_IAPI(self): from zope.interface.verify import verifyClass from repoze.who.interfaces import IAPI verifyClass(IAPI, self._getTargetClass()) def test_ctor_accepts_logger_instance(self): logger = DummyLogger() api = self._makeOne(logger=logger) self.assertEqual(len(logger._info), 1) self.assertEqual(logger._info[0], 'request classification: browser') self.assertEqual(len(logger._debug), 0) def test_authenticate_no_identities(self): logger = DummyLogger() environ = self._makeEnviron() plugin = DummyNoResultsIdentifier() plugins = [ ('dummy', plugin) ] api = self._makeOne(environ=environ, identifiers=plugins, logger=logger) identity = api.authenticate() self.assertEqual(identity, None) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: browser') self.assertEqual(logger._info[1], 'no identities found, ' 'not authenticating') def test_authenticate_w_identities_no_authenticators(self): logger = DummyLogger() environ = self._makeEnviron() credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials) identifiers = [ ('i', identifier) ] api = self._makeOne(environ=environ, identifiers=identifiers, logger=logger) identity = api.authenticate() self.assertEqual(identity, None) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: browser') # Hmm, should this message distinguish "none found" from # "none authenticated"? self.assertEqual(logger._info[1], 'no identities found, ' 'not authenticating') #def test_authenticate_w_identities_w_authenticators_miss(self): def test_authenticate_w_identities_w_authenticators_hit(self): logger = DummyLogger() environ = self._makeEnviron() credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials) identifiers = [ ('i', identifier) ] authenticator = DummyAuthenticator('chrisid') authenticators = [ ('a', authenticator) ] api = self._makeOne(environ=environ, identifiers=identifiers, authenticators=authenticators, logger=logger) identity = api.authenticate() self.assertEqual(identity['repoze.who.userid'], 'chrisid') self.failUnless(identity['identifier'] is identifier) self.failUnless(identity['authenticator'] is authenticator) self.assertEqual(len(logger._info), 1) self.assertEqual(logger._info[0], 'request classification: browser') def test_challenge_noidentifier_noapp(self): logger = DummyLogger() identity = {'login':'chris', 'password':'password'} environ = self._makeEnviron() environ['repoze.who.identity'] = identity challenger = DummyChallenger() plugins = [ ('challenge', challenger) ] api = self._makeOne(environ=environ, challengers=plugins, request_classifier=lambda environ: 'match', logger=logger, ) app = api.challenge('401 Unauthorized', []) self.assertEqual(app, None) self.assertEqual(environ['challenged'], None) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: match') self.assertEqual(logger._info[1], 'no challenge app returned') self.assertEqual(len(logger._debug), 2) self.failUnless(logger._debug[0].startswith( 'challengers registered: [')) self.failUnless(logger._debug[1].startswith( 'challengers matched for ' 'classification "match": [')) def test_challenge_noidentifier_with_app(self): logger = DummyLogger() identity = {'login':'chris', 'password':'password'} environ = self._makeEnviron() environ['repoze.who.identity'] = identity app = DummyApp() challenger = DummyChallenger(app) plugins = [ ('challenge', challenger) ] api = self._makeOne(environ=environ, challengers=plugins, request_classifier=lambda environ: 'match', logger=logger, ) result = api.challenge('401 Unauthorized', []) self.assertEqual(result, app) self.assertEqual(environ['challenged'], app) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: match') self.failUnless(logger._info[1].startswith('challenger plugin ')) self.failUnless(logger._info[1].endswith( '"challenge" returned an app')) self.assertEqual(len(logger._debug), 2) self.failUnless(logger._debug[0].startswith( 'challengers registered: [')) self.failUnless(logger._debug[1].startswith( 'challengers matched for ' 'classification "match": [')) def test_challenge_identifier_no_app_no_forget_headers(self): logger = DummyLogger() credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials) identity = {'login':'chris', 'password':'password', 'identifier': identifier} environ = self._makeEnviron() environ['repoze.who.identity'] = identity challenger = DummyChallenger() plugins = [ ('challenge', challenger) ] api = self._makeOne(environ=environ, challengers=plugins, request_classifier=lambda environ: 'match', logger=logger, ) result = api.challenge('401 Unauthorized', []) self.assertEqual(result, None) self.assertEqual(environ['challenged'], None) self.assertEqual(identifier.forgotten, identity) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: match') self.assertEqual(logger._info[1], 'no challenge app returned') self.assertEqual(len(logger._debug), 2) self.failUnless(logger._debug[0].startswith( 'challengers registered: [')) self.failUnless(logger._debug[1].startswith( 'challengers matched for ' 'classification "match": [')) def test_challenge_identifier_app_no_forget_headers(self): logger = DummyLogger() credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials) identity = {'login':'chris', 'password':'password', 'identifier': identifier} environ = self._makeEnviron() environ['repoze.who.identity'] = identity app = DummyApp() challenger = DummyChallenger(app) plugins = [ ('challenge', challenger) ] api = self._makeOne(environ=environ, challengers=plugins, request_classifier=lambda environ: 'match', logger=logger, ) result = api.challenge('401 Unauthorized', []) self.assertEqual(result, app) self.assertEqual(environ['challenged'], app) self.assertEqual(identifier.forgotten, identity) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: match') self.failUnless(logger._info[1].startswith('challenger plugin ')) self.failUnless(logger._info[1].endswith( '"challenge" returned an app')) self.assertEqual(len(logger._debug), 2) self.failUnless(logger._debug[0].startswith( 'challengers registered: [')) self.failUnless(logger._debug[1].startswith( 'challengers matched for ' 'classification "match": [')) def test_challenge_identifier_no_app_forget_headers(self): FORGET_HEADERS = [('X-testing-forget', 'Oubliez!')] logger = DummyLogger() credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials, forget_headers=FORGET_HEADERS) identity = {'login':'chris', 'password':'password', 'identifier': identifier} environ = self._makeEnviron() environ['repoze.who.identity'] = identity app = DummyApp() challenger = DummyChallenger(app) plugins = [ ('challenge', challenger) ] api = self._makeOne(environ=environ, challengers=plugins, request_classifier=lambda environ: 'match', logger=logger, ) result = api.challenge('401 Unauthorized', []) self.assertEqual(result, app) self.assertEqual(environ['challenged'], app) self.assertEqual(challenger._challenged_with[3], FORGET_HEADERS) self.assertEqual(len(logger._info), 3) self.assertEqual(logger._info[0], 'request classification: match') self.failUnless(logger._info[1].startswith( 'forgetting via headers from')) self.failUnless(logger._info[1].endswith(repr(FORGET_HEADERS))) self.failUnless(logger._info[2].startswith('challenger plugin ')) self.failUnless(logger._info[2].endswith( '"challenge" returned an app')) self.assertEqual(len(logger._debug), 2) self.failUnless(logger._debug[0].startswith( 'challengers registered: [')) self.failUnless(logger._debug[1].startswith( 'challengers matched for ' 'classification "match": [')) def test_multi_challenge_firstwins(self): credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials) identity = {'login':'chris', 'password':'password', 'identifier': identifier} environ = self._makeEnviron() environ['repoze.who.identity'] = identity app1 = DummyApp() app2 = DummyApp() challenger1 = DummyChallenger(app1) challenger2 = DummyChallenger(app2) plugins = [ ('challenge1', challenger1), ('challenge2', challenger2) ] api = self._makeOne(environ=environ, challengers=plugins, request_classifier=lambda environ: 'match') result = api.challenge('401 Unauthorized', []) self.assertEqual(result, app1) self.assertEqual(environ['challenged'], app1) self.assertEqual(identifier.forgotten, identity) def test_multi_challenge_skipnomatch_findimplicit(self): from repoze.who.interfaces import IChallenger credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials) identity = {'login':'chris', 'password':'password', 'identifier': identifier} environ = self._makeEnviron() environ['repoze.who.identity'] = identity app1 = DummyApp() app2 = DummyApp() challenger1 = DummyChallenger(app1) challenger1.classifications = {IChallenger:['nomatch']} challenger2 = DummyChallenger(app2) challenger2.classifications = {IChallenger:None} plugins = [ ('challenge1', challenger1), ('challenge2', challenger2) ] api = self._makeOne(environ=environ, challengers=plugins, request_classifier=lambda environ: 'match') result = api.challenge('401 Unauthorized', []) self.assertEqual(result, app2) self.assertEqual(environ['challenged'], app2) self.assertEqual(identifier.forgotten, identity) def test_multi_challenge_skipnomatch_findexplicit(self): from repoze.who.interfaces import IChallenger credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials) identity = {'login':'chris', 'password':'password', 'identifier': identifier} environ = self._makeEnviron() environ['repoze.who.identity'] = identity app1 = DummyApp() app2 = DummyApp() challenger1 = DummyChallenger(app1) challenger1.classifications = {IChallenger:['nomatch']} challenger2 = DummyChallenger(app2) challenger2.classifications = {IChallenger:['match']} plugins = [ ('challenge1', challenger1), ('challenge2', challenger2) ] api = self._makeOne(environ=environ, challengers=plugins, request_classifier=lambda environ: 'match') result = api.challenge('401 Unauthorized', []) self.assertEqual(result, app2) self.assertEqual(environ['challenged'], app2) self.assertEqual(identifier.forgotten, identity) def test_remember_identifier_plugin_returns_none(self): class _Identifier: def identify(self, environ): return None def remember(self, environ, identity): return () def forget(self, environ, identity): return () identity = {'identifier': _Identifier()} api = self._makeOne() headers = api.remember(identity=identity) self.assertEqual(tuple(headers), ()) def test_remember_no_identity_passed_or_in_environ(self): logger = DummyLogger() environ = self._makeEnviron() api = self._makeOne(environ=environ) self.assertEqual(len(api.remember()), 0) self.assertEqual(len(logger._info), 0) self.assertEqual(len(logger._debug), 0) def test_remember_no_identity_passed_but_in_environ(self): HEADERS = [('Foo', 'Bar'), ('Baz', 'Qux')] logger = DummyLogger() class _Identifier: def remember(self, environ, identity): return HEADERS environ = self._makeEnviron() environ['repoze.who.identity'] = {'identifier': _Identifier()} api = self._makeOne(environ=environ, logger=logger) self.assertEqual(api.remember(), HEADERS) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: browser') self.failUnless(logger._info[1].startswith( 'remembering via headers from')) self.failUnless(logger._info[1].endswith(repr(HEADERS))) self.assertEqual(len(logger._debug), 0) def test_remember_w_identity_passed_no_identifier(self): logger = DummyLogger() environ = self._makeEnviron() api = self._makeOne(environ=environ, logger=logger) identity = {} self.assertEqual(len(api.remember(identity)), 0) self.assertEqual(len(logger._info), 1) self.assertEqual(logger._info[0], 'request classification: browser') self.assertEqual(len(logger._debug), 0) def test_remember_w_identity_passed_w_identifier(self): HEADERS = [('Foo', 'Bar'), ('Baz', 'Qux')] logger = DummyLogger() class _Identifier: def remember(self, environ, identity): return HEADERS environ = self._makeEnviron() api = self._makeOne(environ=environ, logger=logger) identity = {'identifier': _Identifier()} self.assertEqual(api.remember(identity), HEADERS) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: browser') self.failUnless(logger._info[1].startswith( 'remembering via headers from')) self.failUnless(logger._info[1].endswith(repr(HEADERS))) self.assertEqual(len(logger._debug), 0) def test_forget_identifier_plugin_returns_none(self): class _Identifier: def identify(self, environ): return None def remember(self, environ, identity): return () def forget(self, environ, identity): return () identity = {'identifier': _Identifier()} api = self._makeOne() headers = api.forget(identity=identity) self.assertEqual(tuple(headers), ()) def test_forget_no_identity_passed_or_in_environ(self): logger = DummyLogger() environ = self._makeEnviron() api = self._makeOne(environ=environ, logger=logger) self.assertEqual(len(api.forget()), 0) self.assertEqual(len(logger._info), 1) self.assertEqual(logger._info[0], 'request classification: browser') self.assertEqual(len(logger._debug), 0) def test_forget_no_identity_passed_but_in_environ(self): HEADERS = [('Foo', 'Bar'), ('Baz', 'Qux')] logger = DummyLogger() class _Identifier: def forget(self, environ, identity): return HEADERS environ = self._makeEnviron() environ['repoze.who.identity'] = {'identifier': _Identifier()} api = self._makeOne(environ=environ, logger=logger) self.assertEqual(api.forget(), HEADERS) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: browser') self.failUnless(logger._info[1].startswith( 'forgetting via headers from')) self.failUnless(logger._info[1].endswith(repr(HEADERS))) self.assertEqual(len(logger._debug), 0) def test_forget_w_identity_passed_no_identifier(self): environ = self._makeEnviron() logger = DummyLogger() api = self._makeOne(environ=environ, logger=logger) identity = {} self.assertEqual(len(api.forget(identity)), 0) self.assertEqual(len(logger._info), 1) self.assertEqual(logger._info[0], 'request classification: browser') self.assertEqual(len(logger._debug), 0) def test_forget_w_identity_passed_w_identifier(self): HEADERS = [('Foo', 'Bar'), ('Baz', 'Qux')] logger = DummyLogger() class _Identifier: def forget(self, environ, identity): return HEADERS environ = self._makeEnviron() api = self._makeOne(environ=environ, logger=logger) identity = {'identifier': _Identifier()} self.assertEqual(api.forget(identity), HEADERS) self.assertEqual(len(logger._info), 2) self.assertEqual(logger._info[0], 'request classification: browser') self.failUnless(logger._info[1].startswith( 'forgetting via headers from')) self.failUnless(logger._info[1].endswith(repr(HEADERS))) self.assertEqual(len(logger._debug), 0) def test_login_w_identifier_name_hit(self): REMEMBER_HEADERS = [('Foo', 'Bar'), ('Baz', 'Qux')] FORGET_HEADERS = [('Spam', 'Blah')] class _Identifier: def identify(self, environ): pass def remember(self, environ, identity): return REMEMBER_HEADERS[1:] def forget(self, environ, identity): return FORGET_HEADERS class _BogusIdentifier: def identify(self, environ): pass def remember(self, environ, identity): return REMEMBER_HEADERS[:1] def forget(self, environ, identity): pass authenticator = DummyAuthenticator('chrisid') environ = self._makeEnviron() identifiers = [('bogus', _BogusIdentifier()), ('valid', _Identifier()), ] api = self._makeOne(identifiers=identifiers, authenticators=[('authentic', authenticator)], environ=environ) identity, headers = api.login({'login': 'chrisid'}, 'valid') self.assertEqual(identity['repoze.who.userid'], 'chrisid') self.assertEqual(headers, REMEMBER_HEADERS[1:]) def test_login_wo_identifier_name_hit(self): REMEMBER_HEADERS = [('Foo', 'Bar'), ('Baz', 'Qux')] FORGET_HEADERS = [('Spam', 'Blah')] class _Identifier: def identify(self, environ): pass def remember(self, environ, identity): return REMEMBER_HEADERS[1:] def forget(self, environ, identity): return FORGET_HEADERS class _BogusIdentifier: def identify(self, environ): pass def remember(self, environ, identity): return REMEMBER_HEADERS[:1] def forget(self, environ, identity): pass authenticator = DummyAuthenticator('chrisid') environ = self._makeEnviron() identifiers = [('bogus', _BogusIdentifier()), ('valid', _Identifier()), ] api = self._makeOne(identifiers=identifiers, authenticators=[('authentic', authenticator)], environ=environ) identity, headers = api.login({'login': 'chrisid'}) self.assertEqual(identity['repoze.who.userid'], 'chrisid') self.assertEqual(headers, REMEMBER_HEADERS) def test_login_w_identifier_name_miss(self): REMEMBER_HEADERS = [('Foo', 'Bar'), ('Baz', 'Qux')] FORGET_HEADERS = [('Spam', 'Blah')] class _Identifier: def identify(self, environ): pass def remember(self, environ, identity): return REMEMBER_HEADERS def forget(self, environ, identity): return FORGET_HEADERS class _BogusIdentifier: def identify(self, environ): pass def remember(self, environ, identity): return () def forget(self, environ, identity): return () authenticator = DummyFailAuthenticator() environ = self._makeEnviron() identifiers = [('bogus', _BogusIdentifier()), ('valid', _Identifier()), ] api = self._makeOne(identifiers=identifiers, authenticators=[('authentic', authenticator)], environ=environ) identity, headers = api.login({'login': 'notchrisid'}, 'valid') self.assertEqual(identity, None) self.assertEqual(headers, FORGET_HEADERS) def test_logout_wo_identifier_name_miss(self): FORGET_HEADERS = [('Spam', 'Blah')] class _Identifier: def identify(self, environ): pass def remember(self, environ, identity): return () def forget(self, environ, identity): return FORGET_HEADERS[:1] class _BogusIdentifier: def identify(self, environ): pass def remember(self, environ, identity): return () def forget(self, environ, identity): return FORGET_HEADERS[1:] environ = self._makeEnviron() identifiers = [('valid', _Identifier()), ('bogus', _BogusIdentifier()), ] api = self._makeOne(identifiers=identifiers, environ=environ) headers = api.logout() self.assertEqual(headers, FORGET_HEADERS) def test_logout_w_identifier_name(self): FORGET_HEADERS = [('Spam', 'Blah')] class _Identifier: def identify(self, environ): pass def remember(self, environ, identity): return () def forget(self, environ, identity): return FORGET_HEADERS class _BogusIdentifier: def identify(self, environ): pass def remember(self, environ, identity): return () def forget(self, environ, identity): return () environ = self._makeEnviron() identifiers = [('bogus', _BogusIdentifier()), ('valid', _Identifier()), ] api = self._makeOne(identifiers=identifiers, environ=environ) headers = api.logout('valid') self.assertEqual(headers, FORGET_HEADERS) def test_logout_wo_identifier_name(self): REMEMBER_HEADERS = [('Foo', 'Bar'), ('Baz', 'Qux')] FORGET_HEADERS = [('Spam', 'Blah')] class _Identifier: def identify(self, environ): pass def remember(self, environ, identity): return REMEMBER_HEADERS def forget(self, environ, identity): return FORGET_HEADERS class _BogusIdentifier: def identify(self, environ): pass def remember(self, environ, identity): return () def forget(self, environ, identity): return () authenticator = DummyFailAuthenticator() environ = self._makeEnviron() identifiers = [('valid', _Identifier()), ('bogus', _BogusIdentifier()), ] api = self._makeOne(identifiers=identifiers, authenticators=[('authentic', authenticator)], environ=environ) headers = api.logout() self.assertEqual(headers, FORGET_HEADERS) def test_logout_removes_repoze_who_identity(self): class _Identifier: def identify(self, environ): pass def forget(self, environ, identity): return () def remember(self, environ, identity): return () authenticator = DummyFailAuthenticator() environ = self._makeEnviron() environ['repoze.who.identity'] = 'identity' identifiers = [('valid', _Identifier())] api = self._makeOne(identifiers=identifiers, authenticators=[('authentic', authenticator)], environ=environ) api.logout() self.failIf('repoze.who.identity' in environ) def test__identify_success(self): environ = self._makeEnviron() credentials = {'login':'chris', 'password':'password'} identifier = DummyIdentifier(credentials) identifiers = [ ('i', identifier) ] api = self._makeOne(environ=environ, identifiers=identifiers) results = api._identify() self.assertEqual(len(results), 1) new_identifier, identity = results[0] self.assertEqual(new_identifier, identifier) self.assertEqual(identity['login'], 'chris') self.assertEqual(identity['password'], 'password') def test__identify_success_empty_identity(self): environ = self._makeEnviron() identifier = DummyIdentifier({}) identifiers = [ ('i', identifier) ] api = self._makeOne(environ=environ, identifiers=identifiers) results = api._identify() self.assertEqual(len(results), 1) new_identifier, identity = results[0] self.assertEqual(new_identifier, identifier) self.assertEqual(identity, {}) def test__identify_fail(self): logger = DummyLogger() environ = self._makeEnviron() plugin = DummyNoResultsIdentifier() plugins = [ ('dummy', plugin) ] api = self._makeOne(environ=environ, identifiers=plugins, logger=logger) results = api._identify() self.assertEqual(len(results), 0) self.assertEqual(len(logger._info), 1) self.assertEqual(logger._info[0], 'request classification: browser') self.assertEqual(len(logger._debug), 4) self.failUnless(logger._debug[0].startswith( 'identifier plugins registered: [')) self.failUnless(logger._debug[1].startswith( 'identifier plugins matched for ' 'classification "browser": [')) self.failUnless(logger._debug[2].startswith( 'no identity returned from <')) self.failUnless(logger._debug[2].endswith('> (None)')) self.assertEqual(logger._debug[3], 'identities found: []') def test__identify_success_skip_noresults(self): environ = self._makeEnviron() api = self._makeOne() plugin1 = DummyNoResultsIdentifier() credentials = {'login':'chris', 'password':'password'} plugin2 = DummyIdentifier(credentials) plugins = [ ('identifier1', plugin1), ('identifier2', plugin2) ] api = self._makeOne(environ=environ, identifiers=plugins) results = api._identify() self.assertEqual(len(results), 1) new_identifier, identity = results[0] self.assertEqual(new_identifier, plugin2) self.assertEqual(identity['login'], 'chris') self.assertEqual(identity['password'], 'password') def test__identify_success_multiresults(self): environ = self._makeEnviron() api = self._makeOne() plugin1 = DummyIdentifier({'login':'fred','password':'fred'}) plugin2 = DummyIdentifier({'login':'bob','password':'bob'}) plugins = [ ('identifier1', plugin1), ('identifier2', plugin2) ] api = self._makeOne(environ=environ, identifiers=plugins) results = api._identify() self.assertEqual(len(results), 2) new_identifier, identity = results[0] self.assertEqual(new_identifier, plugin1) self.assertEqual(identity['login'], 'fred') self.assertEqual(identity['password'], 'fred') new_identifier, identity = results[1] self.assertEqual(new_identifier, plugin2) self.assertEqual(identity['login'], 'bob') self.assertEqual(identity['password'], 'bob') def test__identify_find_implicit_classifier(self): environ = self._makeEnviron() api = self._makeOne() plugin1 = DummyIdentifier({'login':'fred','password':'fred'}) from repoze.who.interfaces import IIdentifier plugin1.classifications = {IIdentifier:['nomatch']} plugin2 = DummyIdentifier({'login':'bob','password':'bob'}) plugins = [ ('identifier1', plugin1), ('identifier2', plugin2) ] api = self._makeOne(environ=environ, identifiers=plugins, request_classifier=lambda environ: 'match') results = api._identify() self.assertEqual(len(results), 1) plugin, creds = results[0] self.assertEqual(creds['login'], 'bob') self.assertEqual(creds['password'], 'bob') self.assertEqual(plugin, plugin2) def test__identify_find_explicit_classifier(self): environ = self._makeEnviron() from repoze.who.interfaces import IIdentifier plugin1 = DummyIdentifier({'login':'fred','password':'fred'}) plugin1.classifications = {IIdentifier:['nomatch']} plugin2 = DummyIdentifier({'login':'bob','password':'bob'}) plugin2.classifications = {IIdentifier:['match']} plugins= [ ('identifier1', plugin1), ('identifier2', plugin2) ] api = self._makeOne(environ=environ, identifiers=plugins, request_classifier=lambda environ: 'match') results = api._identify() self.assertEqual(len(results), 1) plugin, creds = results[0] self.assertEqual(creds['login'], 'bob') self.assertEqual(creds['password'], 'bob') self.assertEqual(plugin, plugin2) def test__authenticate_success(self): environ = self._makeEnviron() plugin1 = DummyAuthenticator('a') plugins = [ ('identifier1', plugin1) ] api = self._makeOne(environ=environ, authenticators=plugins) identities = [ (None, {'login':'chris', 'password':'password'}) ] results = api._authenticate(identities) self.assertEqual(len(results), 1) result = results[0] rank, authenticator, identifier, creds, userid = result self.assertEqual(rank, (0,0)) self.assertEqual(authenticator, plugin1) self.assertEqual(identifier, None) self.assertEqual(creds['login'], 'chris') self.assertEqual(creds['password'], 'password') self.assertEqual(userid, 'a') def test__authenticate_fail(self): logger = DummyLogger() environ = self._makeEnviron() # no authenticators api = self._makeOne(environ=environ, logger=logger) identities = [ (None, {'login':'chris', 'password':'password'}) ] result = api._authenticate(identities) self.assertEqual(len(result), 0) self.assertEqual(len(logger._info), 1) self.assertEqual(logger._info[0], 'request classification: browser') self.assertEqual(len(logger._debug), 3) self.assertEqual(logger._debug[0], 'authenticator plugins ' 'registered: []') self.assertEqual(logger._debug[1], 'authenticator plugins matched ' 'for classification "browser": []') self.assertEqual(logger._debug[2], 'identities authenticated: []') def test__authenticate_success_skip_fail(self): logger = DummyLogger() environ = self._makeEnviron() plugin1 = DummyFailAuthenticator() plugin2 = DummyAuthenticator() plugins = [ ('dummy1', plugin1), ('dummy2', plugin2) ] api = self._makeOne(authenticators=plugins, logger=logger) creds = {'login':'chris', 'password':'password'} identities = [ (None, {'login':'chris', 'password':'password'}) ] results = api._authenticate(identities) self.assertEqual(len(results), 1) result = results[0] rank, authenticator, identifier, creds, userid = result self.assertEqual(rank, (1,0)) self.assertEqual(authenticator, plugin2) self.assertEqual(identifier, None) self.assertEqual(creds['login'], 'chris') self.assertEqual(creds['password'], 'password') self.assertEqual(userid, 'chris') self.assertEqual(len(logger._info), 1) self.assertEqual(logger._info[0], 'request classification: browser') self.assertEqual(len(logger._debug), 5) self.failUnless(logger._debug[0].startswith( 'authenticator plugins registered: [')) self.failUnless(logger._debug[1].startswith( 'authenticator plugins matched for ' 'classification "browser": [')) self.failUnless(logger._debug[2].startswith('no userid returned from')) self.failUnless(logger._debug[3].startswith('userid returned from')) self.failUnless(logger._debug[3].endswith('"chris"')) self.failUnless(logger._debug[4].startswith( 'identities authenticated: [((1, 0),')) def test__authenticate_success_multiresult(self): logger = DummyLogger() environ = self._makeEnviron() plugin1 = DummyAuthenticator('chris_id1') plugin2 = DummyAuthenticator('chris_id2') plugins = [ ('dummy1',plugin1), ('dummy2',plugin2) ] api = self._makeOne(environ=environ, authenticators=plugins, logger=logger) creds = {'login':'chris', 'password':'password'} identities = [ (None, {'login':'chris', 'password':'password'}) ] results = api._authenticate(identities) self.assertEqual(len(results), 2) result = results[0] rank, authenticator, identifier, creds, userid = result self.assertEqual(rank, (0,0,)) self.assertEqual(authenticator, plugin1) self.assertEqual(identifier, None) self.assertEqual(creds['login'], 'chris') self.assertEqual(creds['password'], 'password') self.assertEqual(userid, 'chris_id1') result = results[1] rank, authenticator, identifier, creds, userid = result self.assertEqual(rank, (1,0)) self.assertEqual(authenticator, plugin2) self.assertEqual(identifier, None) self.assertEqual(creds['login'], 'chris') self.assertEqual(creds['password'], 'password') self.assertEqual(userid, 'chris_id2') self.assertEqual(len(logger._info), 1) self.assertEqual(logger._info[0], 'request classification: browser') self.assertEqual(len(logger._debug), 5) self.failUnless(logger._debug[0].startswith( 'authenticator plugins registered: [')) self.failUnless(logger._debug[1].startswith( 'authenticator plugins matched for ' 'classification "browser": [')) self.failUnless(logger._debug[2].startswith('userid returned from')) self.failUnless(logger._debug[2].endswith('"chris_id1"')) self.failUnless(logger._debug[3].startswith('userid returned from')) self.failUnless(logger._debug[3].endswith('"chris_id2"')) self.failUnless(logger._debug[4].startswith( 'identities authenticated: [((0, 0),') ) def test__authenticate_find_implicit_classifier(self): from repoze.who.interfaces import IAuthenticator environ = self._makeEnviron() plugin1 = DummyAuthenticator('chris_id1') plugin1.classifications = {IAuthenticator:['nomatch']} plugin2 = DummyAuthenticator('chris_id2') plugins = [ ('auth1', plugin1), ('auth2', plugin2) ] api = self._makeOne(environ=environ, authenticators=plugins, request_classifier=lambda environ: 'match') identities = [ (None, {'login':'chris', 'password':'password'}) ] results = api._authenticate(identities) self.assertEqual(len(results), 1) result = results[0] rank, authenticator, identifier, creds, userid = result self.assertEqual(rank, (0,0)) self.assertEqual(authenticator, plugin2) self.assertEqual(identifier, None) self.assertEqual(creds['login'], 'chris') self.assertEqual(creds['password'], 'password') self.assertEqual(userid, 'chris_id2') def test__authenticate_find_explicit_classifier(self): from repoze.who.interfaces import IAuthenticator environ = self._makeEnviron() plugin1 = DummyAuthenticator('chris_id1') plugin1.classifications = {IAuthenticator:['nomatch']} plugin2 = DummyAuthenticator('chris_id2') plugin2.classifications = {IAuthenticator:['match']} plugins = [ ('auth1', plugin1), ('auth2', plugin2) ] api = self._makeOne(environ=environ, authenticators=plugins, request_classifier=lambda environ: 'match') identities = [ (None, {'login':'chris', 'password':'password'}) ] results = api._authenticate(identities) self.assertEqual(len(results), 1) result = results[0] rank, authenticator, identifier, creds, userid = result self.assertEqual(rank, (0, 0)) self.assertEqual(authenticator, plugin2) self.assertEqual(identifier, None) self.assertEqual(creds['login'], 'chris') self.assertEqual(creds['password'], 'password') self.assertEqual(userid, 'chris_id2') def test__authenticate_user_null_but_not_none(self): environ = self._makeEnviron() plugin1 = DummyAuthenticator(0) plugins = [ ('identifier1', plugin1) ] api = self._makeOne(environ=environ, authenticators=plugins) identities = [ (None, {'login':'chris', 'password':'password'}) ] results = api._authenticate(identities) self.assertEqual(len(results), 1) result = results[0] rank, authenticator, identifier, creds, userid = result self.assertEqual(rank, (0,0)) self.assertEqual(authenticator, plugin1) self.assertEqual(identifier, None) self.assertEqual(creds['login'], 'chris') self.assertEqual(creds['password'], 'password') self.assertEqual(userid, 0) def test__add_metadata(self): environ = self._makeEnviron() plugin1 = DummyMDProvider({'foo':'bar'}) plugin2 = DummyMDProvider({'fuz':'baz'}) plugins = [ ('meta1', plugin1), ('meta2', plugin2) ] api = self._makeOne(environ=environ, mdproviders=plugins) classification = '' identity = {} results = api._add_metadata(identity) self.assertEqual(identity['foo'], 'bar') self.assertEqual(identity['fuz'], 'baz') def test__add_metadata_w_classification(self): environ = self._makeEnviron() plugin1 = DummyMDProvider({'foo':'bar'}) plugin2 = DummyMDProvider({'fuz':'baz'}) from repoze.who.interfaces import IMetadataProvider plugin2.classifications = {IMetadataProvider:['foo']} plugins = [ ('meta1', plugin1), ('meta2', plugin2) ] api = self._makeOne(environ=environ, mdproviders=plugins) classification = 'monkey' identity = {} api._add_metadata(identity) self.assertEqual(identity['foo'], 'bar') self.assertEqual(identity.get('fuz'), None) class TestIdentityDict(_Base): def _getTargetClass(self): from repoze.who.api import Identity return Identity def _makeOne(self, **kw): klass = self._getTargetClass() return klass(**kw) def test_str(self): identity = self._makeOne(foo=1) self.failUnless(str(identity).startswith('`_. mod_auth_tkt is an Apache module that looks for these signed cookies and sets ``REMOTE_USER``, ``REMOTE_USER_TOKENS`` (a comma-separated list of groups) and ``REMOTE_USER_DATA`` (arbitrary string data). This module is an alternative to the ``paste.auth.cookie`` module; it's primary benefit is compatibility with mod_auth_tkt, which in turn makes it possible to use the same authentication process with non-Python code run under Apache. """ from hashlib import md5 import time as time_mod from repoze.who._compat import encodestring from repoze.who._compat import SimpleCookie from repoze.who._compat import url_quote from repoze.who._compat import url_unquote class AuthTicket(object): """ This class represents an authentication token. You must pass in the shared secret, the userid, and the IP address. Optionally you can include tokens (a list of strings, representing role names), 'user_data', which is arbitrary data available for your own use in later scripts. Lastly, you can override the cookie name and timestamp. Once you provide all the arguments, use .cookie_value() to generate the appropriate authentication ticket. .cookie() generates a Cookie object, the str() of which is the complete cookie header to be sent. CGI usage:: token = auth_tkt.AuthTick('sharedsecret', 'username', os.environ['REMOTE_ADDR'], tokens=['admin']) print 'Status: 200 OK' print 'Content-type: text/html' print token.cookie() print ... redirect HTML ... Webware usage:: token = auth_tkt.AuthTick('sharedsecret', 'username', self.request().environ()['REMOTE_ADDR'], tokens=['admin']) self.response().setCookie('auth_tkt', token.cookie_value()) Be careful not to do an HTTP redirect after login; use meta refresh or Javascript -- some browsers have bugs where cookies aren't saved when set on a redirect. """ def __init__(self, secret, userid, ip, tokens=(), user_data='', time=None, cookie_name='auth_tkt', secure=False): self.secret = secret self.userid = userid self.ip = ip self.tokens = ','.join(tokens) self.user_data = user_data if time is None: self.time = time_mod.time() else: self.time = time self.cookie_name = cookie_name self.secure = secure def digest(self): return calculate_digest( self.ip, self.time, self.secret, self.userid, self.tokens, self.user_data) def cookie_value(self): v = '%s%08x%s!' % (self.digest(), int(self.time), url_quote(self.userid)) if self.tokens: v += self.tokens + '!' v += self.user_data return v def cookie(self): c = SimpleCookie() c_val = encodestring(self.cookie_value()) c_val = c_val.strip().replace('\n', '') c[self.cookie_name] = c_val c[self.cookie_name]['path'] = '/' if self.secure: c[self.cookie_name]['secure'] = 'true' return c class BadTicket(Exception): """ Exception raised when a ticket can't be parsed. If we get far enough to determine what the expected digest should have been, expected is set. This should not be shown by default, but can be useful for debugging. """ def __init__(self, msg, expected=None): self.expected = expected Exception.__init__(self, msg) def parse_ticket(secret, ticket, ip): """ Parse the ticket, returning (timestamp, userid, tokens, user_data). If the ticket cannot be parsed, ``BadTicket`` will be raised with an explanation. """ ticket = ticket.strip('"') digest = ticket[:32] try: timestamp = int(ticket[32:40], 16) except ValueError as e: raise BadTicket('Timestamp is not a hex integer: %s' % e) try: userid, data = ticket[40:].split('!', 1) except ValueError: raise BadTicket('userid is not followed by !') userid = url_unquote(userid) if '!' in data: tokens, user_data = data.split('!', 1) else: # @@: Is this the right order? tokens = '' user_data = data expected = calculate_digest(ip, timestamp, secret, userid, tokens, user_data) if expected != digest: raise BadTicket('Digest signature is not correct', expected=(expected, digest)) tokens = tokens.split(',') return (timestamp, userid, tokens, user_data) def calculate_digest(ip, timestamp, secret, userid, tokens, user_data): secret = maybe_encode(secret) userid = maybe_encode(userid) tokens = maybe_encode(tokens) user_data = maybe_encode(user_data) digest0 = md5( encode_ip_timestamp(ip, timestamp) + secret + userid + b'\0' + tokens + b'\0' + user_data).hexdigest() digest = md5(maybe_encode(digest0) + secret).hexdigest() return digest if type(chr(1)) == type(b''): #pragma NO COVER Python < 3.0 def ints2bytes(ints): return b''.join(map(chr, ints)) else: #pragma NO COVER Python >= 3.0 def ints2bytes(ints): return bytes(ints) def encode_ip_timestamp(ip, timestamp): ip_chars = ints2bytes(map(int, ip.split('.'))) t = int(timestamp) ts = ((t & 0xff000000) >> 24, (t & 0xff0000) >> 16, (t & 0xff00) >> 8, t & 0xff) ts_chars = ints2bytes(ts) return ip_chars + ts_chars def maybe_encode(s, encoding='utf8'): if not isinstance(s, type(b'')): s = s.encode(encoding) return s # Original Paste AuthTktMiddleware stripped: we don't have a use for it. repoze.who-2.2/repoze/who/classifiers.py0000644000175000017500000000412312122367625020351 0ustar tseavertseaverfrom repoze.who._compat import CONTENT_TYPE from repoze.who._compat import REQUEST_METHOD from repoze.who._compat import USER_AGENT from zope.interface import directlyProvides from repoze.who.interfaces import IRequestClassifier from repoze.who.interfaces import IChallengeDecider _DAV_METHODS = ( 'OPTIONS', 'PROPFIND', 'PROPPATCH', 'MKCOL', 'LOCK', 'UNLOCK', 'TRACE', 'DELETE', 'COPY', 'MOVE' ) _DAV_USERAGENTS = ( 'Microsoft Data Access Internet Publishing Provider', 'WebDrive', 'Zope External Editor', 'WebDAVFS', 'Goliath', 'neon', 'davlib', 'wsAPI', 'Microsoft-WebDAV' ) def default_request_classifier(environ): """Return one of the following classifiers: 'dav': the request comes from a WebDAV agent. 'xmlpost': the request is a POST of XML data. 'browser': the request comes from a normal browser (default). """ request_method = REQUEST_METHOD(environ) if request_method in _DAV_METHODS: return 'dav' useragent = USER_AGENT(environ) if useragent: for agent in _DAV_USERAGENTS: if useragent.find(agent) != -1: return 'dav' if request_method == 'POST': if CONTENT_TYPE(environ).lower().startswith('text/xml'): return 'xmlpost' return 'browser' directlyProvides(default_request_classifier, IRequestClassifier) def default_challenge_decider(environ, status, headers): return status.startswith('401 ') directlyProvides(default_challenge_decider, IChallengeDecider) def passthrough_challenge_decider(environ, status, headers): """ Don't challenge for pre-challenged responses. o Assume responsese with 'WWW-Authenticate' or an HTML content type are pre-challenged. """ if not status.startswith('401 '): return False h_dict = dict(headers) if 'WWW-Authenticate' in h_dict: return False ct = h_dict.get('Content-Type') if ct is not None: return not ct.startswith('text/html') return True directlyProvides(passthrough_challenge_decider, IChallengeDecider) repoze.who-2.2/repoze.who.egg-info/0000775000175000017500000000000012145723513017160 5ustar tseavertseaverrepoze.who-2.2/repoze.who.egg-info/top_level.txt0000644000175000017500000000000712145723512021704 0ustar tseavertseaverrepoze repoze.who-2.2/repoze.who.egg-info/SOURCES.txt0000644000175000017500000000355312145723513021050 0ustar tseavertseaver.bzrignore .gitignore CHANGES.rst CONTRIBUTORS.txt COPYRIGHT.txt LICENSE.txt README.rst TODO.txt rtd.txt setup.cfg setup.py tox.ini docs/Makefile docs/api.rst docs/changes.rst docs/conf.py docs/configuration.rst docs/index.rst docs/middleware.rst docs/narr.rst docs/plugins.rst docs/use_cases.rst docs/.static/ingress.png docs/.static/logo_hi.gif docs/.static/repoze.css docs/.static/request-lifecycle.png docs/examples/examples.ini docs/examples/standalone_login.py docs/examples/standalone_login_no_who.py docs/examples/hybrid/example.py repoze/__init__.py repoze.who.egg-info/PKG-INFO repoze.who.egg-info/SOURCES.txt repoze.who.egg-info/dependency_links.txt repoze.who.egg-info/entry_points.txt repoze.who.egg-info/namespace_packages.txt repoze.who.egg-info/not-zip-safe repoze.who.egg-info/requires.txt repoze.who.egg-info/top_level.txt repoze/who/__init__.py repoze/who/_auth_tkt.py repoze/who/_compat.py repoze/who/api.py repoze/who/classifiers.py repoze/who/config.py repoze/who/interfaces.py repoze/who/middleware.py repoze/who/restrict.py repoze/who/utils.py repoze/who/plugins/__init__.py repoze/who/plugins/auth_tkt.py repoze/who/plugins/basicauth.py repoze/who/plugins/htpasswd.py repoze/who/plugins/redirector.py repoze/who/plugins/sql.py repoze/who/plugins/tests/__init__.py repoze/who/plugins/tests/test_authtkt.py repoze/who/plugins/tests/test_basicauth.py repoze/who/plugins/tests/test_htpasswd.py repoze/who/plugins/tests/test_redirector.py repoze/who/plugins/tests/test_sql.py repoze/who/plugins/tests/fixtures/__init__.py repoze/who/plugins/tests/fixtures/test.htpasswd repoze/who/plugins/tests/fixtures/testapp.py repoze/who/tests/__init__.py repoze/who/tests/test__auth_tkt.py repoze/who/tests/test__compat.py repoze/who/tests/test_api.py repoze/who/tests/test_classifiers.py repoze/who/tests/test_config.py repoze/who/tests/test_middleware.py repoze/who/tests/test_restrict.pyrepoze.who-2.2/repoze.who.egg-info/PKG-INFO0000644000175000017500000007517012145723512020264 0ustar tseavertseaverMetadata-Version: 1.0 Name: repoze.who Version: 2.2 Summary: repoze.who is an identification and authentication framework for WSGI. Home-page: http://www.repoze.org Author: Agendaless Consulting Author-email: repoze-dev@lists.repoze.org License: BSD-derived (http://www.repoze.org/LICENSE.txt) Description: ``repoze.who`` -- WSGI Authentication Middleware / API ====================================================== Overview -------- ``repoze.who`` is an identification and authentication framework for arbitrary WSGI applications. ``repoze.who`` can be configured either as WSGI middleware or as an API for use by an application. ``repoze.who`` is inspired by Zope 2's Pluggable Authentication Service (PAS) (but ``repoze.who`` is not dependent on Zope in any way; it is useful for any WSGI application). It provides no facility for authorization (ensuring whether a user can or cannot perform the operation implied by the request). This is considered to be the domain of the WSGI application. See the ``docs`` subdirectory of this package (also available at least provisionally at http://static.repoze.org/whodocs) for more information. repoze.who Changelog ==================== 2.2 (2013-05-17) ---------------- - Parse INI-file configuration using ``SafeConfigParser``: allows escaping the ``'%'`` so that e.g. a query template using for a DB-API connection using ``pyformat`` preserves the template. - Added support for Python 3.3, PyPy. 2.1 (2013-03-20) ---------------- - ``_compat`` module: tolerate missing ``CONTENT_TYPE`` key in the WSGI environment. Thanks to Dag Hoidal for the patch. - ``htpasswd`` plugin: add a ``sha1_check`` checker function (the ``crypt`` module is not available on Windows). Thanks to Chandrashekar Jayaraman for the patch. - Documentation typo fixes from Carlos de la Guardia and Atsushi Odagiri. 2.1b1 (2012-11-05) ------------------ - Ported to Py3k using the "compatible subset" mode. - Dropped support for Python < 2.6.x. - Dropped dependency on Paste (forking some code from it). - Added dependency on WebOb instead. Thanks to Atsushi Odagiri (aodag) for the initial effort. 2.0 (2011-09-28) ---------------- - ``auth_tkt`` plugin: strip any port number from the 'Domain' of generated cookies. http://bugs.repoze.org/issue66 - Further harden middleware, calling ``close()`` on the iterable even if raising an exception for a missing challenger. http://bugs.repoze.org/issue174 2.0b1 (2011-05-24) ------------------ - Enabled standard use of logging module's configuration mechanism. See http://docs.python.org/dev/howto/logging.html#configuring-logging-for-a-library Thanks to jgoldsmith for the patch: http://bugs.repoze.org/issue178 - ``repoze.who.plugins.htpasswd``: defend against timing-based attacks. 2.0a4 (2011-02-02) ------------------ - Ensure that the middleware calls ``close()`` (if it exists) on the iterable returned from thw wrapped application, as required by PEP 333. http://bugs.repoze.org/issue174 - Make ``make_api_factory_with_config`` tolerant of invalid filenames / content for the config file: in such cases, the API factory will have *no* configured plugins or policies: it will only be useful for retrieving the API from an environment populated by middleware. - Fix bug in ``repoze.who.api`` where the ``remember()`` or ``forget()`` methods could return a None if the identifier plugin returned a None. - Fix ``auth_tkt`` plugin to not hand over tokens as strings to paste. See http://lists.repoze.org/pipermail/repoze-dev/2010-November/003680.html - Fix ``auth_tkt`` plugin to add "secure" and "HttpOnly" to cookies when configured with ``secure=True``: these attributes prevent the browser from sending cookies over insecure channels, which could be vulnerable to some XSS attacks. - Avoid propagating unicode 'max_age' value into cookie headers. See https://bugs.launchpad.net/bugs/674123 . - Added a single-file example BFG application demonstrating the use of the new 'login' and 'logout' methods of the API object. - Add ``login`` and ``logout`` methods to the ``repoze.who.api.API`` object, as a convenience for application-driven login / logout code, which would otherwise need to use private methods of the API, and reach down into its plugins. 2.0a3 (2010-09-30) ------------------ - Deprecated the following plugins, moving their modules, tests, and docs to a new project, ``repoze.who.deprecatedplugins``: - ``repoze.who.plugins.cookie.InsecureCookiePlugin`` - ``repoze.who.plugins.form.FormPlugin`` - ``repoze.who.plugins.form.RedirectingFormPlugin`` - Made the ``repoze.who.plugins.cookie.InsecureCookiePlugin`` take a ``charset`` argument, and use to to encode / decode login and password. See http://bugs.repoze.org/issue155 - Updated ``repoze.who.restrict`` to return headers as a list, to keep ``wsgiref`` from complaining. - Helped default request classifier cope with xml submissions with an explicit charset defined: http://bugs.repoze.org/issue145 (Lorenzo M. Catucci) - Corrected the handling of type and subtype when matching an XML post to ``xmlpost`` in the default classifier, which, according to RFC 2045, must be matched case-insensitively: http://bugs.repoze.org/issue145 (Lorenzo M. Catucci) - Added ``repoze.who.config:make_api_factory_with_config``, a convenience method for applications which want to set up their own API Factory from a configuration file. - Fixed example call to ``repoze.who.config:make_middleware_with_config`` (added missing ``global_config`` argument). See http://bugs.repoze.org/issue114 2.0a2 (2010-03-25) ------------------ Bugs Fixed ~~~~~~~~~~ - Fixed failure to pass substution values in log message string formatting for ``repoze.who.api:API.challenge``. Fix included adding tests for all logging done by the API object. See http://bugs.repoze.org/issue122 Backward Incompatibilities ~~~~~~~~~~~~~~~~~~~~~~~~~~ - Adjusted logging level for some lower-level details from ``info`` to ``debug``. 2.0a1 (2010-02-24) ------------------ Features ~~~~~~~~ - Restored the ability to create the middleware using the old ``classifier`` argument. That argument is now a deprecated-but-will-work-forever alias for ``request_classifier``. - The ``auth_tkt`` plugin now implements the ``IAuthenticator`` interface, and should normally be used both as an ``IIdentifier`` and an ``IAuthenticator``. - Factored out the API of the middleware object to make it useful from within the application. Applications using ``repoze.who``` now fall into one of three catgeories: - "middleware-only" applications are configured with middleware, and use either ``REMOTE_USER`` or ``repoze.who.identity`` from the environment to determing the authenticated user. - "bare metal" applications use no ``repoze.who`` middleware at all: instead, they configure and an ``APIFactory`` object at startup, and use it to create an ``API`` object when needed on a per-request basis. - "hybrid" applications are configured with ``repoze.who`` middleware, but use a new library function to fetch the ``API`` object from the environ, e.g. to permit calling ``remember`` after a signup or successful login. Bugs Fixed ~~~~~~~~~~ - Fix http://bugs.repoze.org/issue102: when no challengers existed, logging would cause an exception. - Remove ``ez_setup.py`` and dependency on it in setup.py (support distribute). Backward Incompatibilities ~~~~~~~~~~~~~~~~~~~~~~~~~~ - The middleware used to allow identifier plugins to "pre-authenticate" an identity. This feature is no longer supported: the ``auth_tkt`` plugin, which used to use the feature, is now configured to work as an authenticator plugin (as well as an identifier). - The ``repoze.who.middleware:PluggableAuthenticationMiddleware`` class no longer has the following (non-API) methods (now made API methods of the ``repoze.who.api:API`` class): - ``add_metadata`` - ``authenticate`` - ``challenge`` - ``identify`` - The following (non-API) functions moved from ``repoze.who.middleware`` to ``repoze.who.api``: - ``make_registries`` - ``match_classification`` - ``verify`` 1.0.18 (2009-11-05) ------------------- - Issue #104: AuthTkt plugin was passing an invalid cookie value in headers from ``forget``, and was not setting the ``Max-Age`` and ``Expires`` attributes of those cookies. 1.0.17 (2009-11-05) ------------------- - Fixed the ``repoze.who.plugins.form.make_plugin`` factory's ``formcallable`` argument handling, to allow passing in a dotted name (e.g., from a config file). 1.0.16 (2009-11-04) ------------------- - Exposed ``formcallable`` argument for ``repoze.who.plugins.form.FormPlugin`` to the callers of the ``repoze.who.plugins.form.make_plugin`` factory. Thanks to Roland Hedburg for the report. - Fixed an issue that caused the following symptom when using the ini configuration parser:: TypeError: _makePlugin() got multiple values for keyword argument 'name' See http://bugs.repoze.org/issue92 for more details. Thanks to vaab for the bug report and initial fix. 1.0.15 (2009-06-25) ------------------- - If the form post value ``max_age`` exists while in the ``identify`` method is handling the ``login_handler_path``, pass the max_age value in the returned identity dictionary as ``max_age``. See the below bullet point for why. - If the ``identity`` dict passed to the ``auth_tkt`` ``remember`` method contains a ``max_age`` key with a string (or integer) value, treat it as a cue to set the ``Max-Age`` and ``Expires`` headers in the returned cookies. The cookie ``Max-Age`` is set to the value and the ``Expires`` is computed from the current time. 1.0.14 (2009-06-17) ------------------- - Fix test breakage on Windows. See http://bugs.repoze.org/issue79 . - Documented issue with using ``include_ip`` setting in the ``auth_tkt`` plugin. See http://bugs.repoze.org/issue81 . - Added 'passthrough_challenge_decider', which avoids re-challenging 401 responses which have been "pre-challenged" by the application. - One-hundred percent unit test coverage. - Add ``timeout`` and ``reissue_time`` arguments to the auth_tkt identifier plugin, courtesty of Paul Johnston. - Add a ``userid_checker`` argument to the auth_tkt identifier plugin, courtesty of Gustavo Narea. If ``userid_checker`` is provided, it must be a dotted Python name that resolves to a function which accepts a userid and returns a boolean True or False, indicating whether that user exists in a database. This is a workaround. Due to a design bug in repoze.who, the only way who can check for user existence is to use one or more IAuthenticator plugin ``authenticate`` methods. If an IAuthenticator's ``authenticate`` method returns true, it means that the user exists. However most IAuthenticator plugins expect *both* a username and a password, and will return False unconditionally if both aren't supplied. This means that an authenticator can't be used to check if the user "only" exists. The identity provided by an auth_tkt does not contain a password to check against. The actual design bug in repoze.who is this: when a user presents credentials from an auth_tkt, he is considered "preauthenticated". IAuthenticator.authenticate is just never called for a "preauthenticated" identity, which works fine, but it means that the user will be considered authenticated even if you deleted the user's record from whatever database you happen to be using. However, if you use a userid_checker, you can ensure that a user exists for the auth_tkt supplied userid. If the userid_checker returns False, the auth_tkt credentials are considered "no good". 1.0.13 (2009-04-24) ------------------- - Added a paragraph to ``IAuthenticator`` docstring, documenting that plugins are allowed to add keys to the ``identity`` dictionary (e.g., to save a second database query in an ``IMetadataProvider`` plugin). - Patch supplied for issue #71 (http://bugs.repoze.org/issue71) whereby a downstream app can return a generator, relying on an upstream component to call start_response. We do this because the challenge decider needs the status and headers to decide what to do. 1.0.12 (2009-04-19) ------------------- - auth_tkt plugin tried to append REMOTE_USER_TOKENS data to existing tokens data returned by auth_tkt.parse_tkt; this was incorrect; just overwrite. - Extended auth_tkt plugin factory to allow passing secret in a separate file from the main config file. See http://bugs.repoze.org/issue40 . 1.0.11 (2009-04-10) ------------------- - Fix auth_tkt plugin; cookie values are now quoted, making it possible to put spaces and other whitespace, etc in usernames. (thanks to Michael Pedersen). - Fix corner case issue of an exception raised when attempting to log when there are no identifiers or authenticators. 1.0.10 (2009-01-23) ------------------- - The RedirectingFormPlugin now passes along SetCookie headers set into the response by the application within the NotFound response (fixes TG2 "flash" issue). 1.0.9 (2008-12-18) ------------------ - The RedirectingFormPlugin now attempts to find a header named ``X-Authentication-Failure-Reason`` among the response headers set by the application when a challenge is issued. If a value for this header exists (and is non-blank), the value is attached to the redirect URL's query string as the ``reason`` parameter (or a user-settable key). This makes it possible for downstream applications to issue a response that initiates a challenge with this header and subsequently display the reason in the login form rendered as a result of the challenge. 1.0.8 (2008-12-13) ------------------ - The ``PluggableAuthenticationMiddleware`` constructor accepts a ``log_stream`` argument, which is typically a file. After this release, it can also be a PEP 333 ``Logger`` instance; if it is a PEP 333 ``Logger`` instance, this logger will be used as the repoze.who logger (instead of one being constructed by the middleware, as was previously always the case). When the ``log_stream`` argument is a PEP 333 Logger object, the ``log_level`` argument is ignored. 1.0.7 (2008-08-28) ------------------ - ``repoze.who`` and ``repoze.who.plugins`` were not added to the ``namespace_packages`` list in setup.py, potentially making 1.0.6 a brownbag release, given that making these packages namespace packages was the only reason for its release. 1.0.6 (2008-08-28) ------------------ - Make repoze.who and repoze.who.plugins into namespace packages mainly so we can allow plugin authors to distribute packages in the repoze.who.plugins namespace. 1.0.5 (2008-08-23) ------------------ - Fix auth_tkt plugin to set the same cookies in its ``remember`` method that it does in its ``forget`` method. Previously, logging out and relogging back in to a site that used auth_tkt identifier plugin was slightly dicey and would only work sometimes. - The FormPlugin plugin has grown a redirect-on-unauthorized feature. Any response from a downstream application that causes a challenge and includes a Location header will cause a redirect to the value of the Location header. 1.0.4 (2008-08-22) ------------------ - Added a key to the '[general]' config section: ``remote_user_key``. If you use this key in the config file, it tells who to 1) not perform any authentication if it exists in the environment during ingress and 2) to set the key in the environment for the downstream app to use as the REMOTE_USER variable. The default is ``REMOTE_USER``. - Using unicode user ids in combination with the auth_tkt plugin would cause problems under mod_wsgi. - Allowed 'cookie_path' argument to InsecureCookiePlugin (and config constructor). Thanks to Gustavo Narea. 1.0.3 (2008-08-16) ------------------ - A bug in the middleware's ``authenticate`` method made it impossible to authenticate a user with a userid that was null (e.g. 0, False), which are valid identifiers. The only invalid userid is now None. - Applied patch from Olaf Conradi which logs an error when an invalid filename is passed to the HTPasswdPlugin. 1.0.2 (2008-06-16) ------------------ - Fix bug found by Chris Perkins: the auth_tkt plugin's "remember" method didn't handle userids which are Python "long" instances properly. Symptom: TypeError: cannot concatenate 'str' and 'long' objects in "paste.auth.auth_tkt". - Added predicate-based "restriction" middleware support (repoze.who.restrict), allowing configuratio-driven authorization as a WSGI filter. One example predicate, 'authenticated_predicate', is supplied, which requires that the user be authenticated either via 'REMOTE_USER' or via 'repoze.who.identity'. To use the filter to restrict access:: [filter:authenticated_only] use = egg:repoze.who#authenticated or:: [filter:some_predicate] use = egg:repoze.who#predicate predicate = my.module:some_predicate some_option = a value 1.0.1 (2008-05-24) ------------------ - Remove dependency-link to dist.repoze.org to prevent easy_install from inserting that path into its search paths (the dependencies are available from PyPI). 1.0 (2008-05-04) ----------------- - The plugin at plugins.form.FormPlugin didn't redirect properly after collecting identification information. Symptom: a downstream app would receive a POST request with a blank body, which would sometimes result in a Bad Request error. - Fixed interface declarations of 'classifiers.default_request_classifier' and 'classifiers.default_password_compare'. - Added actual config-driven middleware factory, 'config.make_middleware_with_config' - Removed fossilized 'who_conf' argument from plugin factory functions. - Added ConfigParser-based WhoConfig, implementing the spec outlined at http://www.plope.com/static/misc/sphinxtest/intro.html#middleware-configuration-via-config-file, with the following changes: - "Bare" plugins (requiring no configuration options) may be specified as either egg entry points (e.g., 'egg:distname#entry_point_name') or as dotted-path-with-colon (e.g., 'dotted.name:object_id'). - Therefore, the separator between a plugin and its classifier is now a semicolon, rather than a colon. E.g.:: [plugins:id_plugin] use = egg:another.package#identify_with_frobnatz frobnatz = baz [identifiers] plugins = egg:my.egg#identify;browser dotted.name:identifier id_plugin 0.9.1 (2008-04-27) ------------------ - Fix auth_tkt plugin to be able to encode and decode integer user ids. 0.9 (2008-04-01) ---------------- - Fix bug introduced in FormPlugin in 0.8 release (rememberer headers not set). - Add PATH_INFO to started and ended log info. - Add a SQLMetadataProviderPlugin (in plugins/sql). - Change constructor of SQLAuthenticatorPlugin: it now accepts only "query", "conn_factory", and "compare_fn". The old constructor accepted a DSN, but some database systems don't use DBAPI DSNs. The new constructor accepts no DSN; the conn_factory is assumed to do all the work to make a connection, including knowing the DSN if one is required. The "conn_factory" should return something that, when called with no arguments, returns a database connection. - The "make_plugin" helper in plugins/sql has been renamed "make_authenticator_plugin". When called, this helper will return a SQLAuthenticatorPlugin. A bit of helper logic in the "make_authenticator_plugin" allows a connection factory to be computed. The top-level callable referred to by conn_factory in this helper should return a function that, when called with no arguments, returns a datbase connection. The top-level callable itself is called with "who_conf" (global who configuration) and any number of non-top-level keyword arguments as they are passed into the helper, to allow for a DSN or URL or whatever to be passed in. - A "make_metatata_plugin" helper has been added to plugins/sql. When called, this will make a SQLMetadataProviderPlugin. See the implementation for details. It is similar to the "make_authenticator_plugin" helper. 0.8 (2008-03-27) ---------------- - Add a RedirectingFormIdentifier plugin. This plugin is willing to redirect to an external (or downstream application) login form to perform identification. The external login form must post to the "login_handler_path" of the plugin (optimally with a "came_from" value to tell the plugin where to redirect the response to if the authentication works properly). The "logout_handler_path" of this plugin can be visited to perform a logout. The "came_from" value also works there. - Identifier plugins are now permitted to set a key in the environment named 'repoze.who.application' on ingress (in 'identify'). If an identifier plugin does so, this application is used instead of the "normal" downstream application. This feature was added to more simply support the redirecting form identifier plugin. 0.7 (2008-03-26) ---------------- - Change the IMetadataProvider interface: this interface used to have a "metadata" method which returned a dictionary. This method is not part of that API anymore. It's been replaced with an "add_metadata" method which has the signature:: def add_metadata(environ, identity): """ Add metadata to the identity (which is a dictionary) """ The return value is ignored. IMetadataProvider plugins are now assumed to be responsible for 'scribbling' directly on the identity that is passed in (it's a dictionary). The user id can always be retrieved from the identity via identity['repoze.who.userid'] for metadata plugins that rely on that value. 0.6 (2008-03-20) ---------------- - Renaming: repoze.pam is now repoze.who - Bump ez_setup.py version. - Add IMetadataProvider plugin type. Chris says 'Whit rules'. 0.5 (2008-03-09) ---------------- - Allow "remote user key" (default: REMOTE_USER) to be overridden (pass in remote_user_key to middleware constructor). - Allow form plugin to override the default form. - API change: IIdentifiers are no longer required to put both 'login' and 'password' in a returned identity dictionary. Instead, an IIdentifier can place arbitrary key/value pairs in the identity dictionary (or return an empty dictionary). - API return value change: the "failure" identity which IIdentifiers return is now None rather than an empty dictionary. - The IAuthenticator interface now specifies that IAuthenticators must not raise an exception when evaluating an identity that does not have "expected" key/value pairs (e.g. when an IAuthenticator that expects login and password inspects an identity returned by an IP-based auth system which only puts the IP address in the identity); instead they fail gracefully by returning None. - Add (cookie) "auth_tkt" identification plugin. - Stamp identity dictionaries with a userid by placing a key named 'repoze.pam.userid' into the identity for each authenticated identity. - If an IIdentifier plugin inserts a 'repoze.pam.userid' key into the identity dictionary, consider this identity "preauthenticated". No authenticator plugins will be asked to authenticate this identity. This is designed for things like the recently added auth_tkt plugin, which embeds the user id into the ticket. This effectively alllows an IIdentifier plugin to become an IAuthenticator plugin when breaking apart the responsibility into two separate plugins is "make-work". Preauthenticated identities will be selected first when deciding which identity to use for any given request. - Insert a 'repoze.pam.identity' key into the WSGI environment on ingress if an identity is found. Its value will be the identity dictionary related to the identity selected by repoze.pam on ingress. Downstream consumers are allowed to mutate this dictionary; this value is passed to "remember" and "forget", so its main use is to do a "credentials reset"; e.g. a user has changed his username or password within the application, but we don't want to force him to log in again after he does so. 0.4 (03-07-2008) ---------------- - Allow plugins to specify a classifiers list per interface (instead of a single classifiers list per plugin). 0.3 (03-05-2008) ---------------- - Make SQLAuthenticatorPlugin's default_password_compare use hexdigest sha instead of base64'ed binary sha for simpler conversion. 0.2 (03-04-2008) ---------------- - Added SQLAuthenticatorPlugin (see plugins/sql.py). 0.1 (02-27-2008) ---------------- - Initial release (no configuration file support yet). Keywords: web application server wsgi zope Platform: UNKNOWN Classifier: Intended Audience :: Developers Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.6 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.2 Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Internet :: WWW/HTTP Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content Classifier: Topic :: Internet :: WWW/HTTP :: WSGI Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application repoze.who-2.2/repoze.who.egg-info/dependency_links.txt0000644000175000017500000000000112145723512023223 0ustar tseavertseaver repoze.who-2.2/repoze.who.egg-info/not-zip-safe0000644000175000017500000000000111536774361021416 0ustar tseavertseaver repoze.who-2.2/repoze.who.egg-info/namespace_packages.txt0000644000175000017500000000004512145723512023507 0ustar tseavertseaverrepoze repoze.who repoze.who.plugins repoze.who-2.2/repoze.who.egg-info/requires.txt0000644000175000017500000000021512145723512021553 0ustar tseavertseaverWebOb zope.interface setuptools [docs] WebOb zope.interface Sphinx repoze.sphinx.autointerface [testing] WebOb zope.interface nose coveragerepoze.who-2.2/repoze.who.egg-info/entry_points.txt0000644000175000017500000000044612145723512022457 0ustar tseavertseaver [paste.filter_app_factory] test = repoze.who.middleware:make_test_middleware config = repoze.who.config:make_middleware_with_config predicate = repoze.who.restrict:make_predicate_restriction authenticated = repoze.who.restrict:make_authenticated_restriction repoze.who-2.2/COPYRIGHT.txt0000644000175000017500000000015511530747412015477 0ustar tseavertseaverCopyright (c) 2007 Agendaless Consulting and Contributors. (http://www.agendaless.com), All Rights Reserved repoze.who-2.2/CHANGES.rst0000664000175000017500000005654112145524246015205 0ustar tseavertseaverrepoze.who Changelog ==================== 2.2 (2013-05-17) ---------------- - Parse INI-file configuration using ``SafeConfigParser``: allows escaping the ``'%'`` so that e.g. a query template using for a DB-API connection using ``pyformat`` preserves the template. - Added support for Python 3.3, PyPy. 2.1 (2013-03-20) ---------------- - ``_compat`` module: tolerate missing ``CONTENT_TYPE`` key in the WSGI environment. Thanks to Dag Hoidal for the patch. - ``htpasswd`` plugin: add a ``sha1_check`` checker function (the ``crypt`` module is not available on Windows). Thanks to Chandrashekar Jayaraman for the patch. - Documentation typo fixes from Carlos de la Guardia and Atsushi Odagiri. 2.1b1 (2012-11-05) ------------------ - Ported to Py3k using the "compatible subset" mode. - Dropped support for Python < 2.6.x. - Dropped dependency on Paste (forking some code from it). - Added dependency on WebOb instead. Thanks to Atsushi Odagiri (aodag) for the initial effort. 2.0 (2011-09-28) ---------------- - ``auth_tkt`` plugin: strip any port number from the 'Domain' of generated cookies. http://bugs.repoze.org/issue66 - Further harden middleware, calling ``close()`` on the iterable even if raising an exception for a missing challenger. http://bugs.repoze.org/issue174 2.0b1 (2011-05-24) ------------------ - Enabled standard use of logging module's configuration mechanism. See http://docs.python.org/dev/howto/logging.html#configuring-logging-for-a-library Thanks to jgoldsmith for the patch: http://bugs.repoze.org/issue178 - ``repoze.who.plugins.htpasswd``: defend against timing-based attacks. 2.0a4 (2011-02-02) ------------------ - Ensure that the middleware calls ``close()`` (if it exists) on the iterable returned from thw wrapped application, as required by PEP 333. http://bugs.repoze.org/issue174 - Make ``make_api_factory_with_config`` tolerant of invalid filenames / content for the config file: in such cases, the API factory will have *no* configured plugins or policies: it will only be useful for retrieving the API from an environment populated by middleware. - Fix bug in ``repoze.who.api`` where the ``remember()`` or ``forget()`` methods could return a None if the identifier plugin returned a None. - Fix ``auth_tkt`` plugin to not hand over tokens as strings to paste. See http://lists.repoze.org/pipermail/repoze-dev/2010-November/003680.html - Fix ``auth_tkt`` plugin to add "secure" and "HttpOnly" to cookies when configured with ``secure=True``: these attributes prevent the browser from sending cookies over insecure channels, which could be vulnerable to some XSS attacks. - Avoid propagating unicode 'max_age' value into cookie headers. See https://bugs.launchpad.net/bugs/674123 . - Added a single-file example BFG application demonstrating the use of the new 'login' and 'logout' methods of the API object. - Add ``login`` and ``logout`` methods to the ``repoze.who.api.API`` object, as a convenience for application-driven login / logout code, which would otherwise need to use private methods of the API, and reach down into its plugins. 2.0a3 (2010-09-30) ------------------ - Deprecated the following plugins, moving their modules, tests, and docs to a new project, ``repoze.who.deprecatedplugins``: - ``repoze.who.plugins.cookie.InsecureCookiePlugin`` - ``repoze.who.plugins.form.FormPlugin`` - ``repoze.who.plugins.form.RedirectingFormPlugin`` - Made the ``repoze.who.plugins.cookie.InsecureCookiePlugin`` take a ``charset`` argument, and use to to encode / decode login and password. See http://bugs.repoze.org/issue155 - Updated ``repoze.who.restrict`` to return headers as a list, to keep ``wsgiref`` from complaining. - Helped default request classifier cope with xml submissions with an explicit charset defined: http://bugs.repoze.org/issue145 (Lorenzo M. Catucci) - Corrected the handling of type and subtype when matching an XML post to ``xmlpost`` in the default classifier, which, according to RFC 2045, must be matched case-insensitively: http://bugs.repoze.org/issue145 (Lorenzo M. Catucci) - Added ``repoze.who.config:make_api_factory_with_config``, a convenience method for applications which want to set up their own API Factory from a configuration file. - Fixed example call to ``repoze.who.config:make_middleware_with_config`` (added missing ``global_config`` argument). See http://bugs.repoze.org/issue114 2.0a2 (2010-03-25) ------------------ Bugs Fixed ~~~~~~~~~~ - Fixed failure to pass substution values in log message string formatting for ``repoze.who.api:API.challenge``. Fix included adding tests for all logging done by the API object. See http://bugs.repoze.org/issue122 Backward Incompatibilities ~~~~~~~~~~~~~~~~~~~~~~~~~~ - Adjusted logging level for some lower-level details from ``info`` to ``debug``. 2.0a1 (2010-02-24) ------------------ Features ~~~~~~~~ - Restored the ability to create the middleware using the old ``classifier`` argument. That argument is now a deprecated-but-will-work-forever alias for ``request_classifier``. - The ``auth_tkt`` plugin now implements the ``IAuthenticator`` interface, and should normally be used both as an ``IIdentifier`` and an ``IAuthenticator``. - Factored out the API of the middleware object to make it useful from within the application. Applications using ``repoze.who``` now fall into one of three catgeories: - "middleware-only" applications are configured with middleware, and use either ``REMOTE_USER`` or ``repoze.who.identity`` from the environment to determing the authenticated user. - "bare metal" applications use no ``repoze.who`` middleware at all: instead, they configure and an ``APIFactory`` object at startup, and use it to create an ``API`` object when needed on a per-request basis. - "hybrid" applications are configured with ``repoze.who`` middleware, but use a new library function to fetch the ``API`` object from the environ, e.g. to permit calling ``remember`` after a signup or successful login. Bugs Fixed ~~~~~~~~~~ - Fix http://bugs.repoze.org/issue102: when no challengers existed, logging would cause an exception. - Remove ``ez_setup.py`` and dependency on it in setup.py (support distribute). Backward Incompatibilities ~~~~~~~~~~~~~~~~~~~~~~~~~~ - The middleware used to allow identifier plugins to "pre-authenticate" an identity. This feature is no longer supported: the ``auth_tkt`` plugin, which used to use the feature, is now configured to work as an authenticator plugin (as well as an identifier). - The ``repoze.who.middleware:PluggableAuthenticationMiddleware`` class no longer has the following (non-API) methods (now made API methods of the ``repoze.who.api:API`` class): - ``add_metadata`` - ``authenticate`` - ``challenge`` - ``identify`` - The following (non-API) functions moved from ``repoze.who.middleware`` to ``repoze.who.api``: - ``make_registries`` - ``match_classification`` - ``verify`` 1.0.18 (2009-11-05) ------------------- - Issue #104: AuthTkt plugin was passing an invalid cookie value in headers from ``forget``, and was not setting the ``Max-Age`` and ``Expires`` attributes of those cookies. 1.0.17 (2009-11-05) ------------------- - Fixed the ``repoze.who.plugins.form.make_plugin`` factory's ``formcallable`` argument handling, to allow passing in a dotted name (e.g., from a config file). 1.0.16 (2009-11-04) ------------------- - Exposed ``formcallable`` argument for ``repoze.who.plugins.form.FormPlugin`` to the callers of the ``repoze.who.plugins.form.make_plugin`` factory. Thanks to Roland Hedburg for the report. - Fixed an issue that caused the following symptom when using the ini configuration parser:: TypeError: _makePlugin() got multiple values for keyword argument 'name' See http://bugs.repoze.org/issue92 for more details. Thanks to vaab for the bug report and initial fix. 1.0.15 (2009-06-25) ------------------- - If the form post value ``max_age`` exists while in the ``identify`` method is handling the ``login_handler_path``, pass the max_age value in the returned identity dictionary as ``max_age``. See the below bullet point for why. - If the ``identity`` dict passed to the ``auth_tkt`` ``remember`` method contains a ``max_age`` key with a string (or integer) value, treat it as a cue to set the ``Max-Age`` and ``Expires`` headers in the returned cookies. The cookie ``Max-Age`` is set to the value and the ``Expires`` is computed from the current time. 1.0.14 (2009-06-17) ------------------- - Fix test breakage on Windows. See http://bugs.repoze.org/issue79 . - Documented issue with using ``include_ip`` setting in the ``auth_tkt`` plugin. See http://bugs.repoze.org/issue81 . - Added 'passthrough_challenge_decider', which avoids re-challenging 401 responses which have been "pre-challenged" by the application. - One-hundred percent unit test coverage. - Add ``timeout`` and ``reissue_time`` arguments to the auth_tkt identifier plugin, courtesty of Paul Johnston. - Add a ``userid_checker`` argument to the auth_tkt identifier plugin, courtesty of Gustavo Narea. If ``userid_checker`` is provided, it must be a dotted Python name that resolves to a function which accepts a userid and returns a boolean True or False, indicating whether that user exists in a database. This is a workaround. Due to a design bug in repoze.who, the only way who can check for user existence is to use one or more IAuthenticator plugin ``authenticate`` methods. If an IAuthenticator's ``authenticate`` method returns true, it means that the user exists. However most IAuthenticator plugins expect *both* a username and a password, and will return False unconditionally if both aren't supplied. This means that an authenticator can't be used to check if the user "only" exists. The identity provided by an auth_tkt does not contain a password to check against. The actual design bug in repoze.who is this: when a user presents credentials from an auth_tkt, he is considered "preauthenticated". IAuthenticator.authenticate is just never called for a "preauthenticated" identity, which works fine, but it means that the user will be considered authenticated even if you deleted the user's record from whatever database you happen to be using. However, if you use a userid_checker, you can ensure that a user exists for the auth_tkt supplied userid. If the userid_checker returns False, the auth_tkt credentials are considered "no good". 1.0.13 (2009-04-24) ------------------- - Added a paragraph to ``IAuthenticator`` docstring, documenting that plugins are allowed to add keys to the ``identity`` dictionary (e.g., to save a second database query in an ``IMetadataProvider`` plugin). - Patch supplied for issue #71 (http://bugs.repoze.org/issue71) whereby a downstream app can return a generator, relying on an upstream component to call start_response. We do this because the challenge decider needs the status and headers to decide what to do. 1.0.12 (2009-04-19) ------------------- - auth_tkt plugin tried to append REMOTE_USER_TOKENS data to existing tokens data returned by auth_tkt.parse_tkt; this was incorrect; just overwrite. - Extended auth_tkt plugin factory to allow passing secret in a separate file from the main config file. See http://bugs.repoze.org/issue40 . 1.0.11 (2009-04-10) ------------------- - Fix auth_tkt plugin; cookie values are now quoted, making it possible to put spaces and other whitespace, etc in usernames. (thanks to Michael Pedersen). - Fix corner case issue of an exception raised when attempting to log when there are no identifiers or authenticators. 1.0.10 (2009-01-23) ------------------- - The RedirectingFormPlugin now passes along SetCookie headers set into the response by the application within the NotFound response (fixes TG2 "flash" issue). 1.0.9 (2008-12-18) ------------------ - The RedirectingFormPlugin now attempts to find a header named ``X-Authentication-Failure-Reason`` among the response headers set by the application when a challenge is issued. If a value for this header exists (and is non-blank), the value is attached to the redirect URL's query string as the ``reason`` parameter (or a user-settable key). This makes it possible for downstream applications to issue a response that initiates a challenge with this header and subsequently display the reason in the login form rendered as a result of the challenge. 1.0.8 (2008-12-13) ------------------ - The ``PluggableAuthenticationMiddleware`` constructor accepts a ``log_stream`` argument, which is typically a file. After this release, it can also be a PEP 333 ``Logger`` instance; if it is a PEP 333 ``Logger`` instance, this logger will be used as the repoze.who logger (instead of one being constructed by the middleware, as was previously always the case). When the ``log_stream`` argument is a PEP 333 Logger object, the ``log_level`` argument is ignored. 1.0.7 (2008-08-28) ------------------ - ``repoze.who`` and ``repoze.who.plugins`` were not added to the ``namespace_packages`` list in setup.py, potentially making 1.0.6 a brownbag release, given that making these packages namespace packages was the only reason for its release. 1.0.6 (2008-08-28) ------------------ - Make repoze.who and repoze.who.plugins into namespace packages mainly so we can allow plugin authors to distribute packages in the repoze.who.plugins namespace. 1.0.5 (2008-08-23) ------------------ - Fix auth_tkt plugin to set the same cookies in its ``remember`` method that it does in its ``forget`` method. Previously, logging out and relogging back in to a site that used auth_tkt identifier plugin was slightly dicey and would only work sometimes. - The FormPlugin plugin has grown a redirect-on-unauthorized feature. Any response from a downstream application that causes a challenge and includes a Location header will cause a redirect to the value of the Location header. 1.0.4 (2008-08-22) ------------------ - Added a key to the '[general]' config section: ``remote_user_key``. If you use this key in the config file, it tells who to 1) not perform any authentication if it exists in the environment during ingress and 2) to set the key in the environment for the downstream app to use as the REMOTE_USER variable. The default is ``REMOTE_USER``. - Using unicode user ids in combination with the auth_tkt plugin would cause problems under mod_wsgi. - Allowed 'cookie_path' argument to InsecureCookiePlugin (and config constructor). Thanks to Gustavo Narea. 1.0.3 (2008-08-16) ------------------ - A bug in the middleware's ``authenticate`` method made it impossible to authenticate a user with a userid that was null (e.g. 0, False), which are valid identifiers. The only invalid userid is now None. - Applied patch from Olaf Conradi which logs an error when an invalid filename is passed to the HTPasswdPlugin. 1.0.2 (2008-06-16) ------------------ - Fix bug found by Chris Perkins: the auth_tkt plugin's "remember" method didn't handle userids which are Python "long" instances properly. Symptom: TypeError: cannot concatenate 'str' and 'long' objects in "paste.auth.auth_tkt". - Added predicate-based "restriction" middleware support (repoze.who.restrict), allowing configuratio-driven authorization as a WSGI filter. One example predicate, 'authenticated_predicate', is supplied, which requires that the user be authenticated either via 'REMOTE_USER' or via 'repoze.who.identity'. To use the filter to restrict access:: [filter:authenticated_only] use = egg:repoze.who#authenticated or:: [filter:some_predicate] use = egg:repoze.who#predicate predicate = my.module:some_predicate some_option = a value 1.0.1 (2008-05-24) ------------------ - Remove dependency-link to dist.repoze.org to prevent easy_install from inserting that path into its search paths (the dependencies are available from PyPI). 1.0 (2008-05-04) ----------------- - The plugin at plugins.form.FormPlugin didn't redirect properly after collecting identification information. Symptom: a downstream app would receive a POST request with a blank body, which would sometimes result in a Bad Request error. - Fixed interface declarations of 'classifiers.default_request_classifier' and 'classifiers.default_password_compare'. - Added actual config-driven middleware factory, 'config.make_middleware_with_config' - Removed fossilized 'who_conf' argument from plugin factory functions. - Added ConfigParser-based WhoConfig, implementing the spec outlined at http://www.plope.com/static/misc/sphinxtest/intro.html#middleware-configuration-via-config-file, with the following changes: - "Bare" plugins (requiring no configuration options) may be specified as either egg entry points (e.g., 'egg:distname#entry_point_name') or as dotted-path-with-colon (e.g., 'dotted.name:object_id'). - Therefore, the separator between a plugin and its classifier is now a semicolon, rather than a colon. E.g.:: [plugins:id_plugin] use = egg:another.package#identify_with_frobnatz frobnatz = baz [identifiers] plugins = egg:my.egg#identify;browser dotted.name:identifier id_plugin 0.9.1 (2008-04-27) ------------------ - Fix auth_tkt plugin to be able to encode and decode integer user ids. 0.9 (2008-04-01) ---------------- - Fix bug introduced in FormPlugin in 0.8 release (rememberer headers not set). - Add PATH_INFO to started and ended log info. - Add a SQLMetadataProviderPlugin (in plugins/sql). - Change constructor of SQLAuthenticatorPlugin: it now accepts only "query", "conn_factory", and "compare_fn". The old constructor accepted a DSN, but some database systems don't use DBAPI DSNs. The new constructor accepts no DSN; the conn_factory is assumed to do all the work to make a connection, including knowing the DSN if one is required. The "conn_factory" should return something that, when called with no arguments, returns a database connection. - The "make_plugin" helper in plugins/sql has been renamed "make_authenticator_plugin". When called, this helper will return a SQLAuthenticatorPlugin. A bit of helper logic in the "make_authenticator_plugin" allows a connection factory to be computed. The top-level callable referred to by conn_factory in this helper should return a function that, when called with no arguments, returns a datbase connection. The top-level callable itself is called with "who_conf" (global who configuration) and any number of non-top-level keyword arguments as they are passed into the helper, to allow for a DSN or URL or whatever to be passed in. - A "make_metatata_plugin" helper has been added to plugins/sql. When called, this will make a SQLMetadataProviderPlugin. See the implementation for details. It is similar to the "make_authenticator_plugin" helper. 0.8 (2008-03-27) ---------------- - Add a RedirectingFormIdentifier plugin. This plugin is willing to redirect to an external (or downstream application) login form to perform identification. The external login form must post to the "login_handler_path" of the plugin (optimally with a "came_from" value to tell the plugin where to redirect the response to if the authentication works properly). The "logout_handler_path" of this plugin can be visited to perform a logout. The "came_from" value also works there. - Identifier plugins are now permitted to set a key in the environment named 'repoze.who.application' on ingress (in 'identify'). If an identifier plugin does so, this application is used instead of the "normal" downstream application. This feature was added to more simply support the redirecting form identifier plugin. 0.7 (2008-03-26) ---------------- - Change the IMetadataProvider interface: this interface used to have a "metadata" method which returned a dictionary. This method is not part of that API anymore. It's been replaced with an "add_metadata" method which has the signature:: def add_metadata(environ, identity): """ Add metadata to the identity (which is a dictionary) """ The return value is ignored. IMetadataProvider plugins are now assumed to be responsible for 'scribbling' directly on the identity that is passed in (it's a dictionary). The user id can always be retrieved from the identity via identity['repoze.who.userid'] for metadata plugins that rely on that value. 0.6 (2008-03-20) ---------------- - Renaming: repoze.pam is now repoze.who - Bump ez_setup.py version. - Add IMetadataProvider plugin type. Chris says 'Whit rules'. 0.5 (2008-03-09) ---------------- - Allow "remote user key" (default: REMOTE_USER) to be overridden (pass in remote_user_key to middleware constructor). - Allow form plugin to override the default form. - API change: IIdentifiers are no longer required to put both 'login' and 'password' in a returned identity dictionary. Instead, an IIdentifier can place arbitrary key/value pairs in the identity dictionary (or return an empty dictionary). - API return value change: the "failure" identity which IIdentifiers return is now None rather than an empty dictionary. - The IAuthenticator interface now specifies that IAuthenticators must not raise an exception when evaluating an identity that does not have "expected" key/value pairs (e.g. when an IAuthenticator that expects login and password inspects an identity returned by an IP-based auth system which only puts the IP address in the identity); instead they fail gracefully by returning None. - Add (cookie) "auth_tkt" identification plugin. - Stamp identity dictionaries with a userid by placing a key named 'repoze.pam.userid' into the identity for each authenticated identity. - If an IIdentifier plugin inserts a 'repoze.pam.userid' key into the identity dictionary, consider this identity "preauthenticated". No authenticator plugins will be asked to authenticate this identity. This is designed for things like the recently added auth_tkt plugin, which embeds the user id into the ticket. This effectively alllows an IIdentifier plugin to become an IAuthenticator plugin when breaking apart the responsibility into two separate plugins is "make-work". Preauthenticated identities will be selected first when deciding which identity to use for any given request. - Insert a 'repoze.pam.identity' key into the WSGI environment on ingress if an identity is found. Its value will be the identity dictionary related to the identity selected by repoze.pam on ingress. Downstream consumers are allowed to mutate this dictionary; this value is passed to "remember" and "forget", so its main use is to do a "credentials reset"; e.g. a user has changed his username or password within the application, but we don't want to force him to log in again after he does so. 0.4 (03-07-2008) ---------------- - Allow plugins to specify a classifiers list per interface (instead of a single classifiers list per plugin). 0.3 (03-05-2008) ---------------- - Make SQLAuthenticatorPlugin's default_password_compare use hexdigest sha instead of base64'ed binary sha for simpler conversion. 0.2 (03-04-2008) ---------------- - Added SQLAuthenticatorPlugin (see plugins/sql.py). 0.1 (02-27-2008) ---------------- - Initial release (no configuration file support yet). repoze.who-2.2/LICENSE.txt0000644000175000017500000000337711530747412015222 0ustar tseavertseaverLicense A copyright notice accompanies this license document that identifies the copyright holders. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions in source code must retain the accompanying copyright notice, this list of conditions, and the following disclaimer. 2. Redistributions in binary form must reproduce the accompanying copyright notice, this list of conditions, and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Names of the copyright holders must not be used to endorse or promote products derived from this software without prior written permission from the copyright holders. 4. If any files are modified, you must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. Disclaimer THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. repoze.who-2.2/README.rst0000644000175000017500000000151111733434736015061 0ustar tseavertseaver``repoze.who`` -- WSGI Authentication Middleware / API ====================================================== Overview -------- ``repoze.who`` is an identification and authentication framework for arbitrary WSGI applications. ``repoze.who`` can be configured either as WSGI middleware or as an API for use by an application. ``repoze.who`` is inspired by Zope 2's Pluggable Authentication Service (PAS) (but ``repoze.who`` is not dependent on Zope in any way; it is useful for any WSGI application). It provides no facility for authorization (ensuring whether a user can or cannot perform the operation implied by the request). This is considered to be the domain of the WSGI application. See the ``docs`` subdirectory of this package (also available at least provisionally at http://static.repoze.org/whodocs) for more information. repoze.who-2.2/TODO.txt0000644000175000017500000000125311733434736014703 0ustar tseavertseaverrepoze.who TODOs ================ Features -------- - [X} Config file parser. - [_] Allow the ZODB plugin to connect read-only. - [X] Add a ``RedirectorPlugin``, similar to ``plugins.form.RedirectingFormPlugin``, but which *only* implements the ``IChallenger`` interface. - [_] Deprecate the form plugins. Documentation ------------- - [_] Middleware use case as tutorial: bug tracker + ``REMOTE_USER`` - [_] Middleware use case as tutorial: wiki + ``repoze.who.identity`` - [_] API use case as tutorial: wiki w/ login and logout views - [_] API use case as tutorial: multiple apps w/ SSO - [_] Hybrid use case as tutorial: multiple apps w/ SSO, one OTS repoze.who-2.2/CONTRIBUTORS.txt0000664000175000017500000001111212047053206016054 0ustar tseavertseaverRepoze Project Contributor Agreement ==================================== The submitter agrees by adding his or her name within the section below named "Contributors" and submitting the resulting modified document to the canonical shared repository location for this software project (whether directly, as a user with "direct commit access", or via a "pull request"), he or she is signing a contract electronically. The submitter becomes a Contributor after a) he or she signs this document by adding their name beneath the "Contributors" section below, and b) the resulting document is accepted into the canonical version control repository. Treatment of Account --------------------- Contributor will not allow anyone other than the Contributor to use his or her username or source repository login to submit code to a Repoze Project source repository. Should Contributor become aware of any such use, Contributor will immediately by notifying Agendaless Consulting. Notification must be performed by sending an email to webmaster@agendaless.com. Until such notice is received, Contributor will be presumed to have taken all actions made through Contributor's account. If the Contributor has direct commit access, Agendaless Consulting will have complete control and discretion over capabilities assigned to Contributor's account, and may disable Contributor's account for any reason at any time. Legal Effect of Contribution ---------------------------- Upon submitting a change or new work to a Repoze Project source Repository (a "Contribution"), you agree to assign, and hereby do assign, a one-half interest of all right, title and interest in and to copyright and other intellectual property rights with respect to your new and original portions of the Contribution to Agendaless Consulting. You and Agendaless Consulting each agree that the other shall be free to exercise any and all exclusive rights in and to the Contribution, without accounting to one another, including without limitation, the right to license the Contribution to others under the Repoze Public License. This agreement shall run with title to the Contribution. Agendaless Consulting does not convey to you any right, title or interest in or to the Program or such portions of the Contribution that were taken from the Program. Your transmission of a submission to the Repoze Project source Repository and marks of identification concerning the Contribution itself constitute your intent to contribute and your assignment of the work in accordance with the provisions of this Agreement. License Terms ------------- Code committed to the Repoze Project source repository (Committed Code) must be governed by the Repoze Public License (http://repoze.org/LICENSE.txt, aka "the RPL") or another license acceptable to Agendaless Consulting. Until Agendaless Consulting declares in writing an acceptable license other than the RPL, only the RPL shall be used. A list of exceptions is detailed within the "Licensing Exceptions" section of this document, if one exists. Representations, Warranty, and Indemnification ---------------------------------------------- Contributor represents and warrants that the Committed Code does not violate the rights of any person or entity, and that the Contributor has legal authority to enter into this Agreement and legal authority over Contributed Code. Further, Contributor indemnifies Agendaless Consulting against violations. Cryptography ------------ Contributor understands that cryptographic code may be subject to government regulations with which Agendaless Consulting and/or entities using Committed Code must comply. Any code which contains any of the items listed below must not be checked-in until Agendaless Consulting staff has been notified and has approved such contribution in writing. - Cryptographic capabilities or features - Calls to cryptographic features - User interface elements which provide context relating to cryptography - Code which may, under casual inspection, appear to be cryptographic. Notices ------- Contributor confirms that any notices required will be included in any Committed Code. Licensing Exceptions ==================== None. List of Contributors ==================== The below-signed are contributors to a code repository that is part of the project named "repoze.who". Each below-signed contributor has read, understand and agrees to the terms above in the section within this document entitled "Repoze Project Contributor Agreement" as of the date beside his or her name. Contributors ------------ - Tres Seaver, 2011/02/22 - Atsushi Odagiri, 2012/03/22 - Chandrashekar Jayaraman, 2012/11/09 repoze.who-2.2/docs/0000775000175000017500000000000012145723513014316 5ustar tseavertseaverrepoze.who-2.2/docs/use_cases.rst0000644000175000017500000001301711530747412017023 0ustar tseavertseaver:mod:`repoze.who` Use Cases =========================== How should an application interact with :mod:`repoze.who`? There are three main scenarios: Middleware-Only Use Cases ------------------------- Examples of using the :mod:`repoze.who` middleware, without explicitly using its API. Simple: Bug Tracker with ``REMOTE_USER`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This application expects the ``REMOTE_USER`` variable to be set by the middleware for authenticated requests. It allows the middleware to handle challenging the user when needed. In protected views, such as those which allow creating or following up to bug reports: - Check ``environ['REMOTE_USER']`` to get the authenticated user, and apply any application-specific policy (who is allowed to edit). - If the access check fails because the user is not yet authenticated, return an 401 Unauthorized response. - If the access check fails for authenticated users, return a 403 Forbidden response. Note that the application here doesn't depend on :mod:`repoze.who` at all: it would work identically if run behind Apache's ``mod_auth``. The ``Trac`` application works exactly this way. The middleware can be configured to suit the policy required for the site, e.g.: - challenge / identify using HTTP basic authentication - authorize via an ``.htaccces``-style file. More complex: Wiki with ``repoze.who.identity`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This application use the ``repoze.who.identity`` variable set in the WSGI environment by the middleware for authenticated requests. The application still allows the middleware to handle challenging the user when needed. The only difference from the previous example is that protected views, such as those which allow adding or editing wiki pages, can use the extra metadata stored inside ``environ['repoze.who.identity']`` (a mapping) to make authorization decisions: such metadata might include groups or roles mapped by the middleware onto the user. API-Only Use Cases ------------------ Examples of using the :mod:`repoze.who` API without its middleware. Simple: Wiki with its own login and logout views. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This application uses the :mod:`repoze.who` API to compute the authenticated user, as well as using its ``remember`` API to set headers for cookie-based authentication. In each view: - Call ``api.authenticate`` to get the authenticated user. - Show a ``login`` link for non-authenticated requests. - Show a ``logout`` link for authenticated requests. - Don't show "protected" links for non-authenticated requests. In protected views, such as those which allow adding or editing wiki pages: - Call ``api.authenticate`` to get the authenticated user; check the metadata about the user (e.g., any appropriate roles or groups) to verify access. - If the access check fails because the user is not yet authenticated, redirect to the ``login`` view, with a ``came_from`` value of the current URL. - If the access check fails for authenticated users, return a 403 Forbidden response. In the login view: - For ``GET`` requests, show the login form. - For ``POST`` requests, validate the login and password from the form. If successful, call ``api.remember``, and append the returned headers to your response, which may also contain, e.g., a ``Location`` header for a redirect to the ``came_from`` URL. In this case, there will be no authenticator plugin which knows about the login / password at all. In the logout view: - Call ``api.forget`` and append the headers to your response, which may also contain, e.g., a ``Location`` header for a redirect to the ``came_from`` URL after logging out. More complex: multiple applications with "single sign-on" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In this scenario, authentication is "federated" across multiple applications, which delegate to a central "login application." This application verifies credentials from the user, and then uses headers or other tokens to communicate the verified identity to the delegating application. In the login application: - The SSO login application works just like the login view described above: the difference is that the configured identifier plugins must emit headers from ``remember`` which can be recognized by their counterparts in the other apps. In the non-login applications: - Challenge plugins here must be configured to implement the specific SSO protocol, e.g. redirect to the login app with information in the query string (other protocols might differ). - Identifer plugins must be able to "crack" / consume whatever tokens are returned by the SSO login app. - Authenticators will normally be no-ops (e.g., the ``auth_tkt`` plugin used as an authenticator). Hybrid Use Cases ---------------- Examples of using the :mod:`repoze.who` API in conjuntion with its middleware. Most complex: integrate Trac and the wiki behind SSO ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This example extends the previous one, but adds into the mix the requirement that one or more of the non-login applications (e.g., Trac) be used "off the shelf," without modifying them. Such applications can be plugged into the same SSO regime, with the addition of the :mod:``repoze.who`` middleware as an adapter to bridge the gap (e.g., to turn the SSO tokens into the ``REMOTE_USER`` required by Trac). In this scenario, the middleware would be configured identically to the API used in applications which do not need the middleware shim. repoze.who-2.2/docs/plugins.rst0000664000175000017500000006551612046037044016543 0ustar tseavertseaver.. _about_plugins: About :mod:`repoze.who` Plugins =============================== Plugin Types ------------ Identifier Plugins ++++++++++++++++++ You can register a plugin as willing to act as an "identifier". An identifier examines the WSGI environment and attempts to extract credentials from the environment. These credentials are used by authenticator plugins to perform authentication. Authenticator Plugins +++++++++++++++++++++ You may register a plugin as willing to act as an "authenticator". Authenticator plugins are responsible for resolving a set of credentials provided by an identifier plugin into a user id. Typically, authenticator plugins will perform a lookup into a database or some other persistent store, check the provided credentials against the stored data, and return a user id if the credentials can be validated. The user id provided by an authenticator is eventually passed to downstream WSGI applications in the "REMOTE_USER' environment variable. Additionally, the "identity" of the user (as provided by the identifier from whence the identity came) is passed along to downstream application in the ``repoze.who.identity`` environment variable. Metadata Provider Plugins +++++++++++++++++++++++++ You may register a plugin as willing to act as a "metadata provider" (aka mdprovider). Metadata provider plugins are responsible for adding arbitrary information to the identity dictionary for consumption by downstream applications. For instance, a metadata provider plugin may add "group" information to the the identity. Challenger Plugins ++++++++++++++++++ You may register a plugin as willing to act as a "challenger". Challenger plugins are responsible for initiating a challenge to the requesting user. Challenger plugins are invoked by :mod:`repoze.who` when it decides a challenge is necessary. A challenge might consist of displaying a form or presenting the user with a basic or digest authentication dialog. .. _default_plugins: Default Plugin Implementations ------------------------------ :mod:`repoze.who` ships with a variety of default plugins that do authentication, identification, challenge and metadata provision. .. module:: repoze.who.plugins.auth_tkt .. class:: AuthTktCookiePlugin(secret [, cookie_name='auth_tkt' [, secure=False [, include_ip=False]]]) An :class:`AuthTktCookiePlugin` is an ``IIdentifier`` and ``IAuthenticator`` plugin which remembers its identity state in a client-side cookie. This plugin uses the ``paste.auth.auth_tkt``"auth ticket" protocol. It should be instantiated passing a *secret*, which is used to encrypt the cookie on the client side and decrypt the cookie on the server side. The cookie name used to store the cookie value can be specified using the *cookie_name* parameter. If *secure* is False, the cookie will be sent across any HTTP or HTTPS connection; if it is True, the cookie will be sent only across an HTTPS connection. If *include_ip* is True, the ``REMOTE_ADDR`` of the WSGI environment will be placed in the cookie. Normally, using the plugin as an identifier requires also using it as an authenticator. .. note:: Using the *include_ip* setting for public-facing applications may cause problems for some users. `One study `_ reports that as many as 3% of users change their IP addresses legitimately during a session. .. module:: repoze.who.plugins.basicauth .. class:: BasicAuthPlugin(realm) A :class:`BasicAuthPlugin` plugin is both an ``IIdentifier`` and ``IChallenger`` plugin that implements the Basic Access Authentication scheme described in :rfc:`2617`. It looks for credentials within the ``HTTP-Authorization`` header sent by browsers. It challenges by sending an ``WWW-Authenticate`` header to the browser. The single argument *realm* indicates the basic auth realm that should be sent in the ``WWW-Authenticate`` header. .. module:: repoze.who.plugins.htpasswd .. class:: HTPasswdPlugin(filename, check) A :class:`HTPasswdPlugin` is an ``IAuthenticator`` implementation which compares identity information against an Apache-style htpasswd file. The *filename* argument should be an absolute path to the htpasswd file' the *check* argument is a callable which takes two arguments: "password" and "hashed", where the "password" argument is the unencrypted password provided by the identifier plugin, and the hashed value is the value stored in the htpasswd file. If the hashed value of the password matches the hash, this callable should return True. A default implementation named ``crypt_check`` is available for use as a check function (on UNIX) as ``repoze.who.plugins.htpasswd:crypt_check``; it assumes the values in the htpasswd file are encrypted with the UNIX ``crypt`` function. .. module:: repoze.who.plugins.redirector .. class:: RedirectorPlugin(login_url, came_from_param, reason_param, reason_header) A :class:`RedirectorPlugin` is an ``IChallenger`` plugin. It redirects to a configured login URL at egress if a challenge is required . *login_url* is the URL that should be redirected to when a challenge is required. *came_from_param* is the name of an optional query string parameter: if configured, the plugin provides the current request URL in the redirected URL's query string, using the supplied parameter name. *reason_param* is the name of an optional query string parameter: if configured, and the application supplies a header matching *reason_header* (defaulting to ``X-Authorization-Failure-Reason``), the plugin includes that reason in the query string of the redirected URL, using the supplied parameter name. *reason_header* is an optional parameter overriding the default response header name (``X-Authorization-Failure-Reason``) which the plugin checks to find the application-supplied reason for the challenge. *reason_header* cannot be set unless *reason_param* is also set. .. module:: repoze.who.plugins.sql .. class:: SQLAuthenticatorPlugin(query, conn_factory, compare_fn) A :class:`SQLAuthenticatorPlugin` is an ``IAuthenticator`` implementation which compares login-password identity information against data in an arbitrary SQL database. The *query* argument should be a SQL query that returns two columns in a single row considered to be the user id and the password respectively. The SQL query should contain Python-DBAPI style substitution values for ``%(login)``, e.g. ``SELECT user_id, password FROM users WHERE login = %(login)``. The *conn_factory* argument should be a callable that returns a DBAPI database connection. The *compare_fn* argument should be a callable that accepts two arguments: ``cleartext`` and ``stored_password_hash``. It should compare the hashed version of cleartext and return True if it matches the stored password hash, otherwise it should return False. A comparison function named ``default_password_compare`` exists in the ``repoze.who.plugins.sql`` module demonstrating this. The :class:`SQLAuthenticatorPlugin`\'s ``authenticate`` method will return the user id of the user unchanged to :mod:`repoze.who`. .. class:: SQLMetadataProviderPlugin(name, query, conn_factory, filter) A :class:`SQLMetatadaProviderPlugin` is an ``IMetadataProvider`` implementation which adds arbitrary metadata to the identity on ingress using data from an arbitrary SQL database. The *name* argument should be a string. It will be used as a key in the identity dictionary. The *query* argument should be a SQL query that returns arbitrary data from the database in a form that accepts Python-binding style DBAPI arguments. It should expect that a ``__userid`` value will exist in the dictionary that is bound. The SQL query should contain Python-DBAPI style substitution values for (at least) ``%(__userid)``, e.g. ``SELECT group FROM groups WHERE user_id = %(__userid)``. The *conn_factory* argument should be a callable that returns a DBAPI database connection. The *filter* argument should be a callable that accepts the result of the DBAPI ``fetchall`` based on the SQL query. It should massage the data into something that will be set in the environment under the *name* key. Writing :mod:`repoze.who` Plugins --------------------------------- :mod:`repoze.who` can be extended arbitrarily through the creation of plugins. Plugins are of one of four types: identifier plugins, authenticator plugins, metadata provider plugins, and challenge plugins. Writing An Identifier Plugin ++++++++++++++++++++++++++++ An identifier plugin (aka an ``IIdentifier`` plugin) must do three things: extract credentials from the request and turn them into an "identity", "remember" credentials, and "forget" credentials. Here's a simple cookie identification plugin that does these three things :: class InsecureCookiePlugin(object): def __init__(self, cookie_name): self.cookie_name = cookie_name def identify(self, environ): from paste.request import get_cookies cookies = get_cookies(environ) cookie = cookies.get(self.cookie_name) if cookie is None: return None import binascii try: auth = cookie.value.decode('base64') except binascii.Error: # can't decode return None try: login, password = auth.split(':', 1) return {'login':login, 'password':password} except ValueError: # not enough values to unpack return None def remember(self, environ, identity): cookie_value = '%(login)s:%(password)s' % identity cookie_value = cookie_value.encode('base64').rstrip() from paste.request import get_cookies cookies = get_cookies(environ) existing = cookies.get(self.cookie_name) value = getattr(existing, 'value', None) if value != cookie_value: # return a Set-Cookie header set_cookie = '%s=%s; Path=/;' % (self.cookie_name, cookie_value) return [('Set-Cookie', set_cookie)] def forget(self, environ, identity): # return a expires Set-Cookie header expired = ('%s=""; Path=/; Expires=Sun, 10-May-1971 11:59:00 GMT' % self.cookie_name) return [('Set-Cookie', expired)] def __repr__(self): return '<%s %s>' % (self.__class__.__name__, id(self)) .identify ~~~~~~~~~ The ``identify`` method of our InsecureCookiePlugin accepts a single argument "environ". This will be the WSGI environment dictionary. Our plugin attempts to grub through the cookies sent by the client, trying to find one that matches our cookie name. If it finds one that matches, it attempts to decode it and turn it into a login and a password, which it returns as values in a dictionary. This dictionary is thereafter known as an "identity". If it finds no credentials in cookies, it returns None (which is not considered an identity). More generally, the ``identify`` method of an ``IIdentifier`` plugin is called once on WSGI request "ingress", and it is expected to grub arbitrarily through the WSGI environment looking for credential information. In our above plugin, the credential information is expected to be in a cookie but credential information could be in a cookie, a form field, basic/digest auth information, a header, a WSGI environment variable set by some upstream middleware or whatever else someone might use to stash authentication information. If the plugin finds credentials in the request, it's expected to return an "identity": this must be a dictionary. The dictionary is not required to have any particular keys or value composition, although it's wise if the identification plugin looks for both a login name and a password information to return at least {'login':login_name, 'password':password}, as some authenticator plugins may depend on presence of the names "login" and "password" (e.g. the htpasswd and sql ``IAuthenticator`` plugins). If an ``IIdentifier`` plugin finds no credentials, it is expected to return None. .remember ~~~~~~~~~ If we've passed a REMOTE_USER to the WSGI application during ingress (as a result of providing an identity that could be authenticated), and the downstream application doesn't kick back with an unauthorized response, on egress we want the requesting client to "remember" the identity we provided if there's some way to do that and if he hasn't already, in order to ensure he will pass it back to us on subsequent requests without requiring another login. The remember method of an ``IIdentifier`` plugin is called for each non-unauthenticated response. It is the responsibility of the ``IIdentifier`` plugin to conditionally return HTTP headers that will cause the client to remember the credentials implied by "identity". Our InsecureCookiePlugin implements the "remember" method by returning headers which set a cookie if and only if one is not already set with the same name and value in the WSGI environment. These headers will be tacked on to the response headers provided by the downstream application during the response. When you write a remember method, most of the work involved is determining *whether or not* you need to return headers. It's typical to see remember methods that compute an "old state" and a "new state" and compare the two against each other in order to determine if headers need to be returned. In our example InsecureCookiePlugin, the "old state" is ``cookie_value`` and the "new state" is ``value``. .forget ~~~~~~~ Eventually the WSGI application we're serving will issue a "401 Unauthorized" or another status signifying that the request could not be authorized. :mod:`repoze.who` intercepts this status and calls ``IIdentifier`` plugins asking them to "forget" the credentials implied by the identity. It is the "forget" method's job at this point to return HTTP headers that will effectively clear any credentials on the requesting client implied by the "identity" argument. Our InsecureCookiePlugin implements the "forget" method by returning a header which resets the cookie that was set earlier by the remember method to one that expires in the past (on my birthday, in fact). This header will be tacked onto the response headers provided by the downstream application. Writing an Authenticator Plugin +++++++++++++++++++++++++++++++ An authenticator plugin (aka an ``IAuthenticator`` plugin) must do only one thing (on "ingress"): accept an identity and check if the identity is "good". If the identity is good, it should return a "user id". This user id may or may not be the same as the "login" provided by the user. An ``IAuthenticator`` plugin will be called for each identity found during the identification phase (there may be multiple identities for a single request, as there may be multiple ``IIdentifier`` plugins active at any given time), so it may be called multiple times in the same request. Here's a simple authenticator plugin that attempts to match an identity against ones defined in an "htpasswd" file that does just that:: class SimpleHTPasswdPlugin(object): def __init__(self, filename): self.filename = filename # IAuthenticatorPlugin def authenticate(self, environ, identity): try: login = identity['login'] password = identity['password'] except KeyError: return None f = open(self.filename, 'r') for line in f: try: username, hashed = line.rstrip().split(':', 1) except ValueError: continue if username == login: if crypt_check(password, hashed): return username return None def crypt_check(password, hashed): from crypt import crypt salt = hashed[:2] return hashed == crypt(password, salt) An ``IAuthenticator`` plugin implements one "interface" method: "authentictate". The formal specification for the arguments and return values expected from these methods are available in the ``interfaces.py`` file in :mod:`repoze.who` as the ``IAuthenticator`` interface, but let's examine this method here less formally. .authenticate ~~~~~~~~~~~~~ The ``authenticate`` method accepts two arguments: the WSGI environment and an identity. Our SimpleHTPasswdPlugin ``authenticate`` implementation grabs the login and password out of the identity and attempts to find the login in the htpasswd file. If it finds it, it compares the crypted version of the password provided by the user to the crypted version stored in the htpasswd file, and finally, if they match, it returns the login. If they do not match, it returns None. .. note:: Our plugin's ``authenticate`` method does not assume that the keys ``login`` or ``password`` exist in the identity; although it requires them to do "real work" it returns None if they are not present instead of raising an exception. This is required by the ``IAuthenticator`` interface specification. Writing a Challenger Plugin +++++++++++++++++++++++++++ A challenger plugin (aka an ``IChallenger`` plugin) must do only one thing on "egress": return a WSGI application which performs a "challenge". A WSGI application is a callable that accepts an "environ" and a "start_response" as its parameters; see "PEP 333" for further definition of what a WSGI application is. A challenge asks the user for credentials. Here's an example of a simple challenger plugin:: from paste.httpheaders import WWW_AUTHENTICATE from paste.httpexceptions import HTTPUnauthorized class BasicAuthChallengerPlugin(object): def __init__(self, realm): self.realm = realm # IChallenger def challenge(self, environ, status, app_headers, forget_headers): head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm) if head[0] not in forget_headers: head = head + forget_headers return HTTPUnauthorized(headers=head) Note that the plugin implements a single "interface" method: "challenge". The formal specification for the arguments and return values expected from this method is available in the "interfaces.py" file in :mod:`repoze.who` as the ``IChallenger`` interface. This method is called when :mod:`repoze.who` determines that the application has returned an "unauthorized" response (e.g. a 401). Only one challenger will be consulted during "egress" as necessary (the first one to return a non-None response). .challenge ~~~~~~~~~~ The challenge method takes environ (the WSGI environment), 'status' (the status as set by the downstream application), the "app_headers" (headers returned by the application), and the "forget_headers" (headers returned by all participating ``IIdentifier`` plugins whom were asked to "forget" this user). Our BasicAuthChallengerPlugin takes advantage of the fact that the HTTPUnauthorized exception imported from paste.httpexceptions can be used as a WSGI application. It first makes sure that we don't repeat headers if an identification plugin has already set a "WWW-Authenticate" header like ours, then it returns an instance of HTTPUnauthorized, passing in merged headers. This will cause a basic authentication dialog to be presented to the user. Writing a Metadata Provider Plugin ++++++++++++++++++++++++++++++++++ A metadata provider plugin (aka an ``IMetadataProvider`` plugin) must do only one thing (on "ingress"): "scribble" on the identity dictionary provided to it when it is called. An ``IMetadataProvider`` plugin will be called with the final "best" identity found during the authentication phase, or not at all if no "best" identity could be authenticated. Thus, each ``IMetadataProvider`` plugin will be called exactly zero or one times during a request. Here's a simple metadata provider plugin that provides "property" information from a dictionary:: _DATA = { 'chris': {'first_name':'Chris', 'last_name':'McDonough'} , 'whit': {'first_name':'Whit', 'last_name':'Morriss'} } class SimpleMetadataProvider(object): def add_metadata(self, environ, identity): userid = identity.get('repoze.who.userid') info = _DATA.get(userid) if info is not None: identity.update(info) .add_metadata ~~~~~~~~~~~~~ Arbitrarily add information to the identity dict based in other data in the environment or identity. Our plugin adds ``first_name`` and ``last_name`` values to the identity if the userid matches ``chris`` or ``whit``. Known Plugins for :mod:`repoze.who` =================================== Plugins shipped with :mod:`repoze.who` -------------------------------------- See :ref:`default_plugins`. Deprecated plugins ------------------ The :mod:`repoze.who.deprecatedplugins` distribution bundles the following plugin implementations which were shipped with :mod:`repoze.who` prior to version 2.0a3. These plugins are deprecated, and should only be used while migrating an existing deployment to replacement versions. :class:`repoze.who.plugins.cookie.InsecureCookiePlugin` An ``IIdentifier`` plugin which stores identification information in an insecure form (the base64 value of the username and password separated by a colon) in a client-side cookie. Please use the :class:`AuthTktCookiePlugin` instead. :class:`repoze.who.plugins.form.FormPlugin` An ``IIdentifier`` and ``IChallenger`` plugin, which intercepts form POSTs to gather identification at ingress and conditionally displays a login form at egress if challenge is required. Applications should supply their own login form, and use :class:`repoze.who.api.API` to authenticate and remember users. To replace the challenger role, please use :class:`repoze.who.plugins.redirector.RedirectorPlugin`, configured with the URL of your application's login form. :class:`repoze.who.plugins.form.RedirectingFormPlugin` An ``IIdentifier`` and ``IChallenger`` plugin, which intercepts form POSTs to gather identification at ingress and conditionally redirects a login form at egress if challenge is required. Applications should supply their own login form, and use :class:`repoze.who.api.API` to authenticate and remember users. To replace the challenger role, please use :class:`repoze.who.plugins.redirector.RedirectorPlugin`, configured with the URL of your application's login form. Third-party Plugins ------------------- :class:`repoze.who.plugins.zodb.ZODBPlugin` This class implements the :class:`repoze.who.interfaces.IAuthenticator` and :class:`repoze.who.interfaces.IMetadataProvider` plugin interfaces using ZODB database lookups. See http://pypi.python.org/pypi/repoze.whoplugins.zodb/ :class:`repoze.who.plugins.ldap.LDAPAuthenticatorPlugin` This class implements the :class:`repoze.who.interfaces.IAuthenticator` plugin interface using the :mod:`python-ldap` library to query an LDAP database. See http://code.gustavonarea.net/repoze.who.plugins.ldap/ :class:`repoze.who.plugins.ldap.LDAPAttributesPlugin` This class implements the :class:`repoze.who.interfaces.IMetadataProvider` plugin interface using the :mod:`python-ldap` library to query an LDAP database. See http://code.gustavonarea.net/repoze.who.plugins.ldap/ :class:`repoze.who.plugins.friendlyform.FriendlyFormPlugin` This class implements the :class:`repoze.who.interfaces.IIdentifier` and :class:`repoze.who.interfaces.IChallenger` plugin interfaces. It is similar to :class:`repoze.who.plugins.form.RedirectingFormPlugin`, bt with with additional features: - Users are not challenged on logout, unless the referrer URL is a private one (but that’s up to the application). - Developers may define post-login and/or post-logout pages. - In the login URL, the amount of failed logins is available in the environ. It’s also increased by one on every login try. This counter will allow developers not using a post-login page to handle logins that fail/succeed. See http://code.gustavonarea.net/repoze.who-friendlyform/ :func:`repoze.who.plugins.openid.identifiers.OpenIdIdentificationPlugin` This class implements the :class:`repoze.who.interfaces.IIdentifier`, :class:`repoze.who.interfaces.IAuthenticator`, and :class:`repoze.who.interfaces.IChallenger` plugin interfaces using OpenId. See http://quantumcore.org/docs/repoze.who.plugins.openid/ :func:`repoze.who.plugins.openid.classifiers.openid_challenge_decider` This function provides the :class:`repoze.who.interfaces.IChallengeDecider` interface using OpenId. See http://quantumcore.org/docs/repoze.who.plugins.openid/ :class:`repoze.who.plugins.use_beaker.UseBeakerPlugin` This packkage provids a :class:`repoze.who.interfaces.IIdentifier` plugin using :mod:`beaker.session` cache. See http://pypi.python.org/pypi/repoze.who-use_beaker/ :class:`repoze.who.plugins.cas.main_plugin.CASChallengePlugin` This class implements the :class:`repoze.who.interfaces.IIdentifier` :class:`repoze.who.interfaces.IAuthenticator`, and :class:`repoze.who.interfaces.IChallenger` plugin interfaces using CAS. See http://pypi.python.org/pypi/repoze.who.plugins.cas :class:`repoze.who.plugins.cas.challenge_decider.my_challenge_decider` This function provides the :class:`repoze.who.interfaces.IChallengeDecider` interface using CAS. See http://pypi.python.org/pypi/repoze.who.plugins.cas/ :class:`repoze.who.plugins.recaptcha.captcha.RecaptchaPlugin` This class implements the :class:`repoze.who.interfaces.IAuthenticator` plugin interface, using the recaptch API. See http://pypi.python.org/pypi/repoze.who.plugins.recaptcha/ :class:`repoze.who.plugins.sa.SQLAlchemyUserChecker` User existence checker for :class:`repoze.who.plugins.auth_tkt.AuthTktCookiePlugin`, based on the SQLAlchemy ORM. See http://pypi.python.org/pypi/repoze.who.plugins.sa/ :class:`repoze.who.plugins.sa.SQLAlchemyAuthenticatorPlugin` This class implements the :class:`repoze.who.interfaces.IAuthenticator` plugin interface, using the the SQLAlchemy ORM. See http://pypi.python.org/pypi/repoze.who.plugins.sa/ :class:`repoze.who.plugins.sa.SQLAlchemyUserMDPlugin` This class implements the :class:`repoze.who.interfaces.IMetadataProvider` plugin interface, using the the SQLAlchemy ORM. See http://pypi.python.org/pypi/repoze.who.plugins.sa/ :class:`repoze.who.plugins.formcookie.CookieRedirectingFormPlugin` This class implements the :class:`repoze.who.interfaces.IIdentifier` and :class:`repoze.who.interfaces.IChallenger` plugin interfaces, similar to :class:`repoze.who.plugins.form.RedirectingFormPlugin`. The plugin tracks the ``came_from`` URL via a cookie, rather than the query string. See http://pypi.python.org/pypi/repoze.who.plugins.formcookie/ repoze.who-2.2/docs/changes.rst0000644000175000017500000000003412127572663016463 0ustar tseavertseaver.. include:: ../CHANGES.rst repoze.who-2.2/docs/api.rst0000644000175000017500000001245011733434736015631 0ustar tseavertseaver.. _api_narrative: Using the :mod:`repoze.who` Application Programming Interface (API) =================================================================== .. _without_middleware: Using :mod:`repoze.who` without Middleware ------------------------------------------ An application which does not use the :mod:`repoze.who` middleware needs to perform two separate tasks to use :mod:`repoze.who` machinery: - At application startup, it must create an :class:`repoze.who.api:APIFactory` instance, populating it with a request classifier, a challenge decider, and a set of plugins. It can do this process imperatively (see :ref:`imperative_configuration`), or using a declarative configuration file (see :ref:`declarative_configuration`). For the latter case, there is a convenience function, :func:`repoze.who.config.make_api_factory_with_config`: .. code-block:: python # myapp/run.py from repoze.who.config import make_api_factory_with_config who_api_factory = None def startup(global_conf): global who_api_factory who_api_factory = make_api_factory_with_config(global_conf, '/path/to/who.config') - When it needs to use the API, it must call the ``APIFactory``, passing the WSGI environment to it. The ``APIFactory`` returns an object implementing the :class:`repoze.who.interfaces:IRepozeWhoAPI` interface. .. code-block:: python # myapp/views.py from myapp.run import who_api_factory def my_view(context, request): who_api = who_api_factory(request.environ) - Calling the ``APIFactory`` multiple times within the same request is allowed, and should be very cheap (the API object is cached in the request environment). .. _middleware_api_hybrid: Mixed Use of :mod:`repoze.who` Middleware and API ------------------------------------------------- An application which uses the :mod:`repoze.who` middleware may still need to interact directly with the ``IRepozeWhoAPI`` object for some purposes. In such cases, it should call :func:`repoze.who.api:get_api`, passing the WSGI environment. .. code-block:: python from repoze.who.api import get_api def my_view(context, request): who_api = get_api(request.environ) Alternately, the application might configure the ``APIFactory`` at startup, as above, and then use it to find the API object, or create it if it was not already created for the current request (e.g. perhaps by the middleware): .. code-block:: python def my_view(context, request): who_api = context.who_api_factory(request.environ) .. _writing_custom_login_view: Writing a Custom Login View --------------------------- :class:`repoze.who.api.API` provides a helper method to assist developers who want to control the details of the login view. The following BFG example illustrates how this API might be used: .. code-block:: python :linenos: def login_view(context, request): message = '' who_api = get_api(request.environ) if 'form.login' in request.POST: creds = {} creds['login'] = request.POST['login'] creds['password'] = request.POST['password'] authenticated, headers = who_api.login(creds) if authenticated: return HTTPFound(location='/', headers=headers) message = 'Invalid login.' else: # Forcefully forget any existing credentials. _, headers = who_api.login({}) request.response_headerlist = headers if 'REMOTE_USER' in request.environ: del request.environ['REMOTE_USER'] return {'message': message} This application is written as a "hybrid": the :mod:`repoze.who` middleware injects the API object into the WSGI enviornment on each request. - In line 4, this application extracts the API object from the environ using :func:`repoze.who.api:get_api`. - Lines 6 - 8 fabricate a set of credentials, based on the values the user entered in the form. - In line 9, the application asks the API to authenticate those credentials, returning an identity and a set of respones headers. - Lines 10 and 11 handle the case of successful authentication: in this case, the application redirects to the site root, setting the headers returned by the API object, which will "remember" the user across requests. - Line 13 is reached on failed login. In this case, the headers returned in line 9 will be "forget" headers, clearing any existing cookies or other tokens. - Lines 14 - 16 perform a "fake" login, in order to get the "forget" headers. - Line 18 sets the "forget" headers to clear any authenticated user for subsequent requests. - Lines 19 - 20 clear any authenticated user for the current request. - Line 22 returns any message about a failed login to the rendering template. .. _interfaces: Interfaces ---------- .. automodule:: repoze.who.interfaces .. autointerface:: IAPIFactory :members: .. autointerface:: IAPI :members: .. autointerface:: IPlugin :members: .. autointerface:: IRequestClassifier :members: .. autointerface:: IChallengeDecider :members: .. autointerface:: IIdentifier :members: .. autointerface:: IAuthenticator :members: .. autointerface:: IChallenger :members: .. autointerface:: IMetadataProvider :members: repoze.who-2.2/docs/Makefile0000644000175000017500000000434011530747412015756 0ustar tseavertseaver# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d .build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html web pickle htmlhelp latex changes linkcheck help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " pickle to make pickle files (usable by e.g. sphinx-web)" @echo " htmlhelp to make HTML files and a HTML help project" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " changes to make an overview over all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" clean: -rm -rf .build/* html: mkdir -p .build/html .build/doctrees $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) .build/html @echo @echo "Build finished. The HTML pages are in .build/html." pickle: mkdir -p .build/pickle .build/doctrees $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) .build/pickle @echo @echo "Build finished; now you can process the pickle files or run" @echo " sphinx-web .build/pickle" @echo "to start the sphinx-web server." web: pickle htmlhelp: mkdir -p .build/htmlhelp .build/doctrees $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) .build/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in .build/htmlhelp." latex: mkdir -p .build/latex .build/doctrees $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) .build/latex @echo @echo "Build finished; the LaTeX files are in .build/latex." @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ "run these through (pdf)latex." changes: mkdir -p .build/changes .build/doctrees $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) .build/changes @echo @echo "The overview file is in .build/changes." linkcheck: mkdir -p .build/linkcheck .build/doctrees $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) .build/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in .build/linkcheck/output.txt." repoze.who-2.2/docs/conf.py0000644000175000017500000001366111733434736015632 0ustar tseavertseaver# -*- coding: utf-8 -*- # # repoze.who documentation build configuration file, created by # sphinx-quickstart on Wed Jul 16 13:18:14 2008. # # This file is execfile()d with the current directory set to its containing dir. # # The contents of this file are pickled, so don't put values in the namespace # that aren't pickleable (module imports are okay, they're removed automatically). # # All configuration values have a default value; values that are commented out # serve to show the default value. import sys, os # If your extensions are in another directory, add it here. If the directory # is relative to the documentation root, use os.path.abspath to make it # absolute, like shown here. parent = os.path.dirname(os.path.dirname(__file__)) sys.path.append(os.path.abspath(parent)) wd = os.getcwd() os.chdir(parent) os.system('%s setup.py test -q' % sys.executable) os.chdir(wd) for item in os.listdir(parent): if item.endswith('.egg'): sys.path.append(os.path.join(parent, item)) # General configuration # --------------------- # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.doctest', 'repoze.sphinx.autointerface', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['.templates'] # The suffix of source filenames. source_suffix = '.rst' # The master toctree document. master_doc = 'index' # General substitutions. project = 'repoze.who' copyright = '2008, Agendaless Consulting' # The default replacements for |version| and |release|, also used in various # other places throughout the built documents. # # The short X.Y version. version = '2.0a4' # The full version, including alpha/beta/rc tags. release = version # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. today_fmt = '%B %d, %Y' # List of documents that shouldn't be included in the build. #unused_docs = [] # List of directories, relative to source directories, that shouldn't be searched # for source files. #exclude_dirs = [] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # Options for HTML output # ----------------------- # The style sheet to use for HTML and HTML Help pages. A file of that name # must exist either in Sphinx' static/ path, or in one of the custom paths # given in html_static_path. html_style = 'repoze.css' # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (within the static path) to place at the top of # the sidebar. html_logo = '.static/logo_hi.gif' # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['.static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_use_modindex = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, the reST sources are included in the HTML build as _sources/. #html_copy_source = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = '' # Output file base name for HTML help builder. htmlhelp_basename = 'repozebfgdoc' # Options for LaTeX output # ------------------------ # The paper size ('letter' or 'a4'). #latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). #latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, document class [howto/manual]). latex_documents = [ ('index', 'repozebfg.tex', 'repoze.who Documentation', 'Agendaless Consulting', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. latex_logo = '.static/logo_hi.gif' # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # Additional stuff for the LaTeX preamble. #latex_preamble = '' # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_use_modindex = True repoze.who-2.2/docs/index.rst0000644000175000017500000000376311733434736016176 0ustar tseavertseaver.. _index: *************************************************** :mod:`repoze.who` -- WSGI Authentication Middleware *************************************************** :Author: Chris McDonough / Tres Seaver :Version: |version| .. module:: repoze.who :synopsis: WSGI authentication middleware .. topic:: Overview :mod:`repoze.who` is an identification and authentication framework for arbitrary WSGI applications. It can be used as WSGI middleware, or as an API from within a WSGI application. :mod:`repoze.who` is inspired by Zope 2's Pluggable Authentication Service (PAS) (but :mod:`repoze.who` is not dependent on Zope in any way; it is useful for any WSGI application). It provides no facility for authorization (ensuring whether a user can or cannot perform the operation implied by the request). This is considered to be the domain of the WSGI application. It attempts to reuse implementations from ``paste.auth`` for some of its functionality. Sections ======== .. toctree:: :maxdepth: 2 narr use_cases middleware api configuration plugins Change History ============== .. toctree:: :maxdepth: 2 changes Support and Development ======================= To report bugs, use the `Repoze bug tracker `_. If you've got questions that aren't answered by this documentation, contact the `Repoze-dev maillist `_ or join the `#repoze IRC channel `_. Browse and check out tagged and trunk versions of :mod:`repoze.who` via the `Repoze Subversion repository `_. To check out the trunk via Subversion, use this command:: svn co http://svn.repoze.org/repoze.who/trunk repoze.who To find out how to become a contributor to :mod:`repoze.who`, please see the `contributor's page `_. Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` repoze.who-2.2/docs/configuration.rst0000664000175000017500000003200512136473741017724 0ustar tseavertseaver.. _configuration_points: Configuring :mod:`repoze.who` ============================= Configuration Points -------------------- Classifiers +++++++++++ :mod:`repoze.who` "classifies" the request on middleware ingress. Request classification happens before identification and authentication. A request from a browser might be classified a different way than a request from an XML-RPC client. :mod:`repoze.who` uses request classifiers to decide which other components to consult during subsequent identification, authentication, and challenge steps. Plugins are free to advertise themselves as willing to participate in identification and authorization for a request based on this classification. The request classification system is pluggable. :mod:`repoze.who` provides a default classifier that you may use. You may extend the classification system by making :mod:`repoze.who` aware of a different request classifier implementation. Challenge Deciders ++++++++++++++++++ :mod:`repoze.who` uses a "challenge decider" to decide whether the response returned from a downstream application requires a challenge plugin to fire. When using the default challenge decider, only the status is used (if it starts with ``401``, a challenge is required). :mod:`repoze.who` also provides an alternate challenge decider, ``repoze.who.classifiers.passthrough_challenge_decider``, which avoids challenging ``401`` responses which have been "pre-challenged" by the application. You may supply a different challenge decider as necessary. Plugins +++++++ :mod:`repoze.who` has core functionality designed around the concept of plugins. Plugins are instances that are willing to perform one or more identification- and/or authentication-related duties. Each plugin can be configured arbitrarily. :mod:`repoze.who` consults the set of configured plugins when it intercepts a WSGI request, and gives some subset of them a chance to influence what :mod:`repoze.who` does for the current request. .. note:: As of :mod:`repoze.who` 1.0.7, the ``repoze.who.plugins`` package is a namespace package, intended to make it possible for people to ship eggs which are who plugins as, e.g. ``repoze.who.plugins.mycoolplugin``. .. _imperative_configuration: Configuring :mod:`repoze.who` via Python Code --------------------------------------------- .. module:: repoze.who.middleware .. class:: PluggableAuthenticationMiddleware(app, identifiers, challengers, authenticators, mdproviders, classifier, challenge_decider [, log_stream=None [, log_level=logging.INFO[, remote_user_key='REMOTE_USER']]]) The primary method of configuring the :mod:`repoze.who` middleware is to use straight Python code, meant to be consumed by frameworks which construct and compose middleware pipelines without using a configuration file. In the middleware constructor: *app* is the "next" application in the WSGI pipeline. *identifiers* is a sequence of ``IIdentifier`` plugins, *challengers* is a sequence of ``IChallenger`` plugins, *mdproviders* is a sequence of ``IMetadataProvider`` plugins. Any of these can be specified as the empty sequence. *classifier* is a request classifier callable, *challenge_decider* is a challenge decision callable. *log_stream* is a stream object (an object with a ``write`` method) *or* a ``logging.Logger`` object, *log_level* is a numeric value that maps to the ``logging`` module's notion of log levels, *remote_user_key* is the key in which the ``REMOTE_USER`` (userid) value should be placed in the WSGI environment for consumption by downstream applications. An example configuration which uses the default plugins follows:: from repoze.who.middleware import PluggableAuthenticationMiddleware from repoze.who.interfaces import IIdentifier from repoze.who.interfaces import IChallenger from repoze.who.plugins.basicauth import BasicAuthPlugin from repoze.who.plugins.auth_tkt import AuthTktCookiePlugin from repoze.who.plugins.redirector import RedirectorPlugin from repoze.who.plugins.htpasswd import HTPasswdPlugin io = StringIO() salt = 'aa' for name, password in [ ('admin', 'admin'), ('chris', 'chris') ]: io.write('%s:%s\n' % (name, password)) io.seek(0) def cleartext_check(password, hashed): return password == hashed htpasswd = HTPasswdPlugin(io, cleartext_check) basicauth = BasicAuthPlugin('repoze.who') auth_tkt = AuthTktCookiePlugin('secret', 'auth_tkt') redirector = RedirectorPlugin('/login.html') redirector.classifications = {IChallenger:['browser'],} # only for browser identifiers = [('auth_tkt', auth_tkt), ('basicauth', basicauth)] authenticators = [('auth_tkt', auth_tkt), ('htpasswd', htpasswd)] challengers = [('redirector', redirector), ('basicauth', basicauth)] mdproviders = [] from repoze.who.classifiers import default_request_classifier from repoze.who.classifiers import default_challenge_decider log_stream = None import os if os.environ.get('WHO_LOG'): log_stream = sys.stdout middleware = PluggableAuthenticationMiddleware( app, identifiers, authenticators, challengers, mdproviders, default_request_classifier, default_challenge_decider, log_stream = log_stream, log_level = logging.DEBUG ) The above example configures the repoze.who middleware with: - Two ``IIdentifier`` plugins (auth_tkt cookie, and a basic auth plugin). In this setup, when "identification" needs to be performed, the auth_tkt plugin will be checked first, then the basic auth plugin. The application is responsible for handling login via a form: this view would use the API (via :method:`remember`) to generate apprpriate response headers. - Two ``IAuthenticator`` plugins: the auth_tkt plugin and an htpasswd plugin. The auth_tkt plugin performs both ``IIdentifier`` and ``IAuthenticator`` functions. The htpasswd plugin is configured with two valid username / password combinations: chris/chris, and admin/admin. When an username and password is found via any identifier, it will be checked against this authenticator. - Two ``IChallenger`` plugins: the redirector plugin, then the basic auth plugin. The redirector auth will fire if the request is a ``browser`` request, otherwise the basic auth plugin will fire. The rest of the middleware configuration is for values like logging and the classifier and decider implementations. These use the "stock" implementations. .. note:: The ``app`` referred to in the example is the "downstream" WSGI application that who is wrapping. .. _declarative_configuration: Configuring :mod:`repoze.who` via Config File --------------------------------------------- :mod:`repoze.who` may be configured using a ConfigParser-style .INI file. The configuration file has five main types of sections: plugin sections, a general section, an identifiers section, an authenticators section, and a challengers section. Each "plugin" section defines a configuration for a particular plugin. The identifiers, authenticators, and challengers sections refer to these plugins to form a site configuration. The general section is general middleware configuration. To configure :mod:`repoze.who` in Python, using an .INI file, call the `make_middleware_with_config` entry point, passing the right-hand application, the global configuration dictionary, and the path to the config file :: from repoze.who.config import make_middleware_with_config who = make_middleware_with_config(app, global_conf, '/path/to/who.ini') :mod:`repoze.who`'s configuration file can be pointed to within a PasteDeploy configuration file :: [filter:who] use = egg:repoze.who#config config_file = %(here)s/who.ini log_file = stdout log_level = debug Below is an example of a configuration file (what ``config_file`` might point at above ) that might be used to configure the :mod:`repoze.who` middleware. A set of plugins are defined, and they are referred to by following non-plugin sections. In the below configuration, five plugins are defined. The form, and basicauth plugins are nominated to act as challenger plugins. The form, cookie, and basicauth plugins are nominated to act as identification plugins. The htpasswd and sqlusers plugins are nominated to act as authenticator plugins. :: [plugin:redirector] # identificaion and challenge use = repoze.who.plugins.redirector:make_plugin login_url = /login.html [plugin:auth_tkt] # identification and authentication use = repoze.who.plugins.auth_tkt:make_plugin secret = s33kr1t cookie_name = oatmeal secure = False include_ip = False [plugin:basicauth] # identification and challenge use = repoze.who.plugins.basicauth:make_plugin realm = 'sample' [plugin:htpasswd] # authentication use = repoze.who.plugins.htpasswd:make_plugin filename = %(here)s/passwd check_fn = repoze.who.plugins.htpasswd:crypt_check [plugin:sqlusers] # authentication use = repoze.who.plugins.sql:make_authenticator_plugin # Note the double %%: we have to escape it from the config parser in # order to preserve it as a template for the psycopg2, whose 'paramstyle' # is 'pyformat'. query = SELECT userid, password FROM users where login = %%(login)s conn_factory = repoze.who.plugins.sql:make_psycopg_conn_factory compare_fn = repoze.who.plugins.sql:default_password_compare [plugin:sqlproperties] name = properties use = repoze.who.plugins.sql:make_metadata_plugin # Note the double %%: we have to escape it from the config parser in # order to preserve it as a template for the psycopg2, whose 'paramstyle' # is 'pyformat'. query = SELECT firstname, lastname FROM users where userid = %%(__userid)s filter = my.package:filter_propmd conn_factory = repoze.who.plugins.sql:make_psycopg_conn_factory [general] request_classifier = repoze.who.classifiers:default_request_classifier challenge_decider = repoze.who.classifiers:default_challenge_decider remote_user_key = REMOTE_USER [identifiers] # plugin_name;classifier_name:.. or just plugin_name (good for any) plugins = auth_tkt basicauth [authenticators] # plugin_name;classifier_name.. or just plugin_name (good for any) plugins = auth_tkt htpasswd sqlusers [challengers] # plugin_name;classifier_name:.. or just plugin_name (good for any) plugins = redirector;browser basicauth [mdproviders] plugins = sqlproperties The basicauth section configures a plugin that does identification and challenge for basic auth credentials. The redirector section configures a plugin that does challenges. The auth_tkt section configures a plugin that does identification for cookie auth credentials, as well as authenticating them. The htpasswd plugin obtains its user info from a file. The sqlusers plugin obtains its user info from a Postgres database. The identifiers section provides an ordered list of plugins that are willing to provide identification capability. These will be consulted in the defined order. The tokens on each line of the ``plugins=`` key are in the form "plugin_name;requestclassifier_name:..." (or just "plugin_name" if the plugin can be consulted regardless of the classification of the request). The configuration above indicates that the system will look for credentials using the auth_tkt cookie identifier (unconditionally), then the basic auth plugin (unconditionally). The authenticators section provides an ordered list of plugins that provide authenticator capability. These will be consulted in the defined order, so the system will look for users in the file, then in the sql database when attempting to validate credentials. No classification prefixes are given to restrict which of the two plugins are used, so both plugins are consulted regardless of the classification of the request. Each authenticator is called with each set of identities found by the identifier plugins. The first identity that can be authenticated is used to set ``REMOTE_USER``. The mdproviders section provides an ordered list of plugins that provide metadata provider capability. These will be consulted in the defined order. Each will have a chance (on ingress) to provide add metadata to the authenticated identity. Our example mdproviders section shows one plugin configured: "sqlproperties". The sqlproperties plugin will add information related to user properties (e.g. first name and last name) to the identity dictionary. The challengers section provides an ordered list of plugins that provide challenger capability. These will be consulted in the defined order, so the system will consult the cookie auth plugin first, then the basic auth plugin. Each will have a chance to initiate a challenge. The above configuration indicates that the redirector challenger will fire if it's a browser request, and the basic auth challenger will fire if it's not (fallback). repoze.who-2.2/docs/.static/0000775000175000017500000000000012145723513015663 5ustar tseavertseaverrepoze.who-2.2/docs/.static/ingress.png0000644000175000017500000026202611530747412020052 0ustar tseavertseaverPNG  IHDR6 0N(sBIT|d pHYs 4tEXtSoftwarewww.inkscape.org< IDATxy`{' !!HTժ=ZVz{x+++h"[Ǐٳ*/_ף#Fe]f_5dر^6l.B8Ψmٲ%%%-~ uqFlݺǎӧa˖-8vXe6 SL1=xb & 11Xi&8q"b;ӦM,ˍAQY֭áC0`̜9ֿ6mΝ;sN89r$Dmf4MÖ-[ϽyyyDž^&?y$.\]v᪫W\سgklٲ)))7n\{Z^K,+гgOBII }Qc/݋cڴi_ [l(ȑ#1a„&Y^^{#GC"//Cm7nĊ+p!TTT`z jgtJFŋ~ֽ{w`.1XEEK*˗/g}5sg_kjj?6uT&IQfK.eC 1y=M+V KFF[pauMϦn#QSS~ӟ6Nի[|{kt{z2ݳgiܹsM˯ڨ)--e1Ν;+`|I4`: QPPnڪ#;wQF/^~eiiRϳ/sOe˖'F}뮳n&rm]p2Wf?XFFiOfO?4KKKOZZ۸qcY^^|æLb͙3mܸs=O>2W^yiyqq1[z5s: {خ]~s|>_D}>4iQ.11-\nvv-˿o-~?½kLec{w}7;z(boZƲK/Twy' ?MZPcnGl'< >4zc*o0۵kW,}{K.llԩQZ`ާO>nw^vEdee1k7c$IlܹԩSlǎ_d0L5j[l+//gB[Bd/!hFش/(lҥ1Ealcƌa| `lɒ%,;;TFeVXXhŋMex≈裏|`Oe999u?Dl?@~G,K{̘1|"}YS 6TfZcرc [\<iYxy衇ؒ%K؉'Xyy9{뭷f3O#Tfٲe媪VL4֭[7c7>N0!"ؼ{m\ҴΡC(.gܸq/n[UTTgy`cǎe!Dkٱci{'O6-?riw2AtNII۫gϞX 67~E1EQLe~mSGFk1YvmQv2w߿,yF#<:t(Lxw֫jZߚÇG}=K.5mgʔ)z?4Mccƌ1m#ZwMO>ĴpXd@ YnMM 16rHS?ܴ?lKUUb,1bDԺ4mgԩlѢE-Xl6p8?#={nӾ;(.o="{ヲ^z%:@H0:+ ysѣѭ[7<ҥK6l={!))x{Vף{xLϭ_޸|r?~xNO=i7Z̘1ø!A=5Y\~E[7C[>x|:E;ʞ~i9s/6+..w܁^z ӟ0uT. >nv:ux~Ŋ1bnV<ٳwyy$P!6=~M- m)t: @o!i9Sjkk{M?lhdggt:zMfSBe~_cƌP-GG'wq\=> z!9sQꫯ=p7n6222h"̝;?evW\믿 ,0}7L6 ;wĝwމw}x^4x0|S$z:ן'S ;?p@æC q|Ӳ>%Æ (SXX믿oٰZbj)YM\r%|>1&l\..2G}UUvc[meƳ{ oх:gt غu+뇹s>0~q<{oǑ#G?ݻw7-{=bݻcزe f͚ezMnr ϺN$Q! 6YR Sѣn'tquUUŭj;4f=т<l,ƺu{IQ؍V:;ؼy3?|w^(++È#nn+3;"ʔfNIIa_~%SUzX<{WSS}yVQQ/L?5jT16o޼&'?dL7))(ӫWjNYY)†jÇ#/̙3'>,Xn͛7;v,7pi%K4:C?П'n9Ҹ409s_mF>l6Ml0`fb˗/guuuLQj*e7[w(tSN5}نt}躪_~kوȮzfӺ[vvvĬ] =XVdn]ǧ[c|8&Nq(Xf ^ygM>|8z)]?'fرe9InFO̙;k׮Ŷm۰w^<.fbiӧcXjmۆ۷CUUc?_|1#ѣGp^u&Q <}tF-Geu]n+8o]w݅?N{bXd >scL 7܀rHK.4]hӄ+1~x|K.$$;Ǐ޽{o>ݻȑ#I&E#b ٳ{5n555߿?rss b>,5qJ͖$,& /t\#^{qy'bٲee^/M/xnMCH,Ä***Lg/dZqM7y7 '$P!lڴrD;( l!$%%%(~z<#\Ʃ71FH16]TWWcXji|Ejj*&N؁5$$cر8Ǐ$Iشi6lpW{JMHg@ 6`э.ONNnD~Ho6͛Bcƴ4dggcذa뮻".@H`Cڅő#G]."rrr]"^/TU!(B!$naB! 6Bl!7(B!$nP!BHܠ`C!AB!q !BB! 6Bl!7(B!$nP!BHܠ`C!AB!q !BB! 6Bl!7(B!$nP!BHܠ`C!AB!q !BB! 6Bl!7Ď@G`utq]B!]T\JhJ1 6qx+xBH{` 4Z n_5#`-h;2H]%B!qcUH芴_\xj:F9s=恗zutU!ā 6zaP'>(s^L vIS.i^J%T݁{Kt*G!$.EWTFUQ{t4ac/8aO8vNLC{޺`,V%GTGB! [l4 P=.0VYC_V5go״>B\Yd}ho<@%x^hmPg2/gB91lBCf<'1FBd0Vl~}Wk9]n-B=uY?4Xו$Bwf|R$[NF !Ą=85Mc(1@ot9wv;Lj wRcunUFlBi 6@}h4<{r'З#,tth𝝋@Q{`QW[%˰:eB*b27 {Lqhk]9IRKjj5Q<[M !ĝ 6jcP5@(Jiˁ4mPH N w,%R{?!.%&M0hUQw{92Hma:S=_L(p IDATJhc{ .Ӣ_1ح շTBHs0q\C7uY{*>\|vV@VLKǤ~> -9኉p9Z[O=x~HKlZw8B2tņ [K:=l%Nzz}Y_/JFw3,s9sFmvR "o =%B|-~Gyk4oӫp{(ԪPԪu!"&LXwA%%Jp9E*fG%.֓JK*Ȳd4_QZdܗs7_\DݎɣO C-dlaa !-6@H+2VbpgJ}Xo_!' ؾNxpwU8[/]hZZ5o ָӿ>)^q?J,_]jT&2\=Ձsk\6XFV`wr"rtuS/Kb.gdc\=̚owTd>gg6aX7Wa1ྟFL?^PY'Of吔(!̸܉{8uZ}";* ω~nDwӰI!t10pqoƥ`j8 pÆ#$v?+ז1eS`߉kfd{Hf+_/crxd=8dIQk S!1*$vtEZ*89_ AuM5+awH~9xVߚѵՏ b)!C.MnY7s-^ 7yjr{0k'JsWA!$G 8Y!Q!g,pOi!tlHpx VB,(tFGvf<8!D{B!S`*XzD[d# (@@awa@YMB!]I_jQVCN6cn0$ر z[r5?O7h Rda>7ΔcPGCve 8q*G`Cj>2gBopS$$ӏ!AGNp'k0#KƤ㫵8`lUW_߇ݥxld3Fl %n.?գ4{ qJzYHJp/;1bP?4_Dcil4^ NfMK܄Bbp>̈|-ъq"M]3{ jk5̚c3#jiw /(M5̚2,e+OBC}YqUPtX8 DZ}1/U'_#˂*i Aa:AE%p؂ ;8y[00ǁ|'zeZ pa@ B:? 6va&Tn:w2~f~tu~Un W =XCլxjj1p 5t/}cLCKGA>V}vc9v}upq1JNB,n$ZYjꑡe VX SmHK::\T:U=UHiK|` X,b pPUfQIeC6x}*F OjX?[qUYypjTt B!8K}u7}z90tXߎqE#0nt:>{ڄ啾m?, 'O{G賓(ǕS3'患ظc:ża:a7`XemþCny "~rkS !Ďl8 mhVs! `*X-zt`T@Kw;2/r3\1)lZVL>>AVWd"=Uƪ58zQbRe ĉS^Ժdɘ>95t}LlZn(] $&Q8X%ĎHK1Ơi<k!W]  @B 60-WxGv9}wa`.c`hvm~" KDNs]Q`!.fQ0zEO`'JsWǛO!$| q2vp\WRCK֑ b:p0@xNShb0fgA:nX$8U5ㅘiD$҉l8Nl l:(('P;g)3Ætn.@3@'C BU1yL}_s< S]ݐ7W\yiPjhɫÇBi 6VhѸr5PM`X]Փ4x82~Q`с"b.08 }Qs2uU17N~7 p%f@dJ|u{"r5W?B!1-MqyC$~ U@}I/8V xmҾ|yV["`O7`L%'9\TBH! "DI(oJ 5#YM:OcF )tq!ˍ{̴Np !Fསʳͅ-f HX6 {;"BZ,& CȲ Պu$KK`s (-ϖ3k45';zqPd`R6,D{'t?$Gbna 3 ׃ZTTV$Ŀwg?r AVp9s iJ%39g17B!lMӠ( |>n7jjQQY pU8,el;.δ@Jִg J$l!|1<;8F$XA/Ar QYji;!Fc24.C><_B !*&M~7q6LDQ,ɨq%И33 }1B!uEŀn 1X,8;VBHӥ c[PcL43FJJ: tu ހBiq \,djлnhZgwd8 !"nMxx30Vb#1cPTT˅I&59q_gqF ~p!|WqlM42?vCٳ#11v ~(r'ZyjeX ȌEZvE+ˢ2B7lE]X]}/(sm.}sЃR?ғµ` QpB!&jE?[[7P OWC <а MƶZY {! > _l*^D$Aq6(Bi^̈́Y*lŞ5ւ(ԜX?3 q`n,#Ǯ `cԠnsuS !tN1l'SU,\^Vbu)إnjGB qx6L!nZm4MC@ r؞11f+g!| jnuoYC-:k6-6ƅ3)BiFL /pz<-> ~+(?2@D%2,Nwpx_PoPŅ?ԻT2X9I)oȗ \߾A22Jo$xM9v)j2eax!_?K5\Bi 6.)q;6~d2kD d8Rx4ڡ`R]gm7-} S~L&<<^݋HMpRAEcd'x*~~^!-pC!%b:4\BAcc4{k:]/J) soUu?CAuH/‘jTh(="apH+4Tx Z= D;>qpDxOCb/wc*ܥ*T8;>o .OD8Q+|Ph&(d ` OGn1x:*T䈨9JFl51P-,[m_C5_}]B!R Mf m]0޺qCfXR>h}^n`t 2kXm~cdx4k W5V6N߳E ;يS;ȝlIHkRػ8oƠ+ӳOl 򨊱w9 ȜtzppCEoMLh g1fLCH]QBZ,f :*4EߖЂ1' p3x*4h>nbOVK҉=yZe̗oe E%C~hӇHz8c@B5U$d (;ɣ~C%b;"/!%zƜ5Lo CB-1l G.ӐCht-TEP z 2VdCэȋBqȻڪ/Ǯ<*aV͌_ֳ@$g89#䳚֭a%`+~AJA&w6 ]?UX= 5BZ#MP[E+m #?نi0u9\SБ5~SAwex5' Ehr qz[TXJ-JI;GѲ:ZÖW a̍~pJoucK+*F!FN׎򮶢䀂]y:u~Q^8ؓxTԻiO8)`*S[ šJQaqx@vÑ# =Olo|]£Pi=GȨ<(>}ŀdl>B9Ŧ-%0.v~kT?C^ψ2 N`>\Zc^ IDATjٻċ'S} )DEh/Y`MRsEdIjso(5V-8zѳ@2|jJRyWY>BqP@Q22a@b!t41V + רxܨWE]PIѻ\Z~l!3bzY[7>_`O[eB[Lu >7>i]˯?>Ώf˚[xpo5 "R7H{7bp V^U -`M!7 61sqr`NW"m]B!-F&,s>VO"y R"Nl}B!gM]xNUtA#gnB!g i Nȼg5!rQ!N,8 vphBH,`Z ,RK< oEp@,ӳ !A:ơ[rJéh!( 6gARP}JAR_ E<`yڡ(v Az 6mTJp ;Zȝd`PGaYpݪS;8 =\>H az4VP~D 't n]KW܄BHgA<'B$ sb!yNWp8Ey{bAߑvxk~-0]B2Tb;U8@8e6F /@^` Nn p c:b56p#lN9#CD!,$+C@bf.I!tfl"px $ ꢎ4 z8UpOHV};V>€ SQyҏ=5˾H%Ae*v.հv<ࠂh`^-G  U/C<6ctػz9$(•*O!< 6DC-8ۋۻF~X{:?$ylN Z94Ru4vXsqd\U_ZCbQh-Ut ~{ȯI^N/q)dnH7!b:/BY\YRSR>=ڝzSP1X|o~+:ݍm%/Nf;D!Uo9Gsӳg:q=[|_RB:gͮtӇs4l.x 84C!>k]!NTMrcٞbT3|̓_„ 12xIg1KM092| %,L G+B0kjN)NX/9Jy޿EBVEF)R z]·(,Ets]"S~翝l+ 7fO~y;DnZ\} ^$ʒ{KՔ><΁'~΢wKe>9Ez6z؞*12q\s?P=6!Kv/ŷ XgB]fYnhZ_ýd48kS}-[ l{k|45tMMdWVojp#b)ں)qSh&N~R5br4S㣵IBXml7;4cpَf[FG&NթL4֪ltkS!wO Rp0Zq" 0uyg;݉I%Iq:sf-ecY}٘݅Byu1Ok%׆0BS#ܬаhڏ؉ V}8m؎jF: !X6IF-mYlnoq`8r2K)bf^Ggt&5Opixyc;XUh!%b2nr˲\6c'*L  xx&2+kPYTPREL*?R0zH 6oO=uq۲d&g4B!kU Jc㰥 \q; 8oa5[RV? ݊f<9DiGwvIgHzض]o}RBq:ml+6m.sT CCӣ!tZ؎^5aN+ VbJʼnSq\2_L.%͒NIRm'kMQB!b(c(P8+1c Q!Ri#_ѱEh+>/扻Q蠳.::;(f()4-JBq:m]FSv=0! CKu/'CkjyK)| \l6GP\>O6i 4v}7HB8m_=FQDEeJSLLN211z!qC}6x$ep]T*E*&ɒe`J<۶ڪ`#b1ںbSu]PIPQ:8.)?E6rLa5Ćĵ.r^$ Zad8~PTt*M&%N'S)ǩ7?IBj`puƦԩJ.)aHajaK%n}ۭttt,1T-`Un۶m;xI)|}jYۅBfLPOL\T^!DXflBsEY!!BlBjHB!Ī!F!!BlBjHB!Ī!F!!BlBjHB!Ī!F!!BlBjHB!Ī!F!!BlBjHB!Ī!F!!BlBjHB!Ī!f3ưo߾y_۳g|EB!&fSJn&jo.圞i@) 4J)UKp#LMsSS-DQDDQp1f}g>׹L -|Zg^Q8St zs6wM <2@h²-,])՘F: !X]RZS0̊BTbD U)vE2wMf8l_)_$ɹ]֙!l`sGcűp+Ἧ/Gͥ~~ZgHg)ۃz\!LHST+Ǒ)|vޓtܭE{C^0psV۫<+M.TG. !gJ9tLz/bbx2 L,.'ۗ^IXV|,(_d_/Emyk%:7X ^_O1y!ۧy-/q k-)rJ1,8j?|ˮJL%%KN7j7%ATr+>"թpLJd8|P]O?kȻ}IB!V 6ȫX\T·yJ @X2HmYsMKW[$U^u^L^Oy9ECff, m)t*_rF9T@"?"tq:J!X)zSbL{Wf8L[ӹJ6d{-&Out`#~͡*l~ܝV,jͧf*!; 6(;{w$!an  ~#@h쯓Km=fEXK[)(42(%!h lΑ܀՟IZfSBBKWfC/Fl[ ͏ QuK`ZW<)F \fc9 L2|۳28VedI!E9FWOMjOXn#(( ̿;fM&g-*vυ6[f@DnRgQ-2ݚޞ)\^#d !mFYM'I+n I+kj3Sv8QxO7*!m@<G{Xʭ,9̷1IЉF#"kvV6JXIR3\2#={im!MIiG˹d(46Z7U_Ts`cHZ>)QF+ F{*8fXwY|gB!ډVV+YFPڗ)#4:׺ ,!hGqQ:3h,!,8<+%BU 6Nr(7Gh8MSJRG!XUlNw#oTinB,UTF܉6muaŔvA{}WIjTnB,U[w2;u  yUg@ACuՃV4%!X 67:`v[}[e:._XX(M6ŃXeY([7F&BHml֚uً믍JM9|xQ"S#IYM(۶?IMG!X6VjŖl&NVY{(;~7!΍4LL$?wr88ضBdml;BR,F~O}ÿM f^Ggtv6m !x])c(PX YH8 (LNN02:3cN1nsMYY H+ǹe TDe:j(e빨x=utwuMWgs9R4bN}4K !8Ԇכ, DZ}L:Ž7ko{>˕cHnru(xapͬo QP ˑdvZ!X@jccCT*2LLN0>68'8ĸ:HYM,7Qxa7頟\4H_K:!͑) yrTt:M&Ojj;B~<}mNI5vR%HRK%* AAjLsk|_x;֭[KYJVL‰.yk=<<\u\Z#b)>pcc׫ec;6Yzo IDATRA@Mv.^MQLNzREJkt} u\lNXI 5B!^Ul9PMy!qEqST6k|ߧ/յܗ(~6JimaYLؖ]_ĴyB> 5B!Ī 6LLԇ; 4fWk<#JREUj^4&TTIuFϽ!gjUhTn6Zс`bSƶvмB{WpBLsP#Xu7ZViyc0bV(jàYK9c-U`)F!ٰ*MMͲVY!h'kc FBkUfs@ۡYs3[;#\;o\F+kB6 !B 6mݛBsMM`#B,LM`#B,LB!V 6mD*6B!$ش 6B!$!bՐ`Fb#B,LM`#B,LB!V 6mD*6B!$ش 6B!$ش 6B!$!bՐ`Fb#B,LM`#B,LB!V 6mD*6B!$ش 6B!$ش 6B!$!bՐ`Fb#B,LMl$!$جpp'O/~qH!X$جp]" zԩS\[n/O!XQ$جpwq?00>>΍7ѣGBEcFr_ƍ9y$k׮T*qa:y4!bEM(+>|۶_,!bő`~|>Oc;\B!V 6m[n!}M6- !+>oY!B͊q|5tzF3tBdT2Z(̜ax\uU8}T}.BFr_h@S67oƲ,<^QJzi3KBq>`:/̬ 9Pp%}v(R 6 U:G 8B!7l^ 48nlÀc/?)v?_(=|uPJTjV(T38"!G!j#}lΡchz4q1Oث_'&Ġݽ־\ t_Q8Z';|FBB9 M:qɟݟ#M6s͛OՐ3+Tf pB!D`s,Cps=⨾mױs?u,]k=,G1|LXgsQsǹbкQ5ONs!XM$ؼF 4Lnj'?7vO:e+dmU J'_-QfgCRqY;e7UoJ~Ԭ5pB 6g5$M: 4͏aOspl׬;gOæ/ ıi<3th8 cGaplwXB6&f?SO943OɁ㛻?gE&?kջQ%& &m),' 1Çˌ -'wo=nnDU :MU G!D`H w6Mߣ5Ԕ"GYGf26|tc C0eTڞvt=EahcLz.mnä\n;KB$؜bG8ՆnF7Tyy_3^>5@sZáG<6l5k`yf86L>R& Z;.o#伮jF5oc 8B!ځy,-дLc-Q@Z~m7zs@ 6^v5pᐡ%#|;-[~۶5IQIMUpB 63,*`}iqH,ETqVto:Z 1ETl lWa)8C)MrǛ6 o}z뱴n,6^B!lMLU91 Y~r JqlGӳ#dje&9I=T]t1MTcNJ4uciIe)7;w|hmX!Jw79EG1}K~v+DRԳ'Z8ing&f`3ش6f45^jLg`j$dhdkG-]Wr綏a5, -QLpB :شj%cID!i^}ض-#0-3jaeYAUlas4v^Pi"bhyVGuܾ\nlˑB2,ucF0 x֞ľc)MZl܅dvinZ3K 6՗%ZNJ꣸R 3:e6qMsRk#ubqoكJX/{>W[tsI'hy-fvXyM\*7jVI$/ $K<Ѹ~^!<'-5!X",e=L/p޿b|~ еk4H_`S;okfff-RYޏ( 'FVZf4yݼsm&0;(MU G!ffoDy{1YC[|CN@ 6mT6)Tt}ߩci;ϭ[#m-~ff*8M$F!Rm74+DQX$ydp*!ՃtͿ}`S{z`75_W}I~85ѣeF=+͛o=2-Q-#G!b]R4{|kW *Տ4ަ%~c/`T&?}U#͛q_OqdfAhpӪ |LV-Mc%59o OV 6^֮vb2[rsſ@~K(*YQ\!c~7Widӂ SccƞNh1Ry~LkO'OE%6peIrlߌ~8cĆW9_=ܗS1j%i:9j/όQ`809*I'ےkt4>Q!+yoqc K&܀&Za!ۯFD܀f/ uɱ050=lUtt3W?=%=ev| z/Y 35Զ-p; 8B!+2؜:NqREz$iVz@xg8©Ib]=л䞐?EX6SyE╟Vfjz(& G#U}S칿u+/5Xf;gtmx+9BN"RM79ɟZ3s칐(0\a ڮ^ l~CaϬHaYn"ie,rnx ӣ!ϟxO<̅o[c9t-}f!!l)8irj,y4O?{ɃOg\9,шf 1w_x~P3q4(OBZ!1 Q4zZ6K}[ \nc0~$&(r0P&3Sm\WPLGX&i7~*1#L 73ǿƎܹc\v,Nդ#F!o+"j[1Q=خ{S9L=3θ]C:Y[28X87WTcچmu[F,j,G74]S_so}53!O|G[PXkq-N˨(;('I%Ś9-prر2'+d:nZfKu>cʌ80y# ;6Qkc 7Bq~hPX "0\)Ћ&v=d]ařdSVӶ>q0a}%ƏQQͺ/872e01yDMyҴ6_H /CL @P4E95nxfXH̏?9Ev&NF.]}{/8BO>3й"kxɐu/ˆovv| cC܂噦]kȺ+qpT e1L89t4>5}?ʮ~oON0hm%o'ʍBߖuY{>X4] }nǩpTP4( 8ܓ5|Z&ͫ;{>Aiऒa5L%MIcU a j8zҸv}]3Acӂ )4]؅ VQ =Zaz,Te.n?sێ"wa3H)!8߬`Skb( ]w SQ_1qM'oqyOxv澹7`x`zyflf)lS3c-9Ρԫ1ѣ&jHm>5-sHB˲CGq;{R8HwڭmWrizz^zXiKd.4ά4>s-S(c*(l +1*#kqOЛ]/U!8-[Y99*_~y/DqN?7 ٴ f`|lNv:PyKqR`+8#؉cJ޼sck+`#珳l2bqQ6p?-OIf9:ϽpҬ-+!4m>B 8V^潮 јv.,cyQ}S'_.b ϟ0wl|rVGE_{/~1oHD}[a('O_sߚ)?&f̜7Z?L?a{K/ӝxO|ț?f8;e܈*ROf6=lxWFہzST /|{J{%8}l Qڍq:Kع3|8 5:vp6N+þ⼱"y]?G/ _}/8< ,s^=@aP,ʨ.@'X}<3Y(% qi:Jj8&w~[.5Ǽ;B!Vl,msrÆ؁ڮ8je }~Սh2'q1چnt\/NӷO+đMrP͎;P:Eows~"lOT  4GǾxMd$|m ѿ#(, v6(cY L-SHoK?M߇c3J#lP 4׬+S÷~#O0r 7mpѹ"(^zOlԩ?lȫUNм2OsK`;=I3R$ݥfz̿Wtm7Kԝ,[bRMT1xM]xʤ{ӣo*<}o~'M0mxJӣBprOݻ+ܓbP_.k~).}GSCv_{m- ˲* )H%G!+MS :Z+Im٘8&m8\h IDATcsr\?;3Bc+,v5ßfW4ˆW*\~w</;VatpuU@\kN5syޢfc}};ldZ&,e'br6׬ݙ!q:`).̒p|MV#qpx)M׆MgJ* IPQZѵmx]n Iw+]NE(X;c:3k<\qvjUhvVT'?ɦM5A{GQDlb0""0 B0 TJPTTAxO~]S1Scg:m:&#k$;I=AT;l]bS#窾3o~<Êj쫜)S婈#e{SpE|=؎dc[6cןkmڿ:m :Bq~YQ曐FceTu) ˪5KDض8C8õ/sy0*1rW *_Q)3Ҹ:|,u6^;a.~4~)pѐ#Y+ wř8asJZ0A$ q`ٯcl-M>|$$rI"#sU&t=gfWJZI~vgSO=lc^ܺED4S-P}h|2;who|5qRe,:d:V7tgZx'U>)j$Dү ܗ9 }޸*hj88ؙ0lexFSZ4u|76E.VeϦۢ^4An 10>B)Ì(F:bsgM-IL+iD`#c'j4oIS3~JIPo?,#H$ Ca;/.B7樸j蚆,6CI *>ha{Yhʠ&nD1S a P ;c2TFN3`lw&XȳKD ~ NU D"d3N. 븞9.c"ꚎeV˲daf4v|[6#\Aˮ8-T `4⢓9B4ܟzsl(I`Gк[3l]Jeh/LmM1F׵Tܙ/;H${aI> #p(7^?WQSVqqq[]e:cYvj}31-x[DhhiT *3ML;."3v;c9`BṜ\uJ0Lg0\Cp*M:ij>A ,D"H#Jd=UcAA2qE$z :jyձtFu N81!'.gY޾h ceFP|hr8O;GcF"348j grbW( Ty3t% z) )f$D[XadG!ÓGSep5RlA'T0_aY">k uٵBYM&j-D& @qD#mM;t6۾AلR r9%7F"=Ϩ\^J"WW˵H$I_qЅ͹KGG:Ӷ;׭yL6W^<GuEZI? qoU*R񸅮Jgy>ig%ػ9B(ƪ80)@9RQAhv:y1{vĈu8a/fJE 9Me-AT-TO D"B/>N;4|I"z!*:;;y衇8x9烓)p%lpU\UCU42 t0T|T' Y*=E;Kд3FqIbщE':A G] kDp(.Ӵ3s}ŋjN(*Sgbq XIUS *ghߏ4D"9Xta3j(~%K!CREq-[+i'ݧQ+RNKm |TIs|B&ǚ|fdD:eCLjX3bVh}FktCc Zc4ʌL-s1t4w;<^zvu9S/"v窫 /֛{2mrJ G%G8JOZpI78ePkS esw:XсnT .5pڊa4'ƀqN\͟q{F+DN*1Ri.jP1[*i1A4;*tl-u"tʃ&QhV})\~ &x33i*'o?(j #8I$?qwctM8g}S>'j.~VZ0`<(|=:L UhN"euvu۶ +T<j2hlæXy ŅQF)lPTp(%eXк7N(]cLLU|]=H9kZ*Af`zy.H$ɡ QFqW3w\ƌSfŊ8|sWWW??1g~o2gΜG~-&7zB8.f鶖GebI덁a L^GOeR.FQAِ fH-hqGe^ڃPMFo鶦똩LzFT[/S*JR|$D"9cǎ$ ؖ-[O~n4hk׮oSNK.֕'[M鸉t|T7MzjUX7Rq yk8%z95,,BE:z@moC8T~ E\L8;1ew&H$a_T1b6lQJxz͛ԩSO3'Ht N0 oxt- 49;}ſΧY:ے:~͆6yB&\_%l&RNF"^Rd=PTm?Sf^'D"HRL4 4Yp!vY]v555L2gyaÆ?逄M nn !pKאX&8cٶgI7m0M]ɴKYּaWX+IJq|Eo"Ar ,@U/R*M2ӶD"Ht)++㦛no__x ϧN;z#Fկ~`0H0L+z7EU>t|ɩ*4m; 0i9yWY^D*cJŗh!T-)Z4l4D"9:8¦/< 8.#Gr-lܸz3<Kb&SeovCMM <x|K |T.5莋۸jl.. DS×Ҳ68R2lMU=QXe[=H$1!6x={0}tOxb'-(('{o[laԨQ|_g̙ۻ?K*~:=PNf'3@=Mz㺉.yNƎ^I|p]X f2K~G?)h$Dr4s \O`3eG}YZ[Zַ h#`Qe'D"K#'L5$WSg:`JzZ)XpF_=H$ю6}LOgƅI UJRظ%әa83IgpE i$Dr A7ь3WRy۵Ĵ DYMP5]7r2fL{ H$c)l=]I $'x+N!{I=H$ɱ6oc"_9Mo7ȴD"9f0?٫s7e?xD"Has'p2{)lzRD"H$REHz$D"9֐¦]&|KbkUU1M3W9D"HT=!^ &PXXH(b8SnP(Daa!wa8[D"HRֲyfnV6oͮ]̟?ŋ3H$"QD8N[oUV)..0 *++J$Drp(cȑw#qUW坒MMMl۶ D"H$)lBj9{=~}_=z4SN墋.nh4zV"H$C EQx)**◿%W{~_seqײqF>Sϟϣ>ʙg[M%H$IE #FDQt;%rJ~_ss7zg?;8-H$!Q~f̘%Ke'x˲+r%A;OD"H )lr~a/~5kY|99gS]]M8fڵzD"(R :9s椦\) @ [G m)l$D.3gweΜ99;8nݚ&NaaA?WD"H)l|AJKK[roϜ9 H$?#QmXw_MM s%9׾O?/ھw^~_QYYo~zD"RٳYfSNJQQQ[GƔ)S4iH$ 8`:Geʺ-;l0 7'&H$!FNEI$D"9jF"H$Q6D"HH$Dr D"H$)l9˛q|pFD"_X\7ppL"H$6?z3f`62{ly/ӓH$_!s***=z4K.eϞ=ʦM2d#F8ܧ'H$IBZlf͚E<g[GH$XB #K/ ,"SZZ7}ZD";9:u*a^B!D"9i\f?(ˣ)lɗޣԶ BX#Gn&$_g6}Dr$O&{}@%Lò<:ѹL a#ओNq4LQE Drҕɶd,6IaJuB&9t@7>a@ b,\G?~ƅRN4PP5GH#H4%h2gIos2Y1Uߡ(P ' IDAT?Q?T,*/!D3P͝;#FpEuG<=m?7H;xnݼ1bN9̮¡%rrN)D"L?uBvRY9O^Ω<4 k٣M?呎65 La !p\]mynݼI,77w&s2zjJƥFdlrD"*I7eBYׂ&8].>f;yYu)P6(@if&|g\hس5csI72TUYm8IߺeH$}H4cd?xn՝lo[Sf*T QP$9.Ns],8k7RT'MP #)lL׮z]5ȩGU*(2I*yuiٻ%F<1TfO :;%n2!!pPȴx+F+HnDO$v6kfwǖJՈbdw:ٕuEÎ12.ţDMOe_?¦ ? ru ʌAc 낦+hQݡakHSȲ)̪C.@S'c>99PJAFJ?EI#u}gt?KfJfNͻK h4E*PNj'rq, .5X4l˵xhLe<OrJHC um XN4U702D0kZvMoKGQAhF#.c7+)υ_A׌ Mzd4fzF6%4ڴROV=̫6a*W(`*fHE了XqUx(_p-G\N K~1+ltNs WD}#!HXgDw d0ņt6>k} fX K O8i/cV (gcOlrd,\з .ଳJ~gxt#GS^r!eaYq6я M&Ksa3{Ϩ.A8"  1 3a}NQWVH]Kf.wePPnP9oHL9ēTM1F:f*aݴ3Fo. Vs1W2 a}s-Xn2[̂UÃGt}}rA&Yp̐TrX01l:ˍƦyk6bfO> !rV$&jUUUpm8/8p MMM<|_38(ϛm&Gyrmĝ(o  uf0㈧5;msvp+Q}S]Dm{4wh2+t,Xsk.գLʇ SԠvŎhʂ22hCMŻ9,|}B,(Ty(ccF,Xuo SrGbq&@lrpvXxp*) кۢ>wqû۞bɶ9q̪!A3@N(mmm-?پ};<eQ[[+s)30n8|I@C6{))j>: ^ &MCFaxu:,#D9LC. $i9тLt=4؇`ln^[UJ(7*G)^tبm j Ɗ49ǛxzX揜35%Y\o9ꧢgNX1Ϯ˧/%1ZUڅH=8o**mrBPO}!.Nov=n=jO]l2{nwfرXŚ5k:thj9y8dvIQ/^>N@PP3pLoGq8ɼ܇9M.lƶ척iα/ܺZӽi#QwP"kRAts*>ڦ}+cZ!.VَŻ[<1tS|Io^2^d|eSQ2"w[X7愸:/TA ah Gciw46 _M.p CRS2]B[ %KPZZʄ 7&`;㰢-[ԷoHա e O<[nիWSYY+tR.rXl>(SLW_0)o;3_L0 r}Bqqqڥﳤu7=<aS0CRF^gwvwcwUȭ?c" Q;q,OplP]QCyE9e0+tM̘rJ :_H6> kHSԿ/\S>$;ͺ2:#rbI޷Y痺>e3FP%XҎޔ~ƴ!5zMt G}w9j ۶Ry,( PPA|mI$dMK$X- jvܥ>Nk8ࢱG(E7o^z)guO<̙3W &0j(/^̝;믿vJJJ?:::(,,+lm(o};^  {e&I7?G %ҽ8{D87&TBeE% 0Mӳh*i0=4&^^/nxKQ5 3P_6~n(aHC 70$ *)p#yǂ&Ϝeyg<&Rg9dVHS\s؞)MaAkha;?+IvBEQDVl18Nw%\y9s9sD 9sرcsm5k?OyWS`ٲe8i'PXX迆ql65}WG}|ߠZjG1:G>G4%cYi" h#%[и~3 (\_ËkRuBA 3PfܝK^M  5BEaݶBu/q݋L:odb)K4irJW2q.GNdY&kg6Ԙፍf{iܞCQ¤爣7^?X=lmV@X#T*eLJhtݹxf9jnc;xg HΙ3O>+WSO-kswp='-[o^9餓{8Ә2e>EQ8lmIgszM_{\ cM9!-^-DQb8x/ɰb)ٵcOw":uStIqrM"i ;L8Ѣ&XyVʀQ!*ox垷X[)?YsB~U\TMM O)pr9M&aveB팵uۓCJ 2}_k *3QTePTelT'jnv(0Ksb;xc!:zhNJYY7.oիW x衇mf?/KN?tp<==+JwP}LRTEϘRPp-x%J x ˊ{)07ݟ|_@Aڔ/dv,X}7l/fW Q:Ȥ"aӬ8uxɧ`vg㺂f`N[yZ14@`=-qV2&\)fi(n~"&H!l_9h/{?L՜$FWyާY''m_6Vԥ%G3<(KubB{E[VkWr$vPMh)1s(WUVVi&*++s)))aܹ|'? Yj+W{>0d pGAS ^K r]8:.:{uڴȉ.)lz}P|}p~B@&fc5%:F *6hhpڲ{W&\#.͌)S ; <!4ZwgJmo eY4t'UO`1!O)d5}UMi*П^mS.߷2u]vܥuOQ:m\W(ШN\ҁ&XnnZp2}x3{Z&8cwYSˤIx׺-WWW'|z… +Xz5JNy1!Ge˛} J3fjezضź]0+Kt?pqQ(4:aI9vq\4D\".Wbm5/DLCw;\6݃ST:vhJ$#N:^=0uoCO k3KO Rz+Pu-ʏNc?u<ςRL*fK:f<8a ƍ˟C &YN/NRb(m{c=R9< @7N?ď;uuN_6G/~ s\s5} 3W_$q^sGǢЕI:{ak_TMt "qV$vq}W~+Qw;[v/"6.aukݴ᳧bmMpڃ&U+9%b#u3s'‹o_ij+#ڒs #z6 L#)hjF8-%ٛH+w؄ uez*Pm u1ś[䡳=zGe;wdƍD"㫯رc>|='x"?կ~ 'W_͘1cXnO?4UUU+V`s9\srꩧ> LrxQB˳}" rSɎ7znTTg<8” .9Z{9B4CZe/zfA;QA>Ѓ["Һ;N[EěO: Ҳ+E~sbqS(GM7鲑Yky67.g;`sĜ+Vd'Y46gJ.b0>6})u6VA\e%! +A6cGU#N3)^^ڼa+qTFmR567PgˆWcZ=A8+)[޶P a*0#-޳|Iuljgy+S5^aCvQ5'KgEF#0tmZi.]iژ!r@FqAqAGMs]HmmA3TMG)pjpwdMo~F?s-oVN?t~xB&L[naL8ٳgsmqW3p@.bnTlё(;M)JNw'h]ϱ`]fXlIasVT(80w;{nep޲{ׁ5 ԯ0 UƝgv[og˚cֻGPӴ8 IDATo +nw2fS?wrW ջp'/0:{78|0֦Pʰϛl{/KB6{9` `J^1$d(ۄ+cE]Z9 ?`4oYl^l1"ٴآu/lzâuk Jm:MrPNApihq |\LOv8=%p 1)xۼ{?<~{Wr^f̘3?e{^)= TSt TEu݂\gMy^~1/,;;[|1‰YO,BX^'Zms!TCa͢H~jB k4bc|6/ʴav6 _VDP2Laߎ3A`)DlƜgoZhJa@NoX =[fQ@€ 'ghhq{/µ>qQU04VCSQ3Jc ::M~ FCO1X5G%b.mJk FV'P2pL;>Nnd7[~2'gidXmD YďgoJJCol:8(H׊ eƽOWM 2Nk%+B!ܚ{R1 J  ze%XZt8f-s5=e]**_v f)KgsmsAKRFª4d5PѸI $] iq;L Њha 81^(ocpX` LL᏶+簡{¥:eLBoQ=`*~N`a jN0Q\7Q^uGSL-K-Ɵ|dע:V6Cp;:8JjzW_oس&PP4(}ٻ+ݿ93FQAJASҖLL-DXO8 ~CK;E{:xi#<>Z/Bpq%i"i.(vA8K:vX%=x`_#q h1 O$)t6/Bf?,Uh܅)'dҊqD (=xKS}om oo+jf1{ \:؟v)n&ʬ)jlbֿlk]d8 `~UurYYAvz.f&ELnHKgddEfhjN40kiWu7):_R賺+ٶirSaߎwAKP 'Vd\%@/L\gKr|{ ^6/OG]dl\'8#cg>asnOTO0(s1T]zQI:aF1 nU_戚|{Euo%&%LߐuY ׆mOɽ'n2iP A@.w٩L|+*+:2wן祇3(J1հw҈ AS 0*գBT Reϰt30xpO(/Yl-;O̊Էysªhf:kO,g4 CO6 :ӤiC[ˠ>NXsL>'jߥJHslxzΊg8qAe瞠(P6\e ajbj*q-q!^]L; L6-d6LL:=Gmk̚x6#te]]UUUo>;Z~+GPsYkS4P#X޶{MP ̈́M6BWxJҝZ&Z=lD8^] .RiHCмݡr6VD0pΣ!!{BxE7Jo`i*z-Zw:.Uk)G{!qtot]TiR`kQغ+&;w2`s7ϭ=B[!A* a4Mx6k/ T Jhԯ]]+ѾYVv|_]>Bc͢Tu6 WD[ݮynBAu7Kk**f9jvYV8 VcD/0JbKŰ e5Zw{|wĤ\|܏RsO$-b&&l^Ydb,/AURB M ,c;:NbQB22h{"s6y'<5:8ỄLo1 o|̚5>=FmX S=Q'T᧦?{*J}.|?sQRyq9覑6^ZPRzaqOlp'ʱ:[qXZSF5=OP>RÎQ~[Vqa+Tש_Q\ƽ[e]]VwK[ !dI$,0a7߱1sg@e0=> c,EHP kK꽫k-q#"#V>:̈̌ƻBODba~Xir;L9U9w]dUr: ??款gҷ%3͹Z1{wy'+Vছn: u6ο=%_1V1s2iN &0giY)gf"x吱c!%4khS&#,5tC6T'4#! -t#!V5UK~=767c8&{BN5N:Ќ2),@i8 jj>EyA Յ2) 4oD?cRP!v)SUB3HnC6o :2=wr"6mbky{ߛT*e&&'CI [}qa tz?伛 xU?sG>}fjco4ɦgYV1J#_.?uO n,@m?G|7V|A!/8G/8a?ӁN7顐4}8O~PO0տM1ߍljR7 \Ek!,;':*+T& Xؿ P8.RDX044\y77 HY/i32_r =6݇!bqwhtXϪ !.˴C4CX(YйBڢDwmZ3OˢbՀ%v!|%@!Nʉ"LhJ!4R\\p*iZfbwNijSBSCdin^;4M,s>׳]q5Jfs +oJ})g3/JQSQ>yUx;c`ELH_gg塞ahgMdJ3CzE?ܢIL=y5Ε ?lL+b]ϩk*|`bfgᅢ۾9<}[nGbv"pw6a%{VݳmI2-'hΎ8 sfy6C9pL"]?@fLYщs24H(ذ+ܿZ4cak2=^n8t.8Y|OUE ž瘹;ۢ,b7+=ĞK396GA[]xj]'kސ>+T:IG+}D ͱYp!96l?s\5#3W4)?3p2ܧ=l6i6\~͍wՀXCiXvsߪҿNwT'0KFwV9vkvYt-<BP,/+{W* n!t-vtxlBi jnl;U /dhh 6pl.cJk"e]O~^!ɃLM^|2#< ENqUaFtGa86Cslf9$;g< gvt'{0/0"D7Q|֯×o> [U3&?:α:5y` |i>{GfȨ"U8$(xGwEM2IWo>bl!wrJ0銊 Tx'6d%ff1Tƴ|%lwN:!췺n,g_&lx{lW_UA"F̜6{C~)gygQ/O~κ֥ؗK{x%~22/+m4YdO'=9"bm}+~mfGtF95vZ2SөKkеB&$Ii"tʾUU̒4| mxqTF؞*)7&oZ>N)e}8cl:6͎ͱvE jSPB"])!D>hBBh(LJ!?ѶV- &,lK䉕'g%<~c~MS,;˱ؤLLǿyxMdt: :6V {v-7vKKl [ucʹcz-l1Z61# *84}c96XU:X{Yw78-V6ˣG7̋MAwJ\66Gv_{^btOU:^#z`[GѳBtߩ2Ql!a2z_|>-F^ \#tGfqeJĩ#ڷ6)K UTH'W%̟K qe(bgڜ%l+8XHa6 $68:t֡GH)PiͶBb#¢4gژ#+pwx_粁யT ïU]$Id+UWY|)oXxVEpYehVTJ{kLq6]5+>Ċ8c23@H>pgE5G YRJ0@%%du%83y81Ҙo{/r ]y£2侐lH&s{g`dʚ՘vY,I&zCIuBv όuh$ɗF9m~%)X'-Ϲ6CY,K.ϖ&\MKL2ʥCGy7xL@3QġazD ?y%\vt PJ " RtrV^ox=:Bˆ}vp,)Z\=. 50~aJ>q''t(9$U&{)#[,)# z~[zX}I?kޗA}U j.}^ILی\tg[f=Vjt.s0x*Iśm?1x:h~2yN7/p=5.ԏi[# 5TG"x8Pcl_ j9Vssqt4(4dk)c qrNAG:ZQREj-f!Ғ,YO w{xi~gtwhd㺲\8#>~U3&ͻl' d lB>qBzWɆՒ!s!O s7| łlrH0:Ik$Ə4E)XAQc{kArngX_ݨp麉Sc*ql%hV|{;}ceȎSRnJpzPl/-@#5\{ՀbY3^ڎ7m~>U{Q+F瀙gp<(Sа[>J,9/MHWH"PŵL[LSP޷(X  &,ubOHTD5~̄SWz4<$X}y{Rgw+Oqp֑˦SbIylogs(ݍ*qv[ض*4D>zaޅƩ J†DZspRH%Q5ŀZ[3x/mҮ٢e-28D[T eI$.h29)UCTE@VvhCF Zkx!,3UXQ B+|rCF3QD2@tM* 96kWsQ-eg&OYDbM̩^;.YY{gA3"]"٦ML4@Fk$rJ%QRV]^Mh<T9+T ϱ)?:SO.[F!u=yz@ #j-T>^hq t ] iNǀd1wPf oJh=Kw0BnQ: vZ9lF̋>L;S8–$# U\]`V2z]! gusAMs#9ǶNRnK`5i̋wy6Ƴ$$(L MXq'{qllʶZƵُrϖ /,1=3=)H] =6<]A WPfd:$R}0`Gag,Ppy b݇3`Û:#Y/E .wB 6`vc,qAKbW 75#mX t|d-O:ICsa62v6qflp.¶MvFEÖժ+3*c&MĞIF5I<<[~,MqQ=<1Mpʔ!bM`(,UYRX6?:RY)9o{GBnmq8-^$^m.iOsE\Qd%rVЮ?9~!>~:+P#t*S^b4܎sE/q^&ZR hVT!FQd{i,W~ Bc)sSIMlN:oZjO*k^O&ſ$|O,lVc:Eu _山{VyF"3%<]B GPʐRQap]G:ʚѝՆw}7~??~+ +/)-]<XdUE<\#B} ]3K2vhY,aKC#4s2ïU "#l ~.$͒q]Loc۶#5HI].E5_; F [8YeJ!c{-X#[H%X~QӌQ (.tXA9vb52YLfp܈(lםK1ep~o88e#ќɈҿa&Oyy&>ZǾdxaX4Llñ uQ#~s4iz-F;eK.Ihv=1/yk?6`Z6StJX)rayM~w"L z1uZCi.'{.ﺞo,7EZp=;9)20{T86!QTWŅ+X( r< ?uiR]/鐺8ͯ<[_z߰07CUo3gM% >:dsYI'zkMNj uHȚ0Hh|{VF@Z Ϗ3V9ֱ{x|]IHZctѸwh/9R!Za˚кZkn VO3_?ɅtʚKLpLCyziP2'*lThVi88%z5 %PJ>+;}ϦyضE6!O.tHhlӽ%S4ls;xPEͦwx#MPЈl+[YoA_c|_ Vo/6u̹oUBUV85IdTXu-im"j5lj*^?{_{T3݋\:8ԉƮU dGqO#L6 i$,pֲt^SwhS1@k㴈:t#7̒qxaȞ*=\jz ?<)ss@<[B#9=첪v=Y2 eU:?ȗ37MTe "[@(m>^D2=vܽ Sߠ5L՘f,ɐ)HC4J(=apj&m>F=TLBHʾznaanu ITsvVęt3*͓d`961ۿbԖ,д?aq8|ύ?AUOnЩ>6g,RIgg'b'|\6뺩RTY; Y~qjVK΢%ix,ntٝWmlCKq$" AAxgSI^%dtLB#l,^υ]7Q*DҞX34l`/cuy2pZ96i\ հ MuJM J$(ϋ8>f^K)cc_ NxLxd:}㺲!+07  -' 4"`!gedKϭV4ՄimHkgMl[q]\ 8?|3:~rJS}kAYL1Cb) dYM5z,5cZ,mu΢o{Լ`_Ślzc_e `]FwWw]A!U=I5‰c#љ2uT*ӣ^îwrA 0"F$}jtؐ!m/goy=ulbfGFٛ!u\"Er }hE( ;Q c+xK<65/}?vLzz ;cѷ"KSSŧm\GqVvKhq2If5\} =(fքg85V"\zth1kcl'$IW9gjjS{8P}Qo~X%n(Zc&С)S̐ tvA|365*s(' L* &⬷RIJy6kKXٱ]S󱯲8fk䣌5]Bah[U'Oyn$^.|7ukNJ+9Liv%£2jٞC3?Kcc\.eZ,o)-\l³ߡNQ4.g C0CĴD:A20ːXӰEs:ʦt9NxϕzK;L躑iI&f,(/VQ =-"}9z4 8v]3Y <|!IuD&h^HpcbĜE| H݁mc~$R]Ȓ¹~jaTNxLxɂY2Y'Gq254ڮ2ڊ!L1 PTl*8& zSDRKZ2^Cqlb*"jv#m,!̩l+v}#y5v3O}'VbUAbKT'VJ*SFƈcdWL༎}y1;Ӕ5=b²x6mfƮ/xndYZ5شc9c$Y٬ʸ '"}] h82 GRU,ե50?:q/yN&Z4@(SBafn3!R Mh 2c{ `{9;dgVyb61.<[y"p96Y IDAT1@CiNjSSNWŋM]m2v.~0LÀiYaxW΅NIx¤]mxjV܂k6tde7tדu3k# 7Ϩy94i4D!(R ۱2\(i0" |I @TZTfR8Ѽ2'Ed8H;ۣwp(Xqp}'P6)8ޚ].ѯB>m^5x+A }2NMLbCэlh-BD`Vq-pbKm)^hv' p7lczH+Ēak&yTimp^<;~1u&*kӷtR2R܈!]&P3#ѽU¦ paԄ]';eM^iJj,6n& B.SRNm >6e~*1L.So-VJ!2N`sqm#\\eJ ͜Vf2=K2t.tdHMLQ(>-@Uؒ!*L{3_E]eU(km7t:I6\V4ĘۂE"^SUVhZFJҝTj;ȹoᅉw$ǟtJ fsG( q0~K-fS{y-&z Q\8j³Z';/4R0Lv L u Z}3kϤl ;ƒy3=ޔ0\2nPs,K_{ؽ~7k%j/,Abj)dj|긲ys9h5u8ŭ8fWkĠش"xi;R&U09Ƿʳql',r΢?\Vy??禿OFQ3{y$J6qJ/Y#vM6h‰,Eyk7x ;KOY~<9~i5#'ݜ}Uu%vSdcSCivāikvW`uzmQۡ>*@!#VM sh{s;H)SMN։V[ ČȢ9ڐBSm<ݴq q;x75AJI6!&Ef=G۱9N8m u}q6:54D1@CZ6#hPhy#*ǜEK7dO3[- #Ib=˲pAs|8icsz6>FOklu}f5'ξ7b#NFG3@PG6SH0lK)qq@j@NgQ"m?-qq؜ 4.ئGM<0-,Ц[k+vӢ԰@)6h4u}04U/? 6Nn6؃maBFy&bVJ.E5:7332?[Sm|Vjpjc}I9"{{pڢ؜$̶`͋6WkO=n;Fm_7Ѷ( ^wpbe&1s}Pۘh;6'r('g5Wf87Gq6h-KͶR4ASr"k;3g6ڎ)D$}E6^m+‹/ذmݺuX"gaΝ߽lٲeNXm{y̶huh6Ny/o~ ͣ>>9|IV\ɇ>9;6ilsgxybk6N7\\\y?[nkm;<>O=#3wi6hsBr؁'>/85m1ڎMmF7o;w;lxw~w8s?/"]w+21^~ezmh;6mFm7|3|{ _Œ7tw7?3֮]7wwvZ>O}^*I{omh;6mFm7,ZO|LNNO}Joo_a0 xپ};O?ͯO|;TU֭[ǽg?YicFmqtRo}»iƗ%nG>z+_Wعs'R jyض}M]QmFmWdY>d2}Χ>d{L<޶mk֬aƍ|cJGGۿ}L6h6;nvreq5״g!ϳvZV<Ǔ}/qFoYv8شFmq!@Jy}Jwqr-[c/~??;LOﯩkE)֭[AEUF!Ġd28@2p+>G}/^{v^~ez-ZTwD'2qD͛w_*!T_~9R)<ϣ .`},Y 7?ϼyhnnnhnnNT*?N,ʗ& B1׶m~͛7ijjgѢElٲ>v D"]l1JHb#bDb1i%]QB!5$B!Ĩ!B!F IlB1jHb#BQC!B!bԐF!DLnjcؖmXm[4cوTV!߾ "fs v'7Ver ~8E9`f\>^NfF<¡xi}My%u=+i"硕ʓ+ 9d8}ݬ~G'}g  B8N@F)!TdxHݛXD+!z!8F: x/ؤo1ٟ  8A}ĦߙG|z_ 7Fȥ=2 7N[ﶟÅH8E',D3Pfs|z {x1/7"$K2=qsįXB$y`%U@i[9u7KȺi ~| Sd]ivխmi`_A 1e.c7ɋɹ aBq/UtnMӻˏ+z:!32*i *>}>@ϥ*7TTOxj/3n Le}(WN&>ce /=?^n[M!^5'e˃o=K+"lz'roǤAuL0,) شmcڟwlxJU4Rn62%ryW} RLqPJ*ġ*o)ջo+3afTyBH6ݑ)A?^r/$ ҩl8UBj銫v9"` RbX(z_=(5[jL᭧+N2 Nfk(_I,q?^3XE jHZ/kblwE*$N͟Uؔ8;\V4L $hF1Q G[|5d*6Lyt] o4}th1t,^6'3d/s/jS@ˀæ2;?BEFLߡ@+wslx1{w"v=RkYgIdr\EW{|B(mЈXurx) ݕ]K:&ΐ'5HU);ʹNHls 4Dy})V?ᤫRqA s,е{ka'|ls9+QPiӿEएM$[sS8/1x4};\?|Mo"7bSi=6]]>L/g,Zch]ߔ+8rtnpy7)IUWabcVuF@Q4 ~"h]Jro/b?`Lw˄9;W:nhA?Ј1hl[PP7 Fck 4kJAV70m]0oKC\;Uz$6Cl\\MrMQg{Nf2XQg`Ju5 f~4ȦWP·eYn1L{paHjGq2sYg2?)mPN&4ع"W'Y3՗N pC>3 f~6ѽͥwgir$ßX PL`wM" Ֆ֍4("ThGy{(=WtLeWˡ!z]O4R `13LƟhb N{ +Xf05oQeϭ[/Ĉ}gylvw$|n ayUV|;$a)b?4q~9zi鼷$CoiQ~d4&`Ŭ?:0nwIuf1x-vAEgQй)ʨ/D#f,[;1c0yd ."&O<;hhA3:gDg}7,[v&MIJe%X1R5mj)&ө )1NV'TM2hDyVHsuNc@G I'R4L1\0MbNZn91m#Ln!6'kop7 83˘;.w=$lԢ{bڵ<,YݻwG<0 ;8^xjjj,B;3wm)9r)E6h-g\x~=/$-Ǚ̊8}2D k!s#k:5 94)F U$T3a"W"?FY!Ҡ'6w}7`ӧofikk`Æ {14-?bMȨ{ȤV^ǭ[]I2q9.bdpOT6 i)ЃD7)-7-$fPc{@#RLhK15˙7o`x<ߏV.Ű,ɓ'1|vLNL&ǰ ;SN4իWc6x}WJ1}tN8!(կxEo'5iv= s`9^$A5رc;v`N;46mڄi455Xh/N Iw;9m}#Gۊ']}`ʹs!+ݧa!z_ho+a ҿi DKyfR|{dC[@!PykM.#J}rLCK}ػ=Ʀ qF8 j>\QQx*]MdItxTS0 FG?~<_җp_|< A&j2f@#R,a3fi@Ɖdl|%K_ ef[i#IvxMN0;VN7x@)oo긮"2}A-.kmmu!؃Y蚁QP(<), ^cd7aj6ltͤpӇASziōEKyغA k&<5H&];>=Nݫ j6/rg4tmtyIbcu4MLZ?`΢nc}/2 }>|esRg}y:6fxW;7#DX/C30|h%\)?Q%=J|S:&fb'dDl5aQ=$)ҰԃhpatnNӹ%K$D2#H\C74&ω.tSaƼFƏg0&<(Vu3fhFlζ8iw9teɥrb[IVP0Ҥ\<1mYIleJ҃ żq C3p**|RiO5SL'#LU; hbJȥ=6δbL==IvOwTbc-LcivJѳ~?(䯙ºWhߐF4 1H1wyZ ѱ)C4GeMl ֣zi6BZ}sK} j0|Rh Ϋ%30nL4ub+=mYIyoկ^c}_WiK}Ě-N+|[yYA}emy'x'̘A/rǝ_G nxgf&ޑc(Sx. ވ BD|&Ќ,G\bk&!#};פ8d]QԘ0;$boݙ4F')yZw"1]vKmiacY=jZWakXR9LKKkG*?^0R4Lp5مRsu%[_wo5hcC `5,BC'hĊ&;w-mY 6^zoۅaiԴXz\:d8'FؘN&9&}0[IO2=Ddž4MA}j[vXgҜ/jMAvK1nf-1L nv1.m⨹Q܉灛SԎ'K޺i!1Tr(b<{kg9ҹ%6X|o1~Y(Ndz$^C'6S5zbc0+S":MAp3B nƣ]K'^btmɢ<Ŵb4LX!_h!TPMט1NMzh={I#=mYwyN-J:nNTw;\͉|?i ޑl[|%V5r9˕4|'8[Iri7ikԍ+xT;\kh%VTubSx# X!(0f_05&_:)cfc׌*cp1vFb@r֌K4wHIi]h kWCFk MfhųjH=be9vMo٘L£k[sJ'jf@/b΋aYai1jZ,]Sj_weS:FY+w~?M!4 ]u1)ݬU#lCTRgift]G~^LD*mNs{0`ܱ!vͰ8ץyv@kly;AӡwgSLO'v͟mg˟}vOz9/3+RJb%UbSjQ?ɺeр2>9/ShPuV&:n`zp^BCѠ<^j/ÉYl{t!b#2^6N p5(/!\o[͠κ{`̠eZij߽nl#\gV̰j(r]S/M@ t0 ?xYT PT -6ce6XdUKh^yE9Hg":1mea&a[nFQaL m :sDKM)'GrrX'֡0g+W du|GW)7 Y*+߬+6/ D˿#=fV}Wͪa0du^xgPU7~@Od.@II,3¶,,TY+^Q8T3y]1 bTC)Յǣx9 jAxL!`, . 7 lJS~iԅpkMʑpwUɽѪh J-Y[5tt! ecY7J%eq,c&1,~;V'pn*17!^e*v4SMztEk|@˲-+"('UU@L,bY4S+c0ZMϴ S`gWݥV -=Ǯ 4& B@2]Ra`e,L-<ؔC$^V8,2Wsژ*}\#|r6Gr蘦 |l ܵ+d^{pT8j>t&L,B0Hkh!V:Hom,ˬRY8t ?^ZAÄ,of^\2xId$^`i[ĨX,fx#=^VybC?Q5,uBRsLB`g}:مR?/t$NV|evscu$B!"hjb5b#mh!S)^~]PJڧs6[{#>.<è/~tS&SkL \&Ũ!H8B0²- :l0a`P-G%O Nd99\U.S#C?'BmP(4Ϳ2-bZJ%N㥓s$^jh` L0 v`0H8ɟ 'pP(X %񲤪(kERh?u8H.H*&͐dpqpBbÈ_qUM0ì|ZO0`1`C!B??$`SC7P$'5ű7M0"NffU/Iyt3?k_&@($gC! @21dE?UFb Js|yx :+mufdl.Jk YiYl˶ ݶe3*3^gmBd3~r8N/eU-/ _tϴ,d0 `KiȨHlB*. iĴlP\u7x# ohy_A4|iX_*,$4RI8/u(\0Ti&K#?^ TV\2lk8y*R8\Ym˦.>U]ӊgj:VTR!¾imU/ž矠Q׭)?(5 _0[J)O0MOߔR~EEUI5MH1^V+ !MV^]=k1h߯kUpQJbdMHT>)B'BTC8b},$NHW}B!F!B!bԐF!$6B!5$B!Ĩ!B!F IlB1jHb#BQC!B!bԐF!$6B!5$B!Ĩ!B!F s;Y;>oロ+V,W\q/'.lB!%6ir]wa6_=۷o뾷r 6m_*m&xB!>}g>z8Sm7o-ƨ"Ju{2!#!\s ˗/? oڴ7|x< 'q˄}ĦxKŤF,"u0̺="AT`p?喝\cPv Uyc 챏c/eϯ!,;mbuI'X(]sfXy5\7 n>&ssxb"4iw}7ӦMWNl Id:/nHflTYbUXO,x܁=ⱺfF(B ܊R925G|gg4=24B 'ogŜ{+?|qFw,XyRKRd;~XO+&5ʿ~TA<ݪrtgs^ztݎB&X;Mk#<£>ʕW^Y1M~ܹCQT ɍydonK{jll$vZa-kJI븸7MyR#6IɍnPy(rPnǡ?B0h_W쒊D1L6fضeYը*yr nJKCx)&mdG;Ȥ'hZMWlefi yK-#… WʦM~?~~۶mF.c̙^T]bB4Pu\r+WB;v젭;*/>{lnyn/7|3v_{UU&6zf9͎4$ٴ%ɖm)zzsݬGe‬[h4-s=G(׿>1~?g\|\{<3 MwC)R뢫mW`S|ک45؇띕p/sK(QztM p*lԩ??ۮ**:;;I$ΒU_b)uP]0O?u[ SI\HzĢ&1ҁS 6chٜǦ-Ici]WkwÀzSD: Jz9ƵDKٸw1iB>L֥Ķ`1vtu;3 uv~Q&ޖƚ{q.]FK*vw=:]vPCr4=4csfpHF!RCC ]Q} ulӹܟY3b̞YmmtrAclntZ6οܺd-nUGqMn,|d??U1V'q, ns~xjY3BTMUfс{ge/wyvbƴ(%m<6GI\~47ظ9I4b,yz'7%xou?;rL+=M66'-5r3/mg0S&DǓy;fT_C4/kדtz|:Ndq8/Oƙuͅ焈E+[k4 "N0BY]{ē:W\\OMT:nfL;?vB!F9=x{֌W-@gwk#7'I]ʎӧFyiY'9fjqhL;:+zcc4``u]˟B4bR,oYUttWN)Z9ZR<]~&ǖmY `@'z0 dcZ>_s/'8~V$?x̘j1P V]V~kxF3OݼͻҬِF!aS0i>ߧk>cQ_gd˶wݳB\)lٞnEyc>),{q=[r%S^^Z?`ƴ`2 LkHĠ0uz ˕S\]aJIi}lP>6j 4 cxIVВ8_f}9qʖ'LBq[nf@1D"=yv{s~jg_c;M;s gW9'_@GWS=9D_h"ٺ=ov[t {wQL`hOxh̚dLŦ-Y42ͺMYf䂳#܄|͚uYy1mNѲ4ui% , z|ǺlKcƴ]Fgg';vxBiD466r%qF>Oreq 'il8~V [ְ&V_).̯Gԣ.>yaCʿ# ,}!O0'ſj{;x9oX_s'hRs=<#̜9oP-ziJn3<{nxJNY`ouuu̙ç?i;<"T޾^잏ЗsэP7piٜGoC,jV$.ɔKS;/U6RGgwPP'Y\#vEtnY~8+px!֋Uq;g)DukW`[kǖ=vY&QY'R.Q-=T Mm&ؔg" +r,Yロ˗dSOgsB!1b y9묳x뭷hoog۶m?ױVHKQJs=c9y%B1{n/^L8檫 /,~i)<\.wX~{?dEؖotۛcѹ xn::s{f-1Gۻ=]g:^Ro.!%A!PT)RQmh*! ZD H MT؎xrgνgf;I=~=;K-><vIpwٹ@'iErq~w\7ݼ]{G6w>W#c!x[/w-\_:oBGTǼmK,)-`(d]wUˎ;رcI06;׺EljJaT@&AŠya±u0Uip@$tu F0<zǁC %n?j0h5d1UV#0)}ݳ2?~:8ɞ$j:1Po8vB&'bWyg}tc'|ϰrdafp԰li6td7tu8ٵDvT=؉FB{Ų6M%Ǵ ]yYc'C4e2_j|VKXW??@rN ~]zcsW < o(2ai_^O"7Ϯ'R>}V,Sky*߾gw_'o8Hm*NvevWg/Z[\qJ~j~7=>[\UowoࢵYo#v֭i`_C!*)x}=~m`'q9t$(>پl+S!߹oϵ|drr '6ƴc9ѓe$Kڸ3|C89pGؔu8߽}5c''bv٘:||u:x@OMX-w SR^>7}ԃ[!T\L?Xց%lu$SFB Fpa2;t3ȵȞ_F[9֭i ^_\G~zwJ.C?n8`@Ƹcl' uKox.bxT= L.7TxV0n7-aq Ɩ0ij[X Qz˜m\ A81ĀC9`iJ扝Y4Td Tf.`Ee\װ{_8Në1'b.V8tbJk|Y|95?Nu&ز0s>""" 51u&ݬZIabzq+9tx"apӿ0JDqM_{Fп* 8>r|0L 6-q OrǦe߳q}a&&=֋=ɡNMmtE5l6/yC]ԚMU̦gxrWEe*Nׁz V 6tX__;g_INձp8xxwc-x\sG' l7-ވL#SCOD̎t,uXkνqxU+{]C{o/ֻiؘJˊe>v޲9{mmlTb)n8+_B[3\{|U_m/lY [π)all1%^W*-{v>41Ag .O_ iLT*μ/l MLF4vN?~ڹ}&&#JM3,>}FBm*y@_ccS-9׳8D $Zʹ59οEl'słX|."""童eo. ,"L;ev:o9-""Y {gۍP"<7"ٵpB 6h ` Ll.yɹ 8v<%iQ-lfB k 6apSD.ͅ9݋$IHOЂ4N)Xk![I7$ }Isw:q/1kf[B 6iXk1b8}ov009i1&^ʫEFZLK 6_(t|ORDDr\Z\N6Za1L:h±KPҾ8n8>}J})\rRDDZYK.1Q1558###$wfɍ3:%:qO뼽7-x31^97Ҙ|4ji_39-l$R("h4j1|KX~;,V vҔ8 Ff^r*k)\\HDDܵdɊS$& B:2<<Sp'x IҘӃ= )ԈҲ8hA6%U2:6Ճz~Q]ͅe~ .]O;ߋ4-l`ΨM&&kLNLPVQVNPpA4T)Bi.+̴b88u.PB%8sceuԣT,ac-qZ[kp:xy B >LMMY(-l.>u]l,R,(chtFk΄pcӀuplDuq]YgcԈ,6۟)Zmx^B4i2l.1YƱs5 cֈBj`3mzZj5Z$M]4m.7DtC.ЙWϝ6rl`6E9H8[W`2̙ќP#""\<}gvye<_hQWC\ht}usW[t7=s+"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""`#"""'2ǒIENDB`repoze.who-2.2/docs/.static/repoze.css0000644000175000017500000000062311733434736017710 0ustar tseavertseaver@import url('default.css'); body { background-color: #006339; } div.document { background-color: #dad3bd; } div.sphinxsidebar h3,h4,h5,li,a { color: #127c56 !important; } div.related { color: #dad3bd !important; background-color: #00744a; } div.related a { color: #dad3bd !important; } ol li { color: Black !important; } ul.simple li { color: Black !important; } repoze.who-2.2/docs/.static/request-lifecycle.png0000644000175000017500000034526711530747412022036 0ustar tseavertseaverPNG  IHDR 6ZsBIT|d pHYs 4tEXtSoftwarewww.inkscape.org< IDATxw|gf'B@P)D " *`C˥ErAQ)N hHm93;%†gNݳ˖y !B!.!B!aPDG\up8PY!UӜ[mONܹwX6k~L<:LmDCy??,w]AYaCcp#wM}̘>%{\<ڷoG{?|5=!@nn.xJmӻwo<3f L&u*wɐ$ 'N}׋jUUnnu}7b̙^nܹs4i:^߄]9uf̘իWY`^ NAA^{5|gք bݺuh޼:'(75!רB$44 3g/sfˆ a{mڵk/zç.-{{ѣgGt7:k?n'ڴq~l_h׮:~8sƽ֣n:ng9s<֯U˜1wӧNƍwyq}O8 8t22}1}V p+SE'x`1?ڶmG#)i. AR%bccqydgg㡇Brȣ>~!##G~R$%%!)) _56mTӕq:-kР߿:^zx"5kv]駟FN_~Q?xgXkݺunӮgA>}O?aܹ_~#GD˖ϣgϞԩS#Gȑ#c…8~8\<%11ӦMåKy{FBB:tƍ#%%Ǘ_~>}#;;/"6nܨի>SDFF"::k9!S_ ɈƏn=RRN˦x={WFEc1DϾ$I|0?11Qſ.Ot1JK- B^1_| W$vw+WY6no:ןôiht_FLt3!ӧWGѭץKL0~~~xw=+&&111޽.tc&\fޠ]-=A6mҰҏ[?R(}:MY^[}j_~R> ,,g!W5k?yGiii>έʫt9JO+$$]vUO8ݛ7ϰX,Z@L>9xҸqcy[YTTI&jȦO^nӵmۖrUu@;͚*VP$$QؾcIFǟlԏ[m͵'Ob\gϞ}=zNC59O?iSϑ1eKĎp1={qhӦ9.v99駟M8u* gOè@$һMޣ#6m|?_DHH զ[^^4~nށG 8|8)B͑?>v[7a=ܥ-=o“O bxݶ'\''Nvڅ'|6F‹/|Xx:o<~[7?M=zxkwe̟?WơC`ZQV-<Ә9s&999x78š}xлwoۦ?E^^>S]nGxx8f̘ӲeKԯ_AAAnqQذazq5j6mڠu֘={͠Ak.uvEP]w݅'O~lڴ VUJKJJ{nr +˗/ٳ<}jQ#ܹsqiu^z?Ysm޼ZkR~KBFں{+k;^~5_~ ɨyR6o~()q6a2yV~7ӧu_iixضMoذ>4bX;<;/޲% 3۶/Zm())ĉ՛ @Vd2""6^|yv/8",+h4jx{'xEUHM=?&ƒo>@f4cXn r5AaX7K~c͎S!/!U{zкuk:tP ѣGׯx]EEESn6̙3ǭ$~m]@ݚ.l6L6 ,p윓ӧCVZyl~zj31={[n8vvڅkbڵx뭷BMy 7t@cj6lN:+W`۶m4h]`Z]Q .`СXr%jժzx J`2G~n&X5ٹt-[6uQV+zhwuyt4 ۶dҥ߱{Zl۾ AA`OFGXE2x>'߇C:~ďpN]h/XbiF(,J wٳ?.D=ض}ΜMR_ba(?Clߎ|#<4Hm[D)b_󔐐 nҨ(,_oذaXtz`;`ٲe?>̭ g} 6ccꫯЩS'f͚M6_~\F)q=z>;x.**ҥKeSNETރn͋RGRddd`ĉj_g}IIIi&:tkRQ=z-[jߏ a9rn{ѱ?FI:ekÖ-[Э[7^ܡpúƍ !pǒ%ߗyIM;t;iΓ>pݼt  Aܹ}<+|Y>6e?0vGKv׿:޻㯋p<3 [o3Ož=ЩS;/~X'ZDM/Av|VG՝fشi :: ́|a,oDdmlظ'On?'vZ}ߌ$Cȭx_n)S}-Z䶟 &M䵓qU q;pA_k={l]z|Pߔsƌ.OLL[$xq}ܹ^x<裕n~6a)C[9s:;wƪU<^oСC=z4tkҧ4AÆ =>((---F[@~!?2oWex#*(\$%WG~t_\潅ثw^M{c_~ف߫ M5?x23hPo_/=?m-Hnn>6oҗcŊ(,,QCtj߾d8oDDb ngyO]ކq4(A}=ط|BBۿ?֮]>> w^A;ٳnݻ}{g5k:l6,Yaԩ9P,n!"\m(1c:ٳm۶ڵkyf+]kQ _!2|pJHQy}-CEjj*fϞ`Ao>|Z2=)ϟ2oBk>f0"Ԛ>훦iv93УwoOG/V7,))軦`O9oRС>Vyw޾t >P+;w!JZf1rvۈQ#w؋b@&uR ^Ϛ=0~@JJJM+V}!C:Bn  IptFWƁpaDDD`ӦMň# ŢZ z:u*YfoUKغuU8p@VCii#FDE^B?QM1b>֭[i׮.T+ ! {4DFtz\3C>Z6P&gΜ;MvХK87MSBnf@n g^YTT"gǹΝjo{ p9eo4aW_y{sEu*5>2cw!ƥƙ3gp]w!&&6;vxPU1czK,޲etӳgPXX>}mِ!Ctm۶r<7R PNݞut28|0]M!CRRRs[,Vn]͎;<LF|`.42yΜ9|> }Ơ!דמ+k+w>^8~m|gHKKßyMg=iҤ fΜN;v n;w/Tǎ{8pn`0o;z2BHժP't͎͛t=`bб54im{|$7?FnЧowv,ؾGl5 AtL Unw܏itjGc޼%hP.z'tA2dg!i^>|Lحxܹ-5G۶ݏn:yFX\L$ ,NڸmiHI9[F/b1uʋB~ewpJNNSSh׮%BCuWh4`ܸ7ypCclݳg'=g^zŋWaU23m~CCՎ))p{4X]5BH^|E\xQ7Bؽ{7[oUz[hQwމUHSRRSOh͚5 ]vŁ1O;v,V+,X|#Գ/, ̙Պ3f`׮]ӧRRR|rQ IDAT8L<nV‘#GtM /nðaPPP >3f իWkK/G~Pn]lܸQa4b vܧOz]~Сڱ<$$!̙3 CÆ Ѽysv?~\mw}w7ҥ /c޼y`!==G5󱱱hժ8xQC^z YYYo.\BDFFb̙YBV,3j;v @矟fnfnu.<޽; ڷo&O,XΝ;dž ⵬;vd$2ӢEرc{aucbbСCΝ;=}^;P]Vy6nˢ<~w}رcU~իgΜ?lذ2E͞= 4{|-f32dm֭{aM6e<{wƍ?ξ{VRRRvjҍn5&SrV :&ΝfΝ;)4^5 Z6m6w}(..޽eng4ЫWveӮ] Ԫ?v Uɳط7 ]D|o֭UvO#p Μcдi#4ogddqonw=$3uP_/pr =?4m͚5BÆfa۶=e h<,xp/xyǃs,GCHMPRR^ 0==ݭUYa2ܚqBnn.Μ9xԮ] |$//A||dggWS!B-o̙UTBHEBѾ}{|n'O'OVC!!Tɓ1aݼ˗/cܸq(--RB!5BUVyO?]M%"Bj RA_cɒ%T*B!fREc7܍RZl?mO<ݻwWC3*!R(TPj) '\<NW\\={k׮㏑YM|Ե{wc|Bqii crqt`zݾU +kE,s@8c>j,/nի:q`ߎcԨQn cϵ;K]yB!~JO{CY+e09][ӧOK.((((s;ǏG߾}gJcy `s|ypܾ (B tx s܇}]4l\0rux5d27ru;z [nK\\Əǣy>(L[\tmB!U W8wO\m=T|yz_JB;[[7 rmΝ;D$&&ĉڦk׮?~<>DFFV]a(GY9Z2jH!HE.34غΘ;6ra}t+fN7sMC4x;{[_۶0BTݻw#11˗/GNNNFq?~pѼ p $W ^ #< m .xӅWjR<5:ϛ"7^0<#UI9bwi^^VXDܹuZƍ1aWkk8D7Ӈ vg7'U((^LE!sxvA9R r0Z H I҇ 4sv0GpֈHp.S3Jr9AJΛ f0ޤ?3M* 4`J\ }8x98B0t 8րIH'=L|zi"OX)`0h4W^0j(9j*'@4oiصku: j-%Ը-"r˻y&RKtf䘆c Itl+JZ!#8jCM$G$Bdl̈́r ~,mRE % &˫C93$ 7Ae7& 9 c$H0J+GpͬxA\ <σ@«"ρWǁAE 5)Tq M`n뒪ij} q}_عs'b 婏@L&~fVX- 2ÇG׮]ϸ?R#xblڼ `@|xI胞={"88y*M89prNm-Iyש!nRVڼG)L_!9II   I'1zFk0[.h5 fk&0s1W/S98XP"QbRS,:`5GgpWCZk9E &ZejJ߇ }tqg*F|ϺDN?Ҝ/YV[K.ƍaa0`0`6燀#44jBѿ_t1ucM$Q3O˺18p FРPMuN8/Թk! @Vq&J!J3h#dh¯4 STt'`Wm{8q_~{Uv>q95Ea`sH@]QAxg qӂP8e9ǁg FFDSs{|zPW=DW|OJ"DQ(jVƩ#HcΦCr(wσAoPڻ\|wXb8 !88aaa]6šYf[.f3<>Zjwعk:*Wtt4^BCB/+ E]T˥!7T><mqa%<8C$Q='&X:ѱP^IEpq-i 3L/jP:NV 1 $1hQoL_G. Mq%.4/V8RjIiAF!E\ KڼK ^kC\B|sPs 6Ŋ\\|(=Vp FHPxA`0a00Z۠ (@90g~둗~($2"]6jBHH"""oqaR|ػw/`AxhCptRW\8C![m !Csg[e|;a2gllddhĕ+Ww^ g`l6OB5o#b9ޢj,IMY@N[bY{¨!d@?`t"w.W2&wgRi†<](ɵ^}݁ZWC,F= cPB}s~k0 2J+ `pL>5sjS'9>H~ [!`"1t(^Ax!.-A d@NhwH`/0<;A0 K <$9%Qh)9;*Պc)b=muv# ѬY ԫ  I~0>JMHuW];wlwfñcp C9x DQ0 8v]ɻAa۶mشy;A%˕.ThXzӐhH_Bn&X>|M(UJs+Qr ]𰋰E/ F* X^̵pp~4=?4Bl /8px`o0ˡc 8c ݶ怕5$8&"‹e.BN@dC^X7(8jH Q(0 I!0PO ~jbt#22;whޚ@ǎCzE@Vv&X*Cf-!I QϘoaiK[!? &f?35j0Ziq95'NQZZ mZv܁iH[dh4a>R6># ˴ʹxQ:&5"JM3.ZB!7O}>CK#TιrnÇr ^a778C9cIS=3 6>9No4f8 bMJsJscQ|ŇPF#7 F 9|Dg%AA!I$xy&1 J2d'ѵWK!ynljpἺc[/Fou/ U ՛*YYK}[yN+$b1F!44!!hסcqavdgg!((+ ?8q>8l2ȑ#0L`u֘2y &'ԩS8y$v;bccѳGOp\lg(..4hoa\߳]4yM,>uU˘ϕ)O5#5Bj A)|(WXIJT>DQfr /'j3iNhzx_}x`$;} ?@́'&.h++h{lht`hh7h $A 0򐿀1J/r05@AwvQRpV5kL  nR9}rTN8Sv_ SyY`Nt4mQuЪMK$:H?? !|r?hgÈ ]Ǝv hӦ ڴiv&Am%hղL&8[{4º`0A45Xƅ%++CL}7VΧA\}lY"! Y`7ٝ*ѫnD)pn#P)͓_AQ0j(dddr;@si琞hԨ>100gϜłڵ;/R Vv n- h?ZG] AY|B!Й-|HN>6M܆3 93zt};汜s*[ث{UKiGlQobՅnf0  # a8Hdݸ1dhdG hP ;N,S2ݑՐ=QWU[#\ E #l62zu͊bbfA`F` >P+A9ǜ3h1DG󔿮C&%%~Xc℉``X|9oXeݻ7>-5$8jtY<4'nXQmBU} CBu K[\, TZM"jh-A久X#oȣY7XR\CiQ{r+\sP zhpA.+D4aH3PH 35|q8p5E();&µ]Y4\K7L&]b#4,Yِ$(jn9Iv~]yQhk t!fbʁ7sL]^={Al6?q\Ɵ (8uڵmW/kMPmsDy-0zFsBn4aΦWr;3e]QgͭWFaAPqeW1|Xs}8;تP+o [`/-`9(F`FV (9I*9 p8 5ޙ%ygGe7z-ƔfWLD}F5KBQq,4$Jj" РA}XJ-txA@ll=#;+F1` `!&&%/`hԝ/*,BHH0BBB X,zATgRV&=pPkp8>"nr;v؁:u ((ȹ dd}e ͛5Gxx8߇߭"EeTB&\qBA˱R&N.X.A#Bu ޚ(UJ+I/,t8]Ü'߇~(0\pCuT[#x\rgռŹi f׊ wV7C? ؋/f[H CBs63p9sn#qyԆ5!tx"J`;];@Z`؈;e Bff>d>ǜwg{| łYSg͒ǴjO?ŒPV~Q!յ=c ۸h0]v ͰZة#ZnNt\CDDž$v;D]>\3.uf]~M̀d#1rHʱ!DՁJKK'%%%ػo/7l,N\ ر#L\7oƚkСC;^ԍ.sJ; }7髽ࡲH^<FB*g`)l $9^YmĞ8&ɿ!AnW[ݯTqomi艶Re/4f*$xfGD/tB9dr\ s q$p֚Ki~(IR/oaa!2NϹIy??`۱(,kQ\/>Ww8pc?dC:cd^Ddd$Zl^}MS @yz\D^ٚi>M~Α`Æ 8|\F݅}o߾0]ݻchwyvf6/E\" r`@I 0%+[eY=, ׶#SV EJD1GD r$.bsޙGu qƎT_˲ؼy3&NOP0iӹk2ڗ^s^ۇ"#=| 7>w/u3"u IyEODZ=l RnXReladݿɇYV|*Jz\I%<=ߤ,@ `#q! !Pl$c^O/xmK"@h‰  M+;S-.F;s] YzmNwOT,ZEzFMeAe$ox TU#V;v>|vNqqsϥ U+WÜ)--a=rӦ2bp>¾} VӦ-uVd3gPX[Nh`}DcQFɶm;c̛?T*ŎЀm+7WNErÇ1}4oob9߷4#7Y/F.!55ք_dBfg=pvZ~ʫ0jjZ|jf%(ғT2Awg+8PI!d&?M^J[񵊀@siMPBh66T: dSb14v:~kVC(d¤ cWի֠kQxIL]mphP?RWw9d5ky4a_ xS;n,'پ}h.uS_ ёlݺ];w#ضu;-1:;8PwS#8--lٲH$uj|d_>mmmD "pw}ǐ;oGƛo̙;rO|:2?"-ɲi _<$IB-)B./)7x={ݝ*q KnΝ39; ?>W/~$R [o Kn`QPP*r~#.*a "}'<OyG 5Ba lò FG +-%=qF.W8 r/]Sb@tФKJv10":~3djOY@B SCDJ}&r% DV]=)eM}4^uYp5/2/2?E ?y'N4x~WLN<QFyFl$ ^yUU'S]36ܳ+o}*oG7.;A49z(YU5[?RghllbZ͟]9sg3s lfQV^)%?~1~|Ր}z5yW4ΝA!ǐ[;]w"RʴeEH&MĤIRaYUJ˔;RL&Yn~~泔?# ?ϟσxw+kgݫRJ/Hwc9].gTyDžƅ# ά5v"aY<O9">aƼsӸYRs:+k@ fd B tD {!Շ)Kv.u ]RciMWEsBDګo۫BpkXڝxk"Ǒ>q˭xxu]+LyÍKx7xͷB0y$Z[[W4=_aF6ۈ;򇰭?McExvlWm(c19rD"a8~iq 8۷@%|%\x ! ,}` eh.q,6-}yl5cj0(7o. G"9/zΛMɤ^^Vf]qD#(.)hZz@޼F&_1&FE JccgA0MG?$x\F͗%:LkkiR^QNEEW 5</3!BoCS_ΎNFE]P5غ~u̘9&((P1l` cry3w%i7x<=~ޞ^CEԳBhD_H1kYdĿ/dE {{XxBwz˭ 6k<'=Fv4::;ؽ{7ϿlVZ!7RTƖ6tpJy`577SOqSZzzF.yy0rr߄? 2zr#Gd䨴7W1cF3g1Ue_]5Hlm5ՌBm&ƍM%qXNQ##+RP\\+((  ).,,$lì=r KbW0 1E1C'caį5}o\:#Fxc_:GU'֬Yձ`]Bzzycu̚5 ۲Ν;yhVNkі*뒕>גm8B/2qwGy\y@}HojE[:K)lFC{ބ"SFG<J3ZGF){7vsu7oMr^8:o b?BH Л9՝t;_4v5T$hKq5m A߀jݫ-p^ܶUߢsMS', iK_nETXK=ڥJ<\/,[˗lٲؐ8#!o1N76~-Gut#XO?i^6sȊi;ss?x 'N(׼ 5(w\d5UC=wS2{l$]ש(K2o<ګlܸM >&xbJ˜K! 7q긲4~r잓ӥͅ_#׋GS:Y#ْ>t&Q.%%tlhO % {Ǐ5]E>f򵏩g74ʓ>˦̫u-x`Q)x_9mV/s(-l; [z[Mocs#Cԙ"d L2+_YcMxS)Œ*8`bXll;joKn^瞣ロ#0tq|?dHI:;u E3AEBxӍxӍؖT%l IDAT?vX_eժUL>}˻#2HPCc:3?7m{n{9nj:܅6?H}H+-rOg[ۅ-9e:e ɋդA8і sk(NO4{҇{F~+syQp,GY`;:p1? Pנܥ`ْSGbmmla>F+Y49ÛJ #2i~M57I8tV1}iqCMPt+DozLAAPǽ詻̙3x\}UCӜy {g >]1tLyDž +:8x3.gHxxcB/|Z磢7WuVQTT֭[|̈́ax=ƛß_wu45ղyf9r$SLpz|/nxut-e֓Gyq3ˣG@ҷOP9. 22̸_!)'V6qeWqBW,TbE"$} #'W{LZF:dw`96T"QƴIJAw^ u>u]E9z2ɮ< p8z8DqY3iO#a8Vd:J^ `@m ib:a\t␎I@6o @QQ{/˗/g֬YP_# o\Y3/>3YH+WS9bEEx\,._'+mS8¯Ag^_Sab vؑQ 8Zw{ ӧM=7N{#1c >(t^&e[wvcŋB'1Yrjk|+WyǥT" %J {1bDqL/- $mCW0idB$lm$f0ut=)56y8C\5!c^jࣳxh/ msT޽ME`ˡ^4.{0mIJ557ʸrn=X#Ev 0Ġ $(h躆0KqH} )^OAP0kAc:۷ƛ>w emQһ\7-9?8$O>TnZ'e-lظCa&L`%^.Vv!|N8g~_}2ʾoX6I&)ner)Gyq.$#K8HO.Bi-ŋ{do#"65l]D>׷$nk:_Β(E}ג?_k"И<22*\==tY[- cBIb(+x M47{nƌ YȑR900~{Oqq1-^o_,YUe>|R^}uj+u:m+s! w8t07lDi0{,.\Mc? K,y|瞕},8dd%К9*\9 t*C<ݷ&ۿӦ:_w=I]]W];W„ ?x SL{o+z˭\892 Gv70(◭>=8\y9$2vܑ.Q%|;`ǎh8uTޚG{{;kֲ5>|qUX)5'N7K/‹/s 2hGh:ĕS9lcB+9.][Пl/H!igw#F d3&M<5r!`*ϱc *FAa $tP(IJ[r7r 1kz<v-/<rwvJKr[*'>\#ŐzG}]Yx!!*++w?~ߦe˖l2N40|p0b,v?wYr=̚9Ϳk.B'OfE̝37C.W_W_/&Jtj I& Cqq8&pHWoWW7d O@0:r ǩԪݿCtM8?ҟӟG} 02-M?H$xؾc;v;ӿy;w֭[ٺu+@^~O˗sݢx_ Yt&TUϲv??mmm|_ںZ =ǩr.'w=a{gI+%?gnyqq- .PpWJ4ԄӴ,SXŁ3P{e)Hvc܆(FTV;B;/ O`.)Nf B`X} /ھl0A L qei%K4OAW!{ݿBtrd%#+ywNfT־B~U`(}+2Dg<*e|?b#䦛oIaa!BhH[RX/ @A[r>9[m29z'; 7 7ܠ ~zVYC0o<_ $vtR@19z~K2sL^xPݿ|l޼y'LߖKN1ܲ+9ޙ"lektNផGyq6H"t@wEу(,ې趍aH2Mlۦc5Lo_.5mD\D(W0xߔf{;Rbd7v~H<&4X-o`Z'SutñNT,]OM ћj3[nf́Çs.9t;w>~F!qa&%%%pL${-@\@I_ƞ9 j4CA "b Ȟ(:;e /+ 1 &`@  `LLt\t]i4MYlrn/FZ]@82gw|[E**ΘcB`F:lB<jYk&m477ǹyTWWe"KBޤ\>Ӟ('ޱa!{^$6ayA `L40L@ו '5V^;bH)I $<~.U  9ѻ5I5G}Q.SK/Q-n~Ǭ@0@̈́PBVZukrX呙"k.ʘ4aؽk7u׭[G$'9tַ sĤϝʵ{Ոwvyq(=ZHi!<Invifp*]o1U¶p]% Q`rJBW=t3_VDpE֛7tPI #El Ȟ`'I촯b;TH4pF0`bLLi(b(2Ys^~?V@xP_ ض_qN?jTVQpu>@g<. ETd@\׬("=ɟ:u* Ɗ7غmW4M-]i];k"Q^L>|+ow^8z">>}o&2#<8[\4 >4}#d ߄D hNhG2s%_',!щlݍ4 PK RR EHN+= \)ٍkF6xgXD*Xi|\.0  ̀A `G>L]^cp#W`? 9H$™CD4McQDcQسkwL 0LDE.|ij-M ȘvR-;pUW{z5kx,\۶I$lܸp$̣}#6т7k.^}UN61zh^w>*__u/.g5yǹgBCK]LZ.eESm*j-9Kɤ{E7S\ȨU\:aġ${X=TpH>h&Rh8 IDAT~ LA;Cٓ^ n k h U+ b:!%*iW`0 "uJz-7#,0 '2pE$uc,bLJ&$ $2dAĔp=T!kob f( ;YC3!p5V"߂ovQ1]b!@!a(Qp> ]%4 tpCWser(Pv;RIz{{yk̿zh4#/yjrNN!:NW UEFyLf1@YÑmvO|nY6D"Ba ibzƸĩܱ-e9,#9"Yt7NOdL<#..o޲0sLf~z7ֆя~ľ0MK'9ݱ<Ӣȼ<#' sʠiۖǶKYVRj^t--5 dRs)!gT&&%ݠD2х< f,3>6~s%UU3n Hdd@\&%:U8udNJ|0VH J$R@(IJ,$t^N碕o.e$3ʟiݝ{K_\:3f`^DCPZZʔ)S<};kFz7)Ə#`Μ9Wg$hklֽGy-.R"BRZl MLw,YrPw!]CKj蚆aF>Sa0`V/8];'0&1v-ݰd72DЃ3$fTS9"MH<Y#)NBƇڥ]aK'd 5;ѥ>2A9LA+5'" ǽi;t=-2w-X=\zz8|\?PCF#TCsa' T2IggeeRRJ88x!XnOymU9tXUkUJLߓ՟&\9\ a{2D!_r@6tE&L@ȈʹËd8,M tq s4";H`P$ͲCa,b9DGgwΎnH)I,L#@ B<¢Bb(‹}:gFʕ+wmC=t^2qq6zr ں2ѥ b\uU^ѣGw^lݶo^q^&%sf!JL&I&{n~V13g0k,L-ٵyy8rC т~"!m[Ķ{llY6mc[)JYXζmX){*RRVVrmv%ũY)P.osFZSl?IgBhd$r Mb4"UљH8[?4~kth$-tsH")-rC@U.(ҩJ82287mJL觷ޞ须^I$IR6x|/H)u  D"D"D FpĉgDBϳ>O!뜌LeW+s [[[׾J?Fno|۲ꤣVZZZhnn$W]uK.eΜ9 yGR±cj^} jI&eZii;D E.99mm)R#":׭ϽeYDJz mDi`PRNI1E1]ЦU!*T? yCӄ q (xRRP$C=#GXZnG_܅}]|8c"L2>~o2Jyn]y]l\O!"0H`0H8& 1Dn\cz7r뭷8ݘɏr䴚t -׭GJGӍ+FiKۉ?q'xcDQJ˼ćGǏFk["--44J[{twwS^Q]wŽKMMf<#3N@T#NOB@:<᜔i;Ƕ-ˇ2bg۶,PXk NY۱qۖ,B"{HvڈBn%&[)m@')"NN&eL QD,K- DP95'nY'\w)Mw!i7,]MWZ]7<{u 4@h-[ALI@ "t,uI$D cH,ee9\byny۳8D>GHܶJ?݊c+G%/ԫ;[,4pՆh%DYL2KF]O8Cu2g' IBcZT*\=+p|y?Ҟs}oIdd>?L&yyihhc'򜖖JKK<>h|v~w^usނ\"l9?VWWO=+W$J9UcqկQUU#lq"&SI֮]/"7(6l`ݺuZ۷iAz۩ɭ!O_ąSxn[mO |xC)0xO*A_Э WxbLHUD-\{-J08WUg:]*ޑ P9(?w!;t4RD_?<=p{/~bvwIJA7?&0,`ƵWɬvIP({s-i1P+F*Ww CCChd3Yf4'HT|g=ۮ* gyM6$Vٰa6l`,[+V_I Թ"۱_W"%b.d*#(> ؾ(be'=e{ZNRGpie qC@m]]-%rQ\?VT~wAW ,͛ٸq#=^P(6lpޙx#(Qu_2|,A+^ăr߂PV>-VJ zE Pn/~@Ċ㸁Ŋb``;D㯃ǠgxW0DWH Zp] R o @g]֎Ҡ _> @p碑mۼ< Ν/~ V\ms?zQU%*xeYd)#?q(hp8 ~>\^z7+U'??dÆ ]v}1?$b۶[?)^bS: U!@<$DJ^;eJ@Y l/f @ 'To8*o~o`)ɔ8WGH+`qݡ\SR޳t(+/8ן(ZJǕ'@C=.=Wr|gg0WB.o^Ķ42\5LdhdbdKYtj hp8g>8fq^A!R"nǘ{s=ƍٲe˄m=.\7 q?8ۿgϻBQiZ nmJ)qU ʛhLy)q**Aᡌor9D)̝*WAQXxP`(*(|(_UO<=W_W|[²,L&C6xÿ1} 0t#VyϊlmC:&j?Q?&'UUC_Ҳl:͙׍I74n}f5MIH؍ Ѵj) QJynV9<ƍٸq#(~lذ}k%e1Bbps7MAg?{sU ,"ںH`}S% @)vQ2VJ1RtG _QUrD$<{S~2~2,GE4<>#\`ݰA5|! ̫q=gyR5P ]C&%p8eAHp$B$hoߛKb{z0ɉ*EADrRb0<<3/AkMӝp4dsYBA<% 0::J<'͐G a ?*=hO\*ͷm:::xGXM%L*kK>OUݶA~r؉{ËSaV^azZɼ~[]be[zM54]EUt]s'ʤk6jaX%?^|?Mn/TnQ |j~{J_\n%)ZJq XPh__P(Ī+U.b?)eqMĀnjЃ{UzxP_9*O<XJQ:^m{s{c43e[P3gqXEcQ$xda!(*==DBak++g'ϑ'f\j`;˶'MRVl>K6ES5bc1L$( ef@qH(;DQ#Z}8=0#B]M)hπ2M,*uSO?#|jXS'x套hii}ar> n*U_?ӫ LSf"4 Ll>u^2]},A:?}#]Gݎ7NZZM ),fU :hu{Y0 za`.6;q  G8p ]c{?oCt>{e,3o?{V߅AuoGy tM5\ Pz?~n}Ĉp[ho E6e-[ZO>4kUU B,*PKRJHӜ N:]?d\>GX8it>Ej&=DQd3|KH[y۲ I\Z?&N1e&3f̠zojomk(n;g.7xX w>xOQ>~=!B185to}O !ݎiYZ** fϧg Nf,&.#-=t?Ͽ'Μd7/O.K?쾟f-:}l>KKT8w峄Caa4Uc؎C<c5?Ͼܹj}@mh}]\/uvl߂1af\MDB;>'Ξoý]x mMmXƷ-9ʜs+{pT>pH?#A(p8\fj(ܵz8[ رjd;IbY6mmm8p]kN §  \..XÜ9/?{FJHs9ĉ3s]G6c \f><K=̌d#8f_aptD]8's&ZYy9iYhJK4N@4k,2\[ZCm^ը7Gʌ;U.3xw1ExxgTxCP7f"k)?g*xx" Xx|%ՃAa\fpϚygϻ|xh7oh7ͩ&n^|#Zrvw3ˡ SH`|hBęSfFFC!Nf$3Jm3ۉhQV;`eۊxeA}D 3'YGP//n}X8lٻ|=ʑmn'rj4u,l\zoXxb('zNB,- G \F PM MJG9gϲIWᑻ |رWCQT63f{AFFlI$v,4MQl?_`n>>=GXѾ]iܲfaXվۖ-V@0!b(C"@A.""@AUSC[{ c#d9j&/m]/i׶N6% q[N9XT Zț&]i#g"FKC3\N}" f'U*2x/ 0eD h9l$FA!Nڶ6^/C2 "ϑ&JڗQ/iapdL4`[#n}7Km@2#}ԷpϚ X;2,P䜮 01"@A>!=d Z=cy#HrKZ cs).wD4ilhkJܨuREshB1^2;:vrAQ̞ςd3=7?46H}M 猤AD#:o1n}C}tf:^:cO(^Xq?VP/gp!Lܘ]Ո,WO!oWHiN4\v c6 `*W[QS&AZ 5dVUS1FIƪ eUY0_p5Q6e+-enBFhXu;8w~IH8PwWIdsfַf9/o}hl>GSjk}f GK_."DAPtZf(bG>((JaST¶e.Nqp˲sF¡kzXG ]N?z)n_sێ%٧ix毬prcX! ˚YFc$5Z-1&ZV*f*f*收s+y];Na[u*V|ֲ\MPU 78<'C*DX,F$! C W;"@Avɪ:PZXp<:Μe`p8yK]}(U^U[7 f6NuW,ਦj[Fc#aBFƃ \ApU?^  DXu ?<8dr9v ƨi *.B'mf$ft,e:H-ZbP0uM  "@ArE)PU 2L"0x5$5o$;ҙ'4CxJDd2I6IMM]c_g<,]!"FAJH%tjNjm4iFapp~8`,VZs:P4%h$ ƨ%Y$Y犐DM p͛7WUJ4AΉ`%t v<8/BrLѱQFGGfhxFct` Ӳ-qplm$? B0b^z]H$L4#'&QC<'4Mcdd'?xM{ W>"@ O !lt&ZDFG%=&͐X qll,۔pAx"Qn,7Q@8& ƈq"h"=~uAz)^u֯_O<˧LAG T D6(!ia&lL6C6u3p'Z\XT xDE4tMC7 BA8!v~DaHG1UxoSOq1y{1.|./$\ D˲wʑ L)uexqQ\,PES54ݵaW:n`:CL<g%H0.UĆ](DB"@kB,4-,r- r_;]:p1 c@bMu&놎-]8ѣGy'yWYv-O<k֬gV\xTZ.[O341"L"@ µGpO# P\׫ #8 B#8yGUUp 0:;SO=7 /Ʃ|G7׉׸\ "DˍA@pmR1@q_Cᷗs866 /3qO?4կx'[LT*=x≒}K8n @eXeTǐ u6וOA ^!F@Dp"> @# c0e|Iv??N8 |;_;-s 1Dibyy~zz4 Q7ZLh'I4ϨfY͋1jA DNDlL/W/_7|o{p0M\>ʩS~"g]ǢK#…30#µA@ Ǻuغukɺo^xzas9:::غ}3٬+OGGzW,[{KG2[oUJ粻y0)FQDٳg>wb۶9r0F8Df QTUUPW|hHAԈ %P`QK4F>q.\%J$6Mxv0[ub$qf͞E"`'8rP%KJSLHG~Lq.%% ”zXE>oi;McǕS7a02e8%\f$]A XU(8TÑWcccX@,݁[q=FuǎsaXzeվ>tcǎ̲KS5{ .왳1H؏Upn<9L$-`Y6 6mdPU2qe[õ~-ˇp" ¸xsxOsQ λ`?0O|Vpa~߰dq}y㍷ ={>fV.\@wwk(@Ʉ޽ؾ}t]+_2+*v|]vïj򘦉eX% IDATU:X` qAaB|->|AV^{=tOŪիhllz{{ٳ#4Mc;bc,˲-و./bAa\\GM"A__?{cٲ& ]Gu2lzw3:_+V+, ~Ucip=N z]ɏ)Μ9K$4-:8y##Sr|K[NXM3|H p" ¤OxKYz%ߎ*>|q5ihGQU SAH}K܈  S9P߾C6YgM sZU[[Ul3඙:+ sΡ2Y2::Vr6gϜ۶s FGhlhRrkk܀I{PP ?h{Y B,cLJn¶~jjUAUUVZooF7t,Y\SKU^aB} "DAKf1o^+G;n>B؞bT۔LP[SÆ'Q*ZTZAQ8y$ken\> mm>zc_o+YyN55nysM7pW`[r0ᕠqjE  Ǿ(}}2V\oMWgl|]8vxEmcٟ7M'<%c y-ZX"fԩ;`nzzz\KKkttcLR|Tc鲥lzw3uDH$o~᱊G|PVĔqyɑAA.)総3xd9|>|.:;Xs%m/Y=ލ`hpUVLxx"O|ٳf]Ջ/CCC߮_3w.xZfFWдogtdW^zPo\-D.w7꫿e;jF4Edv))/YmǹQtZf(rAO+ *Rdm \x$r+gg2iyWsc)x~ B!16.9nL&K.OL&xH$2A9.̝rzt US1 cjDZ]w+vcX UUJ꺎 u9qMk0M^mioI&kB!4Mcje.ߍZp`A\AA" BB D"a"mi8驄u+@4='ۊPSSSt\U]A*"?~6|?_,Ǚ\`}  |JЊm8LUxBq' kQAX=.A4%֏ ]3ǩ&,gq'p_H( o(8 T .D$  W?˧6l~yw_kF$!O9CPDfvE"۶K'ǝ[Nm[VqiXͲ,,;= m_j)  W N#bɭs`(w,~_WYтx]|\~%壊d" ^=wWˎfD"H&{fs446PTMU,cCCrYݺ taeYtwKՕlSa4UcժlټaSWbb7JP!( KEhĉR[Bw»]U 8E~J}=( 8mWMW(%]{r5  \)~N8Ao_e񅇿@oY].o{4immO|o˟{ߣ]te˗v-tw=:y+ポ4h?+ ~vC}C=cc̚=a~O5X,ٳgG$nֹݻo\fdiq=J! VMy8&DsA) JQ\+m*b;_&,W" ´*{Zs (WB> ]?v>vͿ?>EǾ<,_g喛Wm{#E$;wF(/?#ury4MgϮ=,Zq0-TU LZo.(Ns=6Aᒢ(nef ~Fves:yիW:YόF0} )O T*g>{۶~QUe˗tFGxɧ9t0 -;a)n>&K\/n4ɲRTIkY1S șkVH {b18 B _|+UZ'M\#T(UDQx%  \ ;ά3%=&Nm{޺~~ ,Y[F&;>MKK K-Ŷm: 0<Ǐ ={>iF#T!r<{S?OEp^DVuaYVam;~}94-tg3g(p"wș6Y?0t!Zd:ѐVzoq-[GUQԂ)XDT5Hq  \T.Ƣx lwmll, `~٠t' ?o~ʫc\7{vZywؼi3pa%8;y46n,*C457qݟqJWW7Iһ\M4VbZ`y0P튒ir3S#tș,59ӵ1-N *@S`As-aDn hMC+r\CT,*ć؊[SwdL&áC;1cƄٖ.6MMkmgW)̧a zN.WNתac&maZ-|.Gdž1MSEÄBN t"!C0ܔkeY6I.gfºt&G.W8809=0o hpC[5ģ:D7 wiAbj4K-P|k/A%BD 0!Ԣjl0T9:u4#_"K,H!["^X*%΅B{e@Zmp*ܫ̼+6LIJLLӝv9xDT b Ȳ`;؅"f++~x 7oQlǵƘÖi>8aw.K 躎aq4M }QUEWPQl۾DAA&uuŌN=]=\w+l0_ƵVBc(_q`(@B7T?RBs*AEQ&La&m1MٖugJ#Y1I}Pr["4b1Rk ~Q:ŊaN(d !C9EKs ۶o3~}2ʶCi]q̐aia6Z?lm ]/'R8+kB\SAa\rMhHHWW\P(?ab ^Qtef xt:MNl!)zĥ%p-1^##%Y2on3uqC9ؾ(~kE:q d)vkha&H;Gt`VY4Mt d[ؖm&[{09n7iFbYEލ1&@ XL >I_y{Kr8`H ƖdGH3#>=]8U==Ҍf[iSU=; #^,8?DrD BP(PS.kLB_V"LV4Qϲ`-6).We8ecYaVd4V+B<\&Ӈٸm q2K:hmizjQ rD*Ҳ()J.B`:)GDdE,ja@/b%;&yeuH˼ӯ"ٚH|XDQ B(P( &B$s@4tCDzL]x O<0dݻvjX|Zpy?'fhoo#[WvcT qp[zA™dIR75Vg*'ì|ϣl/ZKhk%EǩE w'c[&MYR)L0>1IÌL<>\[.n -QNŭ8"D BP(uWu01-&/[ɞxyv pcYi" =aQ*9\H8#X,xM  ]!ΐNIؖ] ω0zĹו!Wnď^O^2Y OKQMy\ŒKR):ۢ..cd4G~'G=xuuGQJ G ~0!:aR7D%@ BP]0 lI9Za˖/l{ҤZܢo`Zgn4 FGGa_><\vs=zꨯ'ɒrl[ UK|@(0@x^Eȕty[Xry=l[9+0 ƹN7Q 9B!2i3r+f^C?᭯ f V<SMddu,P( Q6L0 ,qҙ ]x1{8x'ٳ{/{vBpDH#^@cc uuR)l o$>*=†~@ucGu/cG쥡i+.`QW˥z]sG7/0.CssiйlaJ}cyl dXPĉ脞+h!B/{Ĉ BP(JcC&1~ZJ/]t:KO><S3s!`&,uYhjjL:M*²~Bvb÷UT6Y骜!=+CS*nk,nu jikm1KC}dJؘK}\%J Syd&&-BQ( B1-qH0,q9)1FxqM6<ӍӐ(\nؖI6cJfH}}u d2G (cU2R|pq]wPpey6rM^Q Eض-g^|w\*%{]M B;$q(P( Di 8  ӲHRdY bR$ʰCitQݥ^ FvTL*M&%͒df3#*Ϛ8$QGev}?|<z 49oU^NrB~!c9:[1MVuQ_fמ^bw(o&QvoL6nj )V;^%@ BPhvTu4LAi`6TL&KXT*\<'}T̀+Ȉ*EIJdݔ"NG?1:::x׻Ekk JTTQt y.[ba!ٲBX]mӜs8KdD,l!vXL"/d{8ΝCV+_W^sӀtC]NQDP( 1I\eC7dfۡ.z.R|OzjҸQ MӤ1 4l;ٱ~<üo+eKۘ*bj@&GW+|\'.{ު.78J T!aAGMu4gd ^ܼyۣ>5a#G IDATY@+烜(oߨX96OB8hȀHM :MG XNP* 9B2&=?ѡ +PH|D3sꙡˡXiHQh&) Èî4MillOO+<zUeU!]e47ǾK|瘻Q\6Goa#.>FyUϽ:q! Az@M=~e mqK.>'wFT {m4˖t09Yd`p#ょde C<1HH$f < < BqL',18xs|NH}QUIV*.[ ه C璋VPMU{<N]S5JHVtJYA-݌OȎ׭Թm]VVEKI9Nq0- Ƕ1M+.lF"\Y'f{Jz@YP( BQђU3u^G lfi-o絯}-so~yg+§Ԕ(B!~&<]|Dηsߏ΅k8O '"zJ "|rj*ëP( 4DTZnnl??pAqw|?Ħ7OJH6)>L;hdū(I #':uX/<fՊض5yq^K,k3cjѻ辏Oi21=£:!}.ezDP( B1#֭[?y/Or 7|>Ϯ]7r?3~edQ{|r,8g{94@C}F`KOAzAJ%qX8w''Q( BP̊.>裏2>>W_;NiVhz#Spe%Fu,8gG%CE-]K/v Q|yyqId?b2*0VRDP( Bq\è,Cq~A;8϶8,h:,[%>B8;@ʱl`tRbQ yh (wOx@@槠 BP(Y#`tt7lٲe_xO㇉+.-8|fOrFFs̫,nAnDT׍{~ ="E^]s>'Qycs ǵK&^iaI^rYwBP zjnf*֭[Ǫիhhh`۶m߿_us /ԘűqTQJ%豣]VBC "D,昛jSDP( B1cvDr95S/{49 N\2^ܯ) "3(P( B8.a8-G9dקw۶8N8>Ķ8 k`Bo4,V'=!Xok>QϞ>-k"^i-|b!EP; xPDP( B1# Nٜ"u aNE`>ޯ5~u^[$nqz[* _}ir^n"0>L~0^qr <|1vFOlap/m Ka'(|. ÒU)\pnz#c}<;NFdHOPR\wax]n]>h_wX=J/: O'C^t2\`l,NkK=Fo0 U'z@9WiYKy@ BP(3h32$Ko ܎ ~h"|~կ@K ˽pMc)/ˌ `X͝+h]T]cC0th7=Fz ԵvѲ|;)F}®X! g3@~!uM + D P( B1M-K"O zO4{9~|rïZ;W_PO$'@nB"v(ȍ[tX{~bZE?`Xwdۖ 띎 Z[*eS({){AcMxTDP( B1kh[H̖@惺8 `(p Ŵ\ CCp|oZ b GmT.tCO1?K^IyatbX.T_'%ɲ# XYY< BP(z\@wիO852a 5y dw*ez|>H5o&?>p_7K'76D5t~po7٦JZThNW С=i-^â s%[˻oH!%`imdN~0'hk *G`jy*P( <~7~'|3g%NcOtL: O(yrPow~ҹ接hP_7xHw-^C1th2p`;G;Aӂ啢"azN扄]~qꂐ& %l$uCy'aXgKK15X,BP(g5Wm۶iӦS83*;3 S,!WP-@Nn( kb}{ ˦C9C'SUѐ_7/Utm*5̲Ko9NSP#* ^!^*gg BP(Zyb]&ahDg6ڔP,)8(2w"=[ R( BqVo>~Aض=^iUDn$!:Q)^!cnej5㤳's!h^0)8^y=)@c/Ȯ~D;SP+Ҳ< WM?n!ǯ92M !ו CGץcK  |.s2*< BP(:J6l`hhbfwQZ-) tčS^JbZ]]dȍfnJoeKu|͝tPwP̏Rײ:a;6oI< W"({ubTjQ( Bq}gybڵkyNшBBcItF'* ljrov:˫Dgra٬{]!pK!iv.m!AG!8!M'/4t\@,\ea9҈Rҏ BP(*o2 _שXsqS g\%)4X PJǫy NRBeZ4T]Sm0S*H$DUJz)}@f;%@ BP5ڵwS[lG߅S ngU}2GX)9.( w6;r@ BP .*ֿ~NѨ X$S6te2*Q9EdS}s?H> BP(A^|Ŋu\r w)ѹA̷X/4 7LU#d vkWJl4/cRDP( ϗ%_b]}}=_ULj[es3J 뜺g7ǐ^ ˬZr\Li Fs;:JdɄt8y.'%@ BPr9.r\lذJıQހmj P* "|$;&O'79|gynAuv#ݛq 92M o__iqlWM2&zVLX!r;8%@ BPf۶m뮼J>O{T"6L5MyTz@hx] A),4p`'C[:9lTk.,< bgo_nдZSs[r繨J( B8#g?C=T}kض}FunRs<,㚲kIi@p۟rRɍ mjߏ[6kb.R瑮k<4b)zv-Y'LXv^&(ؙzr2!It&ULr]n bn S҅qMuQF ҍ[ ?tdZQ̍/X- ZıD,<2Q,M@**fqGbevQ( Bq /pNY/~+VCqbHTE U4-| e1XreGuA1/п%λVv8=;o# er1rcC͕->~5&st,== 2TIqoڹy/,s7,7=~=4ֵ-[t?r ܁|yyJVqb]O=hlfi,Nt c7@ҋ޿9^`x]ŵYxOipX?hr9|~K) BP(r";8cccu] yk_{FuvSáCخ-Gbּ<=9):iMDKyѵz?ODn;s %msKlzۈȁ,:R^N[~g4.[̚k$U/C,'ê+~w+_^οt ۱Yۗl|=v~~L;KncՕhmŔzwqu4vJ6޿`6]elDZ!?6P? m]v r ' -Nb0\o&yΕ\ʻޥke!h:=;p y|Je)ɍеw?Oirl:Ͽ:VTSg|cU!ȏ11xm,X}:\G+)2ڻC^o~'Rg)S>fQ"0BP(JN/Q??o}b][[_W0Me֜hdeχ4\h̘8FM0߃鴴S\0|۟"@Zj.rc3]gvY[RT+M,۟. M 'ӈW^:-hr#riaby(GmY a9joPMHZĞ5]w@ų$I$'T`) Bq3q.ȧ:駟CP:]җŋOѨMD#t4JqX(s7Cһm#{_}{6 cj.n0]aFk),[6Ss۞k5׿\bS4Q- dH_bYzSL#acJ9diRc[X9E mgOx@Z2Y麎ah|n'et5-~3 5UP( 9H$&9u4,v- c6 a\׭X~쥘oDz={-[Σw]y\@}]fӻg ]tsKDI(!g6R\0ܿM?l>@kJ,'M ."[bw/ .GhpL T1vGl0128 KegN7бjt=mK8]Ϣi:ͣiijkhOwӴ4KRu*îښH9^@&?>!=:Y'k}he*HM~ɯg?Q( C$cQfЈ hd"~ؿ[n>c9R**$'9skc:-LqCR働`r\K./Ϸ߳Cy ڵ^⒛['X}[)RY]nTofwopָ  e^^o[L]k'i[&F8F`wr!x lXuŌmhc:m-Ţu^EKdXV:V9Ox ;ѴӫʅBqCר.i:Z]2'QBP P_N1t6}_GyNy,X0ފ&)<! }<-Qr]"RI'˯@dT::Qȍ19> @cf|rcC'FMz4-\Γ C}Xf=nzC&i['SQλV\r#C m*,''볍mzJN2Q/+= g̣uC't5YvLNI\_`lQ1 -dZh/zߙMDzʰp庚ߵv`9iZ:W#]z>s)sJ('(˙ʦvsٲ,?&;d"xdhw+\Rp׿"Sw'Ðñ{qBR4LFރcEc C70t4  ]|V BP(*"(HLA$G"܊xUxd,$yyw;y-Z[[ٰaW_=(NA~,g C ,iO^opdg<7IV5t'tI1iAh`FJ_DI< :o>Kg}k'2DKrF]7 LY~)@084FWg qaUiuKg*Dú #e[Md*_j)0L̄-Q( `sAe""ܣ%*aETqù 20S&> ?fxW^[Vrcn8uTT&5S?ఢd?Ὀ&IBK.jSk=_wC.#6lwu]78ndY~@8J}bgOBн0k,'̣c[:/pNJxkXmʲSJtY1 (a՚"YG )@ɗQєPK-l4)KpBP(#բ!\a$0r]k6>[6>Ѕg 044mضeYؖ]a| 6lPySð'dw}|>HNܫi4g/t맥Y ݀}'ziџKY($͚#C*-9\`mJy>, ӴdVJ$~J( BqM%r/VZ‹!*U}c(>ip,Blt: 7wɭފc;sUDC~yan 1- 0MzkW9lY`XE=mIԒDM LNXaYa !bZЫlWBP('d1*cneF2V.Hb}ǯ~xm?-o~KlDq[_tE\uUtM46n!Yqf kFX䡇 _]w`B|uب5@eY|ag\GXބiLΩglٸs9]5{/M3X55JJܸtPDP(A2,*mń?'LT7:gU}bb ^uxK/뺱[~z.*gA8Cӟ4<6l{L }阆A`weZOCᦵ)~ywV,) :qR cd4l<ؑuY*,3T0Ͳe/=Cr9TN6F BP :czkL|Bd(А]w1MjXl|b####_ /0>3|F[П}t*޾^v؁{.raٲe\qkqiފjZ5Mgw擟$[l~77njH ck7{ R_'us DHi46iP(xyGB\eXaޖz>,SaE++`FU$qs>BP("K]klاQ6 /HX%_W}{\}͔ v?r+[YE_7mګE r֮] Q ]h!= Be b=r/rBu.xM>1VuiͭMwX2%]¶r-= FTΔϓ|bGnIx0>oɣ\V(j4d%Zihw#.UP}%.\Nx+[ES,ws˫n}t]}>Od||P( oc(Jb FFFضm۷o.#_d|l\[hnj h&⩖RX qfۿ[7V|q`kE @zdijcOho6ue֠T`> 2U+ T*D/IIoHnxeh,l]0!.d3DeA) BQ(E"Q*Ǒ\w V`ikk<}屟<@:˯Ƿzj. 5w&>'6"_z!Ǜ[pq,Sh۷ox]?/n64M?17CGG1_(t#UNv60- ;,r`Y6A~ȶ=1?so]}tF8xh0.<^"O:UY:;׶e%aT*_E#jX:%@ BK#))#ЩE @H*\8~>xn!tt46/bZ[Bc !?0M ox/[sSOAz ($5>A׉~ruϗZbWUݻ_~m۶GGoelܸ=T\TNr6PčLo&w422= W fu|."t0egg>)"M=4d{(1- +,k2K 4]WwOTBP(P8Z^4Ə Jt$c~inJ__\veZNz{{ٳgFF8Qe믿>]|I^׳tR-)Z xc#/&1ЊWhRnvsz[ug'g+ D4{r-^/~>|??k(ʹNսB ]G0~+ )Nk|> &ai]mx`9VN> X]M z7,ͳ!N(˲rn%ߣd%@ Bq.hTTʊV؉㉩UMz:ӟ4v튿3tg}. x|_'߾ ]y+^><ԓWi7xS,@x ^ףa6V¶m9̿}dXk_mVY*z*/AջOe"j$(@x?C%+U]vq뭷irr?G,:*?AhNaaĞ24 l;mo&sx-,_AFvtT:^p2 Ɔ8w'u}$E86ccWaկL+(7HPDP(z!Teif&SGEhX<~ ^:.^w1$_7 l|b#o|)L}Q4M㩧W @WWׯgႅq)(V[~X|meq-w6`1M^?}<dWh;)@Sd^dA дp4u^xn)G?NSS8du4AA``?@LDDCA&?7'|,2 HY3(8 dYM}C#8x}ߟR%^XȜ;,[~aq&3*ëP( ̈liGF`yT7"jX]*$C!<± Elm[9t~g˗/go޸G[.r`ǎkϧ֮]֭[d\p,[K~v,Z?1Uy3n&mF{[;]tp=--r]ד%\ctl>q3<-ɶlimi!`ae~0o[o)B@*H t]#*] fT@ o~1I$M{pEW{f)DN'H*&S'=|?`Ǯ ]W,s"ۮ|8aU{cfS2dP0|#Q( QB*WF&qU&bк˷6?~q˗ى8E>uD˲/Yb7\C\J' .on"?]5kl4ꕂA54! de+[bb|g}R(LOS4R漵c _=NJe+Y|_ EbSPmE猼 e!ˎ rr\y<#r֜4(yc27LَN<4HlWh W`;) =aVһVd°dX̯L BPy!#)<]MI'!<%wl<ʾ}‹!nOc \ɇ455pB.\Hˣ?{yl.:H $%6D[]^HIg+N_\b9'w..Y%NJbob' zS~LٙJ$XZb晙gfgwmWAK.Y_?BFf+/GX8eIƔL@AROL&ٻo,QRXXE \?[uW1WӞJD,#1ax&Ob[Z cK#lYN9+RV'b!YUdYt^$fAInv&IjV&M0aB1(2 $[B$tTPWB0m>Llz'ClT܇lx sUEU`97"3g<ô&::{inDU9!@B3L].öDȊB $8lH} 涟પ>& Ö GžV|O0 :RՀeI^T2SNw5", @ 8;^ܞIf%O,qȶϺ o6;w|+TWWc&oysrs!P֭[g„ \~征;C5meE0aoD"HYĆ3%Uµ\Ȓ't1hnG___-r۞c$'tx,M00IM 7/wb]A5HuOd8Xd{>s3;∔uLvlgo?c&)18dZ(KIq I$[;Wzȣ`$dEAVS׹?#ʹ$Y8~5epSCLY=&ICC\zɥ\z饌oeڰq[l5̟7{S$3. doQJXNYC$If@q0S]$0c)wpp] l~naV,_Aj7SP(%KYd g!2o&wn$?/>H$… 1gSR-7ufʽbenAKKriXUIt/2{$B=bR^QNAA> (!//8I;孮fR; Vew %B@ddm b=&*w%96Ȼ uY[V̈́B s))%/?,I&$tNl!H0s3;ܵf o|C$`0w3tvIUMDGjղ|(Z3,nW':O @ 8'&;lL;ux-N:< *?PQQᏣ$fUb붭\yŕXI_୷bҤIxÍ̜9Fii)eee b}mJ;X'~#Akw‚߱`Hm,"ęv}ǵ/??b0>Uc#L(`yEsZ}"B< ɩmfz )*bS SJU&%w<y &i18.q᠊]!ል@@*bY;vjoczVyYO+ @ =NaAH`FA`t%o@lڼ:rrrY?@,#Hp!JSZQQﺛ۷cul"ıX+ V"#+T$QDVL 0,ɑM4ISAk[7me8d*J( /IֶF"$ ^185>7$&AU&` EQ]W+W)kjֵ*nb!gIVh2h "KFFZWPJo.32-> Do[8?#)>ZZZxX.@1N000@8;HP^^ηm~|tMxĢ19b;ʔ?lVK\ёiן >N]!n&2,~NKC˹9eժי9sƐd}̛7wHs./ӦMW¢ 3ljȞ={ٰ~rݷZbl߾hN=p/AU q 3R!Di(!>j@ 3b`i ڱ$A(5 O&ʔ%44O5>$>{^ɠ*&cs4&$Z 8VI$l+n+'ӕ*3bTu ɖt2|\~R$Ib=r9-K,7e|f uk??O[[;w|(\vRrrrNeuC+SdIerFꞗ%ٝɗeMב]7, MQ4!Z@CӬG 1gFxMKcpHDcL(lGBn,F{{{e֬j־ ~@eU%8p/JoO/s.C@x7/9j200@YY)&MD<w(_èOnrsc*5r1ed 4551?@0d.2JKǂ k_G]m3gҋ=gkشi B!&WŋP*2W^u9/2GvIdinxpXB0 y=L\]XЊIyGJt W{G !@@H]HjPa -:WrZj>#nF^z%t]qzj:}t&WMM8Zٳfeګ#UUU|_nH-#[.;P)wGO:0]hk~Haa!u,ZHoO/ǎ5@{{t1cp0-}76G4Mƕg^:O2?~1yrElݺc%˼ؾc;mmmL8g0c sn3X.fWz` ٓԌ:`4cl޸W^Z a۴aSTX$IOn m4MƏ޷1M#a=F%>cGnF&O# @^nd"IaQ!}/DZcR~[8a{lYWHn,i<3̞]ͬ y!͟K]!:::$-AFHPDp+8 Jw֎0$\)&ȊV;]Dkr?t C`e"ެ|C;]xxK0xD*{kp*:GN5s&"ہ8s_C @ 81ARjx>f2f TV*E /bʔ)e3f ---ߴi$Iv{_NKk X㼼<&M:Ϙa o`قX;HRiwRfc$,Yr)6nJ{CIHEEOtvZm+kV^>iXX~Mkea\ņ8r(=S5RPhQn>߱*,U)ju6lf5uw )@dY P*3gΰ?kCEI>Ja/C2Dt%71뺎$(.+#4dT@lKmpmAbApӜ F;JJowRDeJUQlˍb-Kvߺw_N,nC]F@  O WÙw)"_zu v<+_]ɍ7?` /Y^{Xn,믿W_}ϓH$Ceoy~= בx3wws Oʄ㩬С,YzɈicRʚkdt7+,V'E B@ d"]zFޱ Je?nV^ͺ1{lonVb̘1\wu>ؘau w;テattXSN`t{UX@X:`햎SK{G;p?s.;o<3CG2ڬ_CuyHjǏ7 ߙ3g͡>ӧ?(璒b0Ǐ\~R+&lYZz0ؿ{/Yլ[{'Eikkg},_qF{IE(f:]xFj83qQTd;v@2$;h0e˕J20$UKV CqEa?+Uu0TFGl`?x\dIe$ɲhx-E}")?#U`]p6D i *}6vM3cl^{ yF ^{5S+/sUW3vXMiiiS |+_oUUbCW=WړgDRXT8哸Yٶm;&gyl޼m3ct*~6mBUU%^8;>rXZʗ_cۖl۲B,綫ʒ6h4JII1-+# c&3g)㏯fyb }rVXp>D[v(Lٸ "+VY̶xdņ80$$jHRKψp o?$Ce[0ɶł CXG\2dlS#j=;.yL#>ekCZ  Pt H2e gp_R;Mpo'É 1S{ڵkhE,X~Lf붭,\~HXn? U?2uT~m~pN]ta{}Թk̬랑k8Wp]*gt5hb)Z'*6&$t3h9}atwu!`ōگ@ 08 kӃaqN{z V6m%PXx|4MO߰OJKK)--2 ٴܲL981RL@4IN G k{ XX2GP V% :Mpxun ƳG@ 8Și7 35`e Q_9iV{Ŏ;3;v@4'Ȅ|3Nk ɡ:jjD${Vh4E4.̝E$q)ۃ97I1?]N+q"qJt vdUVwp~!R1fƳX4Sܔ!B8rjrqLprplE@ 813}.Br+~v dp]2SvHՒGWWae$Ib$ LӤukױuKD",Z /ɲe5I4en_[t,՗!ܫ~:>q#s!0epbۅ0%8RQS.o7n ]Ak\Y9 @pd  H{kc2]+.!tB4e9lKCu8?޸e4}?oI&<#cBTlFaa劢$k{VNx; L"]{.Xש/^0]6U;l%"#6\k3 ÿ|۸,&CIHܹ_tww3|K~sfq%~>ᰛ*eK/suk3/Iq1L#%rI:|f MxXzr4io)"i$Iwx<@?MB;~֖֌h__?pM8Yh8@8AUl|d˨YK,d bW/O_C3]eDV#q:@ 8q,Lwg03cAR>q!!$ǎWa֭I$;u475ɉ0|TϬf]5P͛7c7N%2e 554oL6-6ˬTnZ;vf'9n" 2n\ۦ}V"0WPVVek)-+e0gFhii+n1۶nc}oʥK/˗mvw-$I w7088Hn^.mmrTTV 3f`}L65˙j 'd"gt:A{OR31eY~ ts)G2= _˖GtASK#8x_x={͓&o~2*++k[YQɏPe.c]myf<28LXt)---\r%J!w;3N`wKƬtzMsS cJܯ^|L8|P5oϽwhijU7ΤI4?hB`[CL_|Y9x[ǟÿTS^1[wθqe {v%'_4 Ez 9 .vnI;x_A)'B31E|8 Ɩ[ٽ{7}}̙3}kUWSOftO럸+ص{|+>rss4>Ď;X0s;d͛kȍR=%.aiCt}UWk>NL_S=If>'&n"3Ęx<ξ;0M h?F21c2e0M&NHUU%q/r+޽I&E{[a0t G| SYUIn,ؼy+3O#7/UUgaa!'{Jӓ-ddI x:q @ x\uAZYn-7nd`` ̿ۿ4駟J<?)-K/b կs4LJKKc]O68vyes~s xf3:],wA擈/`D" G?.ҋ5i|+*PR[$ ݇ˁLt;D",h'OUW~Cuu5[nu]^11׾ XrdYfꔩHD__6mZ ,dq)Kvx}f<ˇ+>PYO$(|¬>'l\[6ou.,,DUf̜μys}mMJƔ֛oc}O1sLO(SBoo/nviL6I4+/+. eS&S5 =;v|ŗ,+%D^c81IgY`!@ěn @Oe,WU?w1k\… ׿ᶫe}>rմwwvv3ˍC{G;}}Ģ1~w?twwǞ0a|JKvX,-kǢPjg/N*ЩĉX]( _qZa2}4e%̙3yy'Xh!۷Dӵa-׳7;B "mʌ tvvRXT(۷p8رce``bw;OZ3!@\~{gXH 7x71Yt+@6mD8`0L r*WVT 7m/w GD1}`رTϬfႅ_0/߹:w;O܋m?C3é'MdL {vejnrssyx奕ps7웬yc 6ma$ [T0NPXX7kּɣ{UU)7YgNeU.Cywhٳg1mTLLt]g}ܿwjD@ 8q&,ɮ4m4Ɩ~>ZEEE?p!}7w穧"LuVV^ŢEظq# ,Xqu]En&,P>Ǻ#{Fp!JZ)'-규*~]WȲW^W^kX/~&J,\ ΰ|Y]v ̘9Í%~Ky$NΝ4Hw>WBF!@< }P8A?^Ee~_L<|;ALӤ뮽?#O=O=۾/`p 6$$-^Kˉ ɲ)cUa܎HuvS[ía̾ޠtǺͬ]|'z=|$IrpuvvNQ(>d+D0" ]]]kZ5SXXHaa!AHjIZ$Xt)+_]s(&Nv5<Wg[>%ljY?~D"I0hUwYWԞUtl\9h<XZE\6g7@ ND"`ʕ8̜X)(^{nϺG]s VWrr"4M66 LuAH}r(-+elߺ]0 @ @("P%" }O=?!Y9phcƔnt  DUR%Ns:q|I8dca:[֏d)pÍ7drrr300Xh-7ۭ#4#\^-6ıX@X=YQ5 ʊ`nk%zR- |>1S`<;hmiuA$C$C$! SXq=S @ |J^ze~_sDN;;;ygy'Xvo83:ǏcmX85P1LM郍lu9ҋ߳v{JƠOm9d  CLLggm 7xrJƔoeZ;1=`fsd.:;;9|$h.X\9Q¡%@ `D >GOd2ɪUxyWzÑ=ŋ '3bG6#nT^+F'uHcr70m4"99̚5Cji8VZjk먭, WӍsu]7Fc呟o Xp8L0$` `4D >!s rtG}S O3vr7rM7 .!p0M3UTG 03uI 8=O?4EEE,_L&#VQyyHc4M4@UTrQbQ ((( //Hp8L +-iB@ 8IL䯾}~ibH*|~=MMMx_''`#/fŊ{6DA8 S+/ծNƛW23 Mt]7ロ{" @47H7 P8DOw7]iIt]] ,9/pM`@%$ ͉'77Fn,B!K8h"@ 7ɚ7 KH ygη=>:;;yܸVUUb VXi+sE m+H*Ju>W,9-+c9ˮ]xx뭷kf͚)IY˰"0hx<}u1t0]qWej7FDFs\"-0*]&@ٸ6$I|>HHl-d${5LYl۶p(dM:zu]'eƍ&IV^O<+2n|A,Yr;Ht"ky9Yamhx3`vmmm<V\I~~9W*^D׊Hf4tM0 01L#{'²^Xl EQ *Ul02^9ėzWN""n]V;h3{4L$(.*aԩlܰ/fŊw}{,Uuzg/a8d06SɶmxGYn_==P;HnLeŇ~jfb9SXXвtXEqݮVa"#ѣ|- @Fjjk۶+V|rf̘q*m!<cXC܊禙ZqOO萤TqI285¦&|I^xN?OSog+2dꦄ5LN$A`* {I$7UEz8Z} @0{[oEQdA5E:J4%x UU}Yb]v٩fK7u$Sr%844;@ ʒd LSyϽެc08z(Y588ʕ+y_'S)鵼yE kcE$m{`4D yfnNr"aU>pd Mi+$*7p+V`ٲe,R2Vm 45{Cד0 3!(oJdg({P֐~/_?!@6lc=ƍNee;7Ojfo%^n"!ӘyˋQ\\LeE%aDFaXʒD~^/"|r&zi+> 3*OLLTap68e6֯e݄~ƸqKēO>ɜ9s) I#\@`}6uG-x bMJeyU+4 J>ai,kkk]7/?<pH$s v8X:c}3vq|N_63uqGƾvv᎙X,#V&޾~:LRqf^5)s((( 77J ZXlS@p.!\gd2˗(*(@U8DVv@ UAN^ IDATv5ɞT$),IRTTDMM fL>w븄W|1tl6ށO 1sJCccT?F>mY| i#a EsV15u uuEQ Yj顫.z{u$>8;uı~8.X 5ǜ5L8uݱl2RpꮚȨAќ(hm0M{#!'C8㌻ @@ Q9 @p`eC3$˄ca2ՉJjihN4MS@Ā9[NkVNBYg}]L3p0ĮC)pK0Mus(}~y̝r!&i0b-]-5" `<_`4u4G;J J8LJvU^(κ4c`pG y~pX8ih̫KA4ch8H(mekv::W POcoa:/k/<m;PN5p28)f2 x.D TCF@b.r?rܡE-@<1HgoyEn)'S]inh`|8J -38tx"Ng_'BdY֯"|&WϻLhl#+bq' E3K hh;FcG# -"+L.Paz}twQr\ ' 0,Sl e˲gwyϱxJWIIS$  009tw3`sLwWݺu~flâئMSU#]'; bnV.4LMfύ3453Gz:N ƧI'8iZd㑗K[  CEP_Y^Ya҉J)\ϥ8xxbYR-,85ԃNX`oܼr#{#T"8Cu`{~wÞp}ZvZp<~Rv2321-5ENcUeSp l*KE"g[ l2:wAr^!:VSF~@ FT݈)Me ip]̶}or}#}ئMK]3[:gsۚ[#$7-_l*˗?E^ݽ^ N q,m]BQbA}kUt@ơh[*$34c EMѵ囨Ts gl-lZ~Sh42đ## e! @"D ו=ܲh4AyBɩ)w? E*ˁb|׏ ws͜c&g]6}Qe3 Y ;p^4}? 0d2 uӴv6JSS#5TVVL%L+U5ՂRach4EJ1@xFsvh4⨯GglR RXI ;aiF)Fh.;rav*0dJh4sF Fh4$Vư JB;Dh Fl\.sAmbZ?VFgoh4beJ48>ā0mTET6i:qDh`h4E][)=2S? N" n[h.6Zh4B041̱c]rҐHC"kUA64U5U,k_jF:Dh4sFH )K2kl]>1je$DPDwl?;`RHV?cmN 6,[O{Nbב݌LReȦ2c8 /YGM`߉8xnL%7-@]\h5lyWmĐh4mh4iB`X&v:A"E2N%0m i)!p;AcMcoyn 600:X #xg׳{7EXV´LS63<1Ȧt~P(/0 +iPA[S+)|_OauqêǁlZ#'Yѹ5KנPtI;,ndy2R7,W à"e`tꆋj4F FH)1m684TS㸾GE:/`Owv;e Z#^)4ύS8Fshh4SҸp@)$ b)gբnPUϑSG~Z p:1p2(MeHI n WݧJvY6U7֧qpmuu%LsӊlXg=ώC;ccSU~ , FsqB qhko?~L$y&^BK5AME :Vhuxk2='Jw%"\F دhi4>\Fn&M)s烆zk'W߰8 lt4wԁB1xqnZ>^>WZm Kۖu۸[bv(Z՛yU` Om NݟuQA.8eR"eP_E3S\\hr.Ȉui7@t b\+( 6>(5;osn?:1<¦v&wl?M,]dGyLqゆ"rF 715mPHo!Fs5H "6δv;qgHͶ/}qh.ɩz) j[C|L*a9ϦlX>*Y>qXֶtޅ騯 x]{OBL+OD&e"a K\3&B 4wϊ2ۅHˋY Me}h#1r4Oy0tQkA\YÐHi`y+om~ X[KE&s0S_ؖE"}=/OԚضYV,Dh4W+״s8z V5c 349@' ċbfلl.Jt_V.~DF"0-z[āa֭2=& ÔrwxnkmXV) d"۔!HH$mӌܱd9Cj4kN.:fFf(Tض(.+o[޾tGf ) Bq NQ˧]HW廜ybD|ē0DJiX]ωSLLoEdCKKP$pɺ]UPfօ4nLR?jVB3zl-Ԭ#Kt;hRY$JIgH؉eaFh4W;ׄ9ۄ^ǞQjec2 䵚T~'3DLI],#~*q$h:8f1Muz# c,"]O+y]4@2a_jݽS,X4nJ***d$6eJFs5r 3Z;l£TdX0W}E$>()`Q(%JGCR%֐x;rċabЦ(<ą,'E!",zWtݚ.Hc(jV12 MrQx|IV^E|!B.--$K[W}A;:J:?駟gpp__&nT2-%@ed+3LNN8sRe:k-FX`ж- /&"A 2ٶM2$LJH&SU"NŇFָ"骎eQt &C O6E=\(Q~(Q>n!  _x衇xihhK_G;ccw0d0M888<\dJ\f#+aH IJLLˊEG$<}?6j4k+Fn5[nj)Px 3)J)/#!09-{d/6xv6H cd80_%pj^k71u" K!xYd.Ѳq2c_XHSx$Ewӻe.3/E1FQCsdbb__G)Ql+c& b׫JD5DՌ35: #r2 #%Y7Q\\dIմ_Y9bEp1FZl0*놯PjGZGKڏ)v @D(YYx K.? 6}e|`ɄIwSx`H,!ZCĈ/0 ʗ( )1&a|_!G4|Jg_ 3k} IDAT$;\t%w%0S8_%ׯ箻*_)Ŗ-[~ݻ{7I{{E>X a >a%7Lbאexj4͵e s*Z=V(`ۋGx^(6|Njxwc=${Hc~%ϥL"R4RF!S/I_'I|o*|^|Tʝy*Qx/^s(,c]Δ߉V02"@|H!0LDx];XR!QH @ h=M"t1Zu=\u&&&xHйĊC)f2TRS[W\d,4UI|f׮]q{nׯ#ߣqF"$Z?eB(K%ˣoFш#GF@sAKCgzD>/!!я-k8s|]:`(VFQ ǀ$@n( USesu<w.TZbʡH#iUc5V :\$j\| J.J3pfKי.^ pa1oK/@|u]@OOO=\.XQTVVҶںZ**U|gttz{%I֮2,Ld*ūc<-L. Zhh4#ME|D^E/`{MP!x eêJ`'Z2qB v*y<Aq~H_(} H e !F !Yup'q 'glb$L"J6"#$f @"!41-_0]\}iH,u099c?0Jukо%_L{ >g4̒V'NsN'fYVZKUU%|;?|dʕl޼ ߸5F,L%xw8;ťف| b;\BhA-TPoF$>,8'^xʢCEeBIJ .$#inYRi'Ce*(9s\!/@J=u][*;v@)0 ɇD21ce_D[2ҺB*d3TWW}RJ[_M-tt,dd2eDVRKWuu53h4rS*@.[2AQ|ŒVnM\7x$Y8ՉGfV-vrViVתR˕ɴh91]a cx(1̊ ުBì0+IfV8sQ~)+6Ӑ)=wF\4=< 7}L3K)1 tA_"bX*:XѤqzz{عkg6mGxEs>E(.>82<4NbUTTVpúy항(;EEEiR[SCEe%Ld2U~F'DL{;C|E\ya urqLTv2L~q\|qf`sg^63 Ô0V5U23+0$K)䎐s\ +,͢0LP@B !EȵBxk8o qٙwTZPξ{R]]MNvD)E__/td"m'H LˍDiïWh4 Ӊ#,w biC Hdz9Y}9W!l㹣y`LӮGDj1vrN~ìBC5N}cwQ]PXQf0L_L/QVm)w\xU+V=GZXjA ۶QPaVZ%dbbt:E*q $ ~tFhf璺`p>C|EᄮVjVDzYq}i312}RbWB݄lF ;ف) {9ti iC't}VJGwêi{}G:EH *+u]bJJ^E0ʆ^dfh۲Q(FFFq mJws|+Wùh4ͥf^Hq"NKݰ4p}7nwOh޸ì$ULxDW¸YXE@)BBV)(fcuXv-|?OUjɁ/1(&@|R(8W$mٟ.j-H82t z;DY5TWW155|B!8aF>\@^Fh4%0#: 8:M_`7;I*&FgĹo(q]˪IJN4`Y&{9lkUMˑ;~'J١;D UǂHL&[zi.kd"uB@> m9 l&L066ƶ7DJsq  ۱mm6}}ܹt:ͦ7y/`7L&(D)m* xK20crbp  ub~HCJD^5Fh4y 3c?˃ *vya煖]XT:,9IDй !Y (Na;Q0Rd+M) Ǣ'{8R JEerRjbaZudk?Z_مYMXSQEI*A*bY) ubT0CZQΜ]:J$>i ogFZʗk>?t'mq8{R^|Xd1+WS?>QjIFh43_E&x[?Gz8G ɸ+t: |5caT#f I&ǏL5a,L11KeNqp!D"4 6!"]*rm)iFE68Ouq]7^1::ʳGJ5=LnsÍ迿Nt\eK/wo@z~>ѱ^ž={r x˖;-s.'ٸqV@ںy7hll䓟8kѲm\]T=-B< ܰ@/ԕ[U q|oA3>©S=~kY}{384%n3;11[onc(7aK:8zQUYɑGPJqhwЮs|GfaB.lG-۷_׿k|wv8-ly#7m ?p㈬DaNYxOGſUk-<4F9+GVC\C8cƁT?0hG h1+D Pozq CFӪ"l&)8 K;OIv% )0\xeA\/[`LD\M.Ysa-?8ЅeYly#?4 ,\Ė-muud+ O1??Fp,CxǢEBr B+ȭOn={TSUUR @QzHYٸdfYS02# )fKt%= D *F~ s;I24W,7ߛ R()6R*f@1 o}Ņ D6L;11l޲6L! m;RJc+_8S& ZZ8}W_JUu5]gˡ\XI;H)j8r$~?l&[a"Mmmq `,v"qcx팎RU]-3O=[o͢EXygF7_$ *l.XG&B&Hە_dr+[&%4']G.|'W4sAD,B$9 SݤAFTz%c- p Q{X@Bwe{*q;Xt1MMs;_;-1`N^}[Y˖J&{,hq~Y]li=ǺߋRŝ>NOO_T*ECcC,p-m=`!&&&8z؜q]N8}ill`llq̌mN|#5ٵk7=r/# ]Rh4]aU񎥂0=璑?J} ߇Sy1xgsn\ٴUH>p:-ǹ >Ds^”'\BY8h%Lxb2LgömH;ͶmoƱL+$ :r6+֮X7>>رŋ Qnƶ< ;ͲeKtM|S 涷xgxy+xJ1K?ۂRHkk TVV2<4~>sG#h4|"Q-a t2藸^灻{87~DaM*>ɋK6S%_ct'Ϝ;kVT<))XԞ==22ꐰ5IjMPqcǧP@KM*)سoaKRnNm}@Qp| ZlwPa0KoJB)ŮSLNz\{89xwwMO?riJ u_~}xjzaqPX~ _P HLe0 ݠ39.~e˗d:<ϧo32뾟~&[nF}fe z'nP˾);0ŷß6 C?Ѣf)WGXGb  yڛ/w'WTWJnfl7l`ϝ 03H#OHV 6 _) Ë/mǏm慟4]]Gxٗ{?Uַv:?|'.PJ8w޲,<={Yja`R0Cio+FhpUV+& pYqdf 2q6uX{ux!7KOO}}}CTVTi|KS߰}n!X6O5F.(P넂Q.R?NHPxnfǞ {Z*kcP[\S9׶ uQ{>3靷Vc8o;|S,h ך|Kd۳3*}WRt'<FhE[>BT$F+dSH‡hP9P>Vr B$lF$Ee$Tmve]VTR\*s]TBe_Ďr#SP Q$ Q$V6`ߗoϜY 0:uv/࢙RAJƎB˸z׺/AJ#Əcկ:&:z-ׯaaa/"^wE<=!rw⨽!΂[( %%tqa] Z܇aleHن"vSw[vVcߛN-+[ [vL:BgnкsW6xo.2 eJ臊V,iDDNEr'A كkۇ'Ol\Oc=8{,>Sgέ=d]Z㩧#8r0z7x /2:^|E 'DŽ7nO p0N0=ݴ0rc_kk`im{s|+{կevfbG@1:(>z+aeq|3 BeJ!42N !j\?AC寓Tw&>˗>iE?_ŏ1?7O?k=ƛ-6:)%^} =,..+?|i;wXXX2 k-~0;;n޼(>ʲ͛طoߺ߽<$!2t=R@|Z# TZT +s8{_?`Ps=t֣~?!`Y[JT vS0f, UNAU~%\"ZmV+CYidJKwo$Q)hx/|W?ė_yBt:o;?oG8qXX╯|_ngt:wv?؅O<4ku܅'='z*~m?}koS8z&vMl[ݍRv+B/kڧ>$a6 $Z#Ue1"N]S 2="!so oFgP12pb X [[NX>5w[lY J}J1磲xdȲ ΐeeZCi$R W{XDٴ#c)Kj?SOdz==o6֫S'Nlj9`jnmX |ዿ׫Aŗ>Ͽ~?zI<نb7΍m,7| kNŭ%6;$BC #TTRB4FAkc,019VV?w.g<;t? ǒh:Bك*,oXR"BWۏAgqե3X^)pK?Ch[hڭG@:򡔯FJ.R d s5o70?ugff'x챃%\~߹;(/8GNBm;$B]yX k{ʫc R06v~v?ѱo¢0:ox?ڷyexpV[.F BnD@Dl !Z 뛌hͧ +OGZ`x2 ;G-k+B*V1yBD1mCYV"C`{^P%AAgSۏB҇\3WV,Ϝ39dPp}YwSĆύjKDyR@g$ ,b+KXXz1}曘_9vGGr-b$ϲ VmŪU=0w8ffKQ؊تGezX^v].~tOB!%U=!.%bnsjΎS箟Vi#hwAVLoExl lP,  Jܴ:AuTxݻ-F1Or'I', V>@ٻcx+6\ɣ*D:ro |gR*}ewhPGCꉉ(@~v}ON=ǚ;7 zGƑ'Μ>K!\udݧYL-D!X 6'>Ҟ2H#iePfWkw]~6}KK0~П uoqKnd/awG?BY,Zvvh;. K^B!qH: EQPWQ.bP._(*hP%NO~:?#o;_WP^ lJOBg+itd;%ۉ뗬baAU΢*n,o*Tk֬3OµqyZutCkY)'PV~~C"&U3GJܺ~'Ng9w1'; /CajƆZwW}7ܐɿ=[nc 0{sW._L\VUBhbll ###v[9,otB8~#f M wc+^ PeU BYT(UY,J\*EPVʲzā]ou-BQ&6riGj2~͞ 0 x3"F-89fp1I\yv J{Uzvt$ P]K_vG>R\m ԕ;|!!q&߀1^.XXX,LZQBr7T^Z,UqLڅSS®IjXI2:!Bf,40OJڤ9&vÖRnKdFQPp4(e1_|7/ .br$ Fb=toM/8㐪r%dOw" !Z{+qwZe㦫&)6Ro7ǥOϣ2#NtdDEZHYU!Q/RVJGN5s}6ʪdU駞/ʵpE2l*c` 팠8&&&0>>1tmyWRB& M[¼BĜ" 0̂-B)OF(*KJT%BYGYj=qQd뽀!*c0]3=s5Z0!¤ݰC(OԷK`+u^-4̲F41V#{sq}0V)H":@YƀZ&D+HW*Jy{y7R2 RRXOKjt6. BYГ!iNg޸up * UTJ{^*R;VYAYjo*KNe *l 7f>تh#LvbjvC}c}-\ŀ Q\uY~bqQnYHlb30=w3 qc,Z1B)N&M*D=f*D7T[1ףJ

,Q<ʼ*'Fq^$.?7]>J&ΦɧɯۚA7uoBmMk^`@bie˓XXV&ۍIIA"oMd-Dd 7ĄVph()Q{h*tvJ\ ƥv]m[p.uޕQVp\TbǢȅ?<ˑ9ڭZ6:+jŜ&W,B!d3.@XBq ",-$W*ָdJkS L *QU` SU0U3c^1i0Bm]w:AA^@2كV* LPT9*CirUd*7] XMbiu! Y tIZZiJIQtx{z( (ٯ{bP!\@*CGR٪n=p{G$־ycnQ|AUmXFv`s IDAT{Еv=s'FZz}P&mܫB`#y!ZHX!e4 cQUG3$uy 6ڮiOA|l1}*xbbi.غNRKW²%AE u{B  wZVD"PJB bd!8I5Tx}#i0Xu}qQ(]7SUZaW9x 6' :TޔуxB!= @F,!`h)`B( \2^8Tmجl]ȇ~b#}w1 1pZֳnyچJ-:O|HC*)`JlWROL,HID$Z)bs!Dv4ϥ`@hvSQ40֗s ϷRֹ AE{^5Bٜ{*@EC\ԠhV1BH IBO}>b#'ZuӮ:"V* B`b&581`CC"F $ߢ E$0\-`@:UzFOXg#>DZ<BBn l ]D$Xt)i-0A(E:nb# c|S'JBT# $b4B4Ļ"9Arr煨LH$^pxHji$ o UҥDm 4p=}1`[ {? lSQQ+i{ cqm!) l "BHuA +։JjT&BŢ/G&`񟍉gC<=gg1~2-4u.zHˈ 2H6B\܎`€)aByи~4$]6kPu~^p>!-UBr"% c5"$$n>Ջ&'CuQ UO8(>b"zG@EFL:H#Hrgi<B!<$0Ȗҿ RZ1"BS $T   B!!B!B(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CB!24(@!B!CC) X{x(KB!Zvu !B!d۠B!24?,KTIENDB`repoze.who-2.2/docs/.static/logo_hi.gif0000644000175000017500000000777611530747412020012 0ustar tseavertseaverGIF89a4polܵEEE999VVUŪ؉ǼʤჂ򠞙dcaRRQ]\\{zx???MLL444333!,4@pH,Ȥrl:Ш@Zجvzxl-z|~su2WfoTe`UXboef :7=Óʋ<Ϥ5)ݳ߼˿ڶܾՎ΢55=C!ѷm+M>o6dtu._ݘUn>؟3:XfC`[0/h uCP![ W d(]"Xr_ 9"#2dwHe.zآ0]Z}7L PeI8tO aiCÃ WI -*蠄ozXv@5 @d "^0e80ݪ&p"|Cnk, 06,w)>jjwiɂ< ɂ:p +[8th k(8,#h,,/U"CPCg Z6 滲˙2宐A:Ё|{J/ B ؁Pp ZP p MK C,( g l| !p'| T)Prª P t5:z|pBX*R3( D-,. A'G, @8 @A*H@>P ql+0 䯀t)T>P`&(0,=#@V`?-;h_S܃ŭqWuo @/S^&  gZUG >B^EZUmyUk-}T0]w~;ܙ9%pquxO0 h07]- K-[^`q< ]rCyPb6Gy$a>p(%%@$g- }ǀ;%y'?rgy<2Tk=>Ng~'@S2D1\4l#px(w\i׀eBxx42-%eT\?05Ph>(}`(&u$`x/"r(^@6†Vz#ehy)iVhKng(`NHz!wB.  gx%d؉mȆl\e-7p_}4z!<`P#K`h~v,0\e `74(;0H9Ȇ<8،<@1#@։T9l(؎8mȆ $ LC9'J(.`KP hs3`"#׎yS:Y: 92`X`Yh ن0i2i 0m20UTYX`FC3Xo:HXyLB7. QI׋ 0dYfy)B gpr9tYtȗ~)|Xr(RV؄ 2xMxu2yuWɈZə捔ق闫W)B8^hBy)iXyiZHz¨I py1|?P雿Iٙy Ti PTK#p:ZP`a)zzEɚ  p?2UĢâ-j-ꓮB2/:1:= pq)@9>)U.ZFKKʣ>:0ڢ,60%ʥ#$b1P3Хɥ]٦Ӷ>ijkm 0 `U+mJ  l*djjr*A7ॆj v DGiZ$b905- :J** jJ*KfK jm2x=ښrʬJšjZ ׮*:ʩk8گʮ#Gڊ{7P0hzʮ:zK = 4 y<2=7U, M9P1:˳> +@+;KE۳@;LۮO۴NS[L[P;\XO+W;efac[\K]kR Y;repoze.who-2.2/docs/examples/0000775000175000017500000000000012145723513016134 5ustar tseavertseaverrepoze.who-2.2/docs/examples/standalone_login_no_who.py0000644000175000017500000000601311530747412023376 0ustar tseavertseaver# Standalone login application for demo SSO: # N.B.: this version does *not* use repoze.who at all, but should produce # a cookie which repoze.who.plugin.authtkt can use. import datetime from paste.auth import auth_tkt from webob import Request LOGIN_FORM_TEMPLATE = """\ Demo SSO Login

Demo SSO Login

%(message)s

""" # oh emacs python-mode, you disappoint me """ # Clients have to know about these values out-of-band SECRET = 's33kr1t' COOKIE_NAME = 'auth_cookie' MAX_AGE = '3600' # seconds AUTH = { 'phred': 'y4bb3d4bb4d00', 'bharney': 'b3dr0ck', } def _validate(login_name, password): # Your application's logic goes here return AUTH.get(login_name) == password def _get_cookies(environ, value): later = (datetime.datetime.now() + datetime.timedelta(seconds=int(MAX_AGE))) # Wdy, DD-Mon-YY HH:MM:SS GMT expires = later.strftime('%a, %d %b %Y %H:%M:%S') # the Expires header is *required* at least for IE7 (IE7 does # not respect Max-Age) tail = "; Max-Age=%s; Expires=%s" % (MAX_AGE, expires) cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME')) wild_domain = '.' + cur_domain return [('Set-Cookie', '%s="%s"; Path=/; Domain=%s%s' % (COOKIE_NAME, value, wild_domain, tail)), ] def login(environ, start_response): request = Request(environ) message = '' if 'form.submitted' in request.POST: came_from = request.POST['came_from'] login_name = request.POST['login_name'] password = request.POST['password'] remote_addr = environ['REMOTE_ADDR'] if _validate(login_name, password): headers = [('Location', came_from)] ticket = auth_tkt.AuthTicket(SECRET, login_name, remote_addr, cookie_name=COOKIE_NAME, secure=True) headers = _get_cookies(environ, ticket.cookie_value()) headers.append(('Location', came_from)) start_response('302 Found', headers) return [] message = 'Authentication failed' else: came_from = request.GET.get('came_from', '') login_name = '' body = LOGIN_FORM_TEMPLATE % {'message': message, 'came_from': came_from, 'login_name': login_name, } start_response('200 OK', []) return [body] def main(global_config, **local_config): return login repoze.who-2.2/docs/examples/standalone_login.py0000644000175000017500000000653211530747412022033 0ustar tseavertseaver# Login application for demo SSO: using the repoze.who API. from repoze.who.api import APIFactory from repoze.who.config import WhoConfig from webob import Request LOGIN_FORM_TEMPLATE = """\ Demo SSO Login

Demo SSO Login

%(message)s

""" MAX_AGE = '3600' # seconds AUTH = { 'phred': 'y4bb3d4bb4d00', 'bharney': 'b3dr0ck', } # This config would normally be in a separate file: inlined here for # didactic purposes. WHO_CONFIG = """\ [plugin:auth_tkt] # identification + authorization use = repoze.who.plugins.auth_tkt:make_plugin secret = s33kr1t cookie_name = auth_cookie secure = True include_ip = True [general] request_classifier = repoze.who.classifiers:default_request_classifier challenge_decider = repoze.who.classifiers:default_challenge_decider remote_user_key = REMOTE_USER [identifiers] plugins = auth_tkt [authenticators] plugins = auth_tkt [challengers] plugins = [mdproviders] plugins = """ # oh emacs python-mode, you disappoint me """ api_factory = None def _configure_api_factory(): global api_factory if api_factory is None: config = WhoConfig(here='/tmp') # XXX config file location config.parse(WHO_CONFIG) api_factory = APIFactory(identifiers=config.identifiers, authenticators=config.authenticators, challengers=config.challengers, mdproviders=config.mdproviders, request_classifier=config.request_classifier, challenge_decider=config.challenge_decider, ) return api_factory def _validate(login_name, password): # Your application's logic goes here return AUTH.get(login_name) == password def login(environ, start_response): request = Request(environ) message = '' if 'form.submitted' in request.POST: came_from = request.POST['came_from'] login_name = request.POST['login_name'] password = request.POST['password'] remote_addr = environ['REMOTE_ADDR'] tokens = userdata = '' if _validate(login_name, password): api = _configure_api_factory()(environ) headers = [('Location', came_from)] headers.extend(api.remember(login_name)) start_response('302 Found', headers) return [] message = 'Authentication failed' else: came_from = request.GET.get('came_from') login_name = '' body = LOGIN_FORM_TEMPLATE % {'message': message, 'came_from': came_from, 'login_name': login_name, } start_response('200 OK', []) return [body] def main(global_config, **local_config): return login repoze.who-2.2/docs/examples/examples.ini0000644000175000017500000000035311530747412020453 0ustar tseavertseaver[application:login_no_who] paste.app_factory = standalone_login_no_who:main [application:login_w_who] paste.app_factory = standalone_login:main [server:main] use = egg:PasteScript#cherrypy host = 127.0.0.1 port = 5552 numthreads = 4 repoze.who-2.2/docs/examples/hybrid/0000775000175000017500000000000012145723513017415 5ustar tseavertseaverrepoze.who-2.2/docs/examples/hybrid/example.py0000644000175000017500000001517611530747412021433 0ustar tseavertseaver""" Simple BFG application demonstrating use of repoze.who in "hybrid" mode. - repoze.who middleware intercepts and validates existing request credentials, leaving 'REMOTE_USER' in the WSGI environ if they are OK. - Application handles login / logout directly, using the repoze.who API to validate credentials and set headers. """ import logging import os import sys from StringIO import StringIO from paste.httpserver import serve from repoze.bfg.authentication import RemoteUserAuthenticationPolicy from repoze.bfg.authorization import ACLAuthorizationPolicy from repoze.bfg.configuration import Configurator from repoze.bfg.security import Allow from repoze.bfg.security import Authenticated from repoze.bfg.security import DENY_ALL from repoze.bfg.security import Everyone from repoze.who.api import get_api from repoze.who.interfaces import IChallenger from repoze.who.middleware import PluggableAuthenticationMiddleware as PAM from repoze.who.plugins.basicauth import BasicAuthPlugin from repoze.who.plugins.auth_tkt import AuthTktCookiePlugin from repoze.who.plugins.redirector import RedirectorPlugin from repoze.who.plugins.htpasswd import HTPasswdPlugin from repoze.who.classifiers import default_request_classifier from repoze.who.classifiers import default_challenge_decider from webob import Response from webob.exc import HTTPFound LINK = '

%(title)s

' ACTIONS = { 'root': {'url': '/', 'title': 'Root'}, 'protected': {'url': '/protected.html', 'title': 'Protected'}, 'login': {'url': '/login.html', 'title': 'Login'}, 'logout': {'url': '/logout.html', 'title': 'Logout'}, } def _actions(request): names = ['root'] if 'REMOTE_USER' in request.environ: names.append('protected') names.append('logout') else: names.append('login') return '\n'.join([LINK % ACTIONS[x] for x in names]) PAGE = """\

%(page_title)s

%(actions)s """ def unprotected(request): return Response(PAGE % {'page_title': 'Unprotected Page', 'actions': _actions(request), }) def protected(request): return Response(PAGE % {'page_title': 'protected Page', 'actions': _actions(request), }) LOGIN_FORM = """\

Log In

%(came_from)s

%(message)s

Login name:

Password:

""" def login(request): message = '' info = {} # Remember any 'came_from', for redirection on succcesful login. came_from = request.params.get('came_from') if came_from is not None: info['came_from'] = ( '' % came_from) else: info['came_from'] = '' who_api = get_api(request.environ) if 'form.login' in request.POST: # Validate credentials. creds = {} creds['login'] = request.POST['login'] creds['password'] = request.POST['password'] authenticated, headers = who_api.login(creds) if authenticated: # Redirect to 'came_from', or to root. # headers here are "remember" headers, setting the # auth_tkt cookies. return HTTPFound(location=came_from or '/', headers=headers) else: message = 'Invalid login.' else: # Forcefully forget any existing credentials. _, headers = who_api.login({}) # Headers here are "forget" headers, clearing the auth_tkt cookies. request.response_headerlist = headers if 'REMOTE_USER' in request.environ: del request.environ['REMOTE_USER'] info['message'] = message return Response(LOGIN_FORM % info) def logout(request): # Use repoze.who API to get "forget" headers. who_api = get_api(request.environ) return HTTPFound(location='/', headers=who_api.logout()) class Root(object): __acl__ = [(Allow, Authenticated, ('view_protected',)), (Allow, Everyone, ('view',)), DENY_ALL, ] def get_root(*args, **kw): return Root() if __name__ == '__main__': # Configure the BFG application ## Set up security policies, root object, etc. authentication_policy=RemoteUserAuthenticationPolicy() authorization_policy=ACLAuthorizationPolicy() config = Configurator( root_factory=get_root, default_permission='view', authentication_policy=authentication_policy, authorization_policy=authorization_policy, ) config.begin() ## Configure views config.add_view(unprotected) config.add_view(protected, 'protected.html', permission='view_protected') config.add_view(login, 'login.html') config.add_view(logout, 'logout.html') config.end() ## Create the app object. app = config.make_wsgi_app() # Configure the repoze.who middleware: ## fake .htpasswd authentication source io = StringIO() for name, password in [('admin', 'admin'), ('user', 'user')]: io.write('%s:%s\n' % (name, password)) io.seek(0) def cleartext_check(password, hashed): return password == hashed htpasswd = HTPasswdPlugin(io, cleartext_check) ## other plugins basicauth = BasicAuthPlugin('repoze.who') auth_tkt = AuthTktCookiePlugin('secret', 'auth_tkt') redirector = RedirectorPlugin(login_url='/login.html') redirector.classifications = {IChallenger:['browser'] } # only for browser ## group / order plugins by function identifiers = [('auth_tkt', auth_tkt), ('basicauth', basicauth)] authenticators = [('auth_tkt', auth_tkt), ('htpasswd', htpasswd)] challengers = [('redirector', redirector), ('basicauth', basicauth)] mdproviders = [] ## set up who logging, if desired log_stream = None if os.environ.get('WHO_LOG'): log_stream = sys.stdout # Wrap the middleware around the application. middleware = PAM(app, identifiers, authenticators, challengers, mdproviders, default_request_classifier, default_challenge_decider, log_stream = log_stream, log_level = logging.DEBUG ) # Serve up the WSGI stack. serve(middleware, host='0.0.0.0') repoze.who-2.2/docs/middleware.rst0000644000175000017500000001333511563257707017202 0ustar tseavertseaver.. _using_middleware: Using :mod:`repoze.who` Middleware ================================== .. _middleware_responsibilities: Middleware Responsibilities --------------------------- :mod:`repoze.who` as middleware has one major function on ingress: it conditionally places identification and authentication information (including a ``REMOTE_USER`` value) into the WSGI environment and allows the request to continue to a downstream WSGI application. :mod:`repoze.who` as middleware has one major function on egress: it examines the headers set by the downstream application, the WSGI environment, or headers supplied by other plugins and conditionally challenges for credentials. .. _request_lifecycle: Lifecycle of a Request ---------------------- :mod:`repoze.who` performs duties both on middleware "ingress" and on middleware "egress". The following graphic outlines where it sits in the context of the request and its response: .. image:: .static/request-lifecycle.png .. _ingress_stages: Request (Ingress) Stages ++++++++++++++++++++++++ .. image:: .static/ingress.png :mod:`repoze.who` performs the following operations in the following order during middleware ingress: #. Environment Setup The middleware adds a number of keys to the WSGI environment: ``repoze.who.plugins`` A reference to the configured plugin set. ``repoze.who.logger`` A reference to the logger configured into the middleware. ``repoze.who.application`` A refererence to the "right-hand" application. The plugins consulted during request classification / identification / authentication may replace this application with another WSGI application, which will be used for the remainer of the current request. #. Request Classification The middleware hands the WSGI environment to the configured ``classifier`` plugin, which is responsible for classifying the request into a single "type". This plugin must return a single string value classifying the request, e.g., "browser", "xml-rpc", "webdav", etc. This classification may serve to filter out plugins consulted later in the request. For instance, a plugin which issued a challenge as an HTML form would be inappropriate for use in requests from an XML-RPC or WebDAV client. #. Identification Each plugin configured as an identifier for a particular class of request is called to extract identity data ("credentials") from the WSGI environment. For example, a basic auth identifier might use the ``HTTP_AUTHORIZATION`` header to find login and password information. Each configured identifier plugin is consulted in turn, and any non-None identities returned are collected into a list to be authenticated. Identifiers are also responsible for providing header information used to set and remove authentication information in the response during egress (to "remember" or "forget" the currently-authenticated user). #. Authentication The middlware consults each plugin configured as an authenticators for a particular class of request, to compare credentials extracted by the identification plugins to a given policy, or set of valid credentials. For example, an htpasswd authenticator might look in a file for a user record matching any of the extracted credentials. If it finds one, and if the password listed in the record matches the password in the identity, the userid of the user would be returned (which would be the same as the login name). Successfully-authenticated ndenties are "weighted", with the highest weight identity governing the remainder of the request. #. Metadata Assignment After identifying and authenticating a user, :mod:`repoze.who` consults plugins configured as metadata providers, which may augmented the authenticated identity with arbitrary metadata. For example, a metadata provider plugin might add the user's first, middle and last names to the identity. A more specialized metadata provider might augment the identity with a list of role or group names assigned to the user. .. _egress_stages: Response (Egress) Stages ++++++++++++++++++++++++ :mod:`repoze.who` performs the following operations in the following order during middleware egress: #. Challenge Decision The middleare examines the WSGI environment and the status and headers returned by the downstream application to determine whether a challenge is required. Typically, only the status is used: if it starts with ``401``, a challenge is required, and the challenge decider returns True. This behavior can be replaced by configuring a different ``challenge_decider`` plugin for the middleware. If a challenge is required, the challenge decider returns True; otherwise, it returns False. #. Credentials reset, AKA "forgetting" If the challenge decider returns True, the middleware first delegates to the identifier plugin which provided the currently-authenticated identity to "forget" the identity, by adding response headers (e.g., to expire a cookie). #. Challenge The plugin then consults each of the set of plugins configured as challengers for the current request classification: the first plugin which returns a non-None WSGI application will be used perform a challenge. Challenger plugins may use application-returned headers, the WSGI environment, and other items to determine what sort of operation should be performed to actuate the challenge. #. Remember The identifier plugin that the "best" set of credentials came from (if any) will be consulted to "remember" these credentials if the challenge decider returns False. repoze.who-2.2/docs/narr.rst0000644000175000017500000000434111530747412016013 0ustar tseavertseaver:mod:`repoze.who` Narrative Documentation ========================================= Using :mod:`repoze.who` as WSGI Middleware ------------------------------------------ :mod:`repoze.who` was originally developed for use as authentication middleware in a WSGI pipeline, for use by applications which only needed to obtain an "authenticated user" to enforce a given security policy. See :ref:`middleware_responsibilities` for a description of this use case. Using :mod:`repoze.who` without WSGI Middleware ----------------------------------------------- Some applications might want to use a configured set of :mod:`repoze.who` plugins to do identification and authentication for a request, outside the context of using :mod:`repoze.who` middleware. For example, a performance-sensitive application might wish to defer the effort of identifying and authenticating a user until the point at which authorization is required, knowing that some code paths will not need to do the work. See :ref:`api_narrative` for a description of this use case. Mixing Middleware and API Uses ------------------------------ Some applications might use the :mod:`repoze.who` middleware for most authentication purposes, but need to participate more directly in the mechanics of identification and authorization for some portions of the application. For example, consider a system which allows users to sign up online for membrship in a site: once the user completes registration, such an application might wish to log the user in transparently, and thus needs to interact with the configured :mod:`repoze.who` middleware to generate response headers, ensuring that the user's next request is properly authenticated. See :ref:`middleware_api_hybrid` for a description of this use case. Configuring :mod:`repoze.who` ----------------------------- Developers and integrators can configure :mod:`repoze.who` using either imperative Python code (see :ref:`imperative_configuration`) or using an INI-style declarative configuration file (see :ref:`declarative_configuration`). In either case, the result of the configuration will be a :class:`repoze.who.api:APIFactory` instance, complete with a request classifier, a challenge decider, and a set of plugins for each plugin interface. repoze.who-2.2/rtd.txt0000664000175000017500000000003412122365762014720 0ustar tseavertseaverrepoze.sphinx.autointerface