New Upstream Release - python-repoze.who

Ready changes

Summary

Merged new upstream version: 2.4.1 (was: 2.2).

Resulting package

Built on 2022-12-30T19:32 (took 5m56s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases python3-repoze.who

Lintian Result

Diff

diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..05f256f
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,31 @@
+# Wire up travis
+language: python
+sudo : false
+
+matrix:
+  include:
+    - python: 2.7
+      env: TOXENV=py27
+    - python: 3.4
+      env: TOXENV=py34
+    - python: 3.5
+      env: TOXENV=py35
+    - python: 3.6
+      env: TOXENV=py36
+    - python: 3.7
+      env: TOXENV=py37
+    - python: 3.8
+      env: TOXENV=py38
+    - python: pypy
+      env: TOXENV=pypy
+    - python: pypy3
+      env: TOXENV=pypy3
+    - python: 3.8
+      env: TOXENV=cover
+
+install:
+  - travis_retry pip install tox
+
+script:
+  - travis_retry tox
+
diff --git a/CHANGES.rst b/CHANGES.rst
index b20b91a..d10d391 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,55 @@
 repoze.who Changelog
 ====================
 
+2.4.1 (2022-02-01)
+------------------
+
+- Disallow separators in AuthTicket component values.  Closes #37.
+
+- Handle bytes / string correctly in 'repoze.who.plugins.htpasswd.sha1_check'.
+  Closes #28.
+
+- Switch to use ``pytest`` as the testrunner.  Closes #34.
+
+2.4 (2020-06-03)
+----------------
+
+- Add upport for Python 3.6, 3.7, and 3.8.
+
+- Drop support for Python 3.3.
+
+- Fix travis configuration.
+
+- Add ``samesite`` option to AuthTktCookiePlugin constructor.
+  If this is passed, it should be a string, and it will be used
+  to compose the Set-Cookie header's "SameSite" value, e.g.
+  if you pass ``samesite="Strict"`` into the constructor,
+  the cookie value for the auth tkt cooke will contain
+  ``SameSite=Strict``.
+
+2.3 (2016-05-31)
+----------------
+
+- Add support for Python 3.4, Python 3.5, and PyPy3.
+
+- Drop support for Python 2.6 and 3.2.
+
+- ``middleware``:  avoid passing extracted ``identity`` to ``remember``
+  during egress (the app may have called ``api.forget()``).  See #21.
+
+- ``_auth_tkt`` / ``plugins.auth_tkt``:  add support for any hash algorithm
+  supported by the ``hashlib`` module in Python's standard library.
+  Fixes #22 via #23.
+
+- ``plugins.auth_tkt``:  Fix storage of "userdata" to save dict.  Fixes
+  #14 via #18.
+
+- middleware:  avoid UnboundLocalError when wrapped generater yields no
+  items.  See:  http://bugs.repoze.org/issue184
+
+- Make cookie expiration date RFC-2616 compliant (independent of locale,
+  including 'GMT' zone). See #11.
+
 2.2 (2013-05-17)
 ----------------
 
diff --git a/PKG-INFO b/PKG-INFO
index bbdf06f..80f6e4d 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,16 +1,26 @@
-Metadata-Version: 1.0
+Metadata-Version: 2.1
 Name: repoze.who
-Version: 2.2
+Version: 2.4.1
 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
-        ======================================================
+Description: repoze.who
+        ==========
         
-        Overview
-        --------
+        .. image:: https://travis-ci.org/repoze/repoze.who.png?branch=master
+                :target: https://travis-ci.org/repoze/repoze.who
+        
+        .. image:: https://readthedocs.org/projects/repozewho/badge/?version=latest
+                :target: http://repozewho.readthedocs.org/en/latest/
+                :alt: Documentation Status
+        
+        .. image:: https://img.shields.io/pypi/v/repoze.who.svg
+                :target: https://pypi.python.org/pypi/repoze.who
+        
+        .. image:: https://img.shields.io/pypi/pyversions/repoze.who.svg
+                :target: https://pypi.python.org/pypi/repoze.who
         
         ``repoze.who`` is an identification and authentication framework
         for arbitrary WSGI applications.  ``repoze.who`` can be configured
@@ -22,15 +32,98 @@ Description: ``repoze.who`` -- WSGI Authentication Middleware / API
         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.
+        
+        Installation
+        ------------
+        
+        Install using setuptools, e.g. (within a virtualenv)::
+        
+         $ easy_install repoze.who
+        
+        or using pip::
+        
+         $ pip install repoze.who
+        
+        
+        Usage
+        -----
+        
+        For details on using the various components, please see the
+        documentation in ``docs/index.rst``.  A rendered version of that documentation
+        is also available online:
+        
+         - http://repozewho.readthedocs.org/en/latest/
+        
+        
+        Reporting Bugs 
+        --------------
+        
+        Please report bugs in this package to
+        
+          https://github.com/repoze/repoze.who/issues
+        
+        
+        Obtaining Source Code
+        ---------------------
+        
+        Download development or tagged versions of the software by visiting:
+        
+          https://github.com/repoze/repoze.who
+        
         
         
         repoze.who Changelog
         ====================
         
+        2.4.1 (2022-02-01)
+        ------------------
+        
+        - Disallow separators in AuthTicket component values.  Closes #37.
+        
+        - Handle bytes / string correctly in 'repoze.who.plugins.htpasswd.sha1_check'.
+          Closes #28.
+        
+        - Switch to use ``pytest`` as the testrunner.  Closes #34.
+        
+        2.4 (2020-06-03)
+        ----------------
+        
+        - Add upport for Python 3.6, 3.7, and 3.8.
+        
+        - Drop support for Python 3.3.
+        
+        - Fix travis configuration.
+        
+        - Add ``samesite`` option to AuthTktCookiePlugin constructor.
+          If this is passed, it should be a string, and it will be used
+          to compose the Set-Cookie header's "SameSite" value, e.g.
+          if you pass ``samesite="Strict"`` into the constructor,
+          the cookie value for the auth tkt cooke will contain
+          ``SameSite=Strict``.
+        
+        2.3 (2016-05-31)
+        ----------------
+        
+        - Add support for Python 3.4, Python 3.5, and PyPy3.
+        
+        - Drop support for Python 2.6 and 3.2.
+        
+        - ``middleware``:  avoid passing extracted ``identity`` to ``remember``
+          during egress (the app may have called ``api.forget()``).  See #21.
+        
+        - ``_auth_tkt`` / ``plugins.auth_tkt``:  add support for any hash algorithm
+          supported by the ``hashlib`` module in Python's standard library.
+          Fixes #22 via #23.
+        
+        - ``plugins.auth_tkt``:  Fix storage of "userdata" to save dict.  Fixes
+          #14 via #18.
+        
+        - middleware:  avoid UnboundLocalError when wrapped generater yields no
+          items.  See:  http://bugs.repoze.org/issue184
+        
+        - Make cookie expiration date RFC-2616 compliant (independent of locale,
+          including 'GMT' zone). See #11.
+        
         2.2 (2013-05-17)
         ----------------
         
@@ -701,16 +794,21 @@ Description: ``repoze.who`` -- WSGI Authentication Middleware / API
         
 Keywords: web application server wsgi zope
 Platform: UNKNOWN
+Classifier: Development Status :: 5 - Production/Stable
 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 :: 3.4
+Classifier: Programming Language :: Python :: 3.5
+Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
 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
+Provides-Extra: docs
+Provides-Extra: testing
diff --git a/README.rst b/README.rst
index 1a11449..11cb758 100644
--- a/README.rst
+++ b/README.rst
@@ -1,8 +1,18 @@
-``repoze.who`` -- WSGI Authentication Middleware / API
-======================================================
+repoze.who
+==========
 
-Overview
---------
+.. image:: https://travis-ci.org/repoze/repoze.who.png?branch=master
+        :target: https://travis-ci.org/repoze/repoze.who
+
+.. image:: https://readthedocs.org/projects/repozewho/badge/?version=latest
+        :target: http://repozewho.readthedocs.org/en/latest/
+        :alt: Documentation Status
+
+.. image:: https://img.shields.io/pypi/v/repoze.who.svg
+        :target: https://pypi.python.org/pypi/repoze.who
+
+.. image:: https://img.shields.io/pypi/pyversions/repoze.who.svg
+        :target: https://pypi.python.org/pypi/repoze.who
 
 ``repoze.who`` is an identification and authentication framework
 for arbitrary WSGI applications.  ``repoze.who`` can be configured
@@ -14,7 +24,41 @@ 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.
+
+Installation
+------------
+
+Install using setuptools, e.g. (within a virtualenv)::
+
+ $ easy_install repoze.who
+
+or using pip::
+
+ $ pip install repoze.who
+
+
+Usage
+-----
+
+For details on using the various components, please see the
+documentation in ``docs/index.rst``.  A rendered version of that documentation
+is also available online:
+
+ - http://repozewho.readthedocs.org/en/latest/
+
+
+Reporting Bugs 
+--------------
+
+Please report bugs in this package to
+
+  https://github.com/repoze/repoze.who/issues
+
+
+Obtaining Source Code
+---------------------
+
+Download development or tagged versions of the software by visiting:
+
+  https://github.com/repoze/repoze.who
+
diff --git a/debian/changelog b/debian/changelog
index 3643010..63ec296 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,8 +1,12 @@
-python-repoze.who (2.2-5) UNRELEASED; urgency=medium
+python-repoze.who (2.4.1-1) UNRELEASED; urgency=medium
 
+  [ Ondřej Nový ]
   * Bump Standards-Version to 4.4.1.
 
- -- Ondřej Nový <onovy@debian.org>  Fri, 18 Oct 2019 16:29:35 +0200
+  [ Debian Janitor ]
+  * New upstream release.
+
+ -- Ondřej Nový <onovy@debian.org>  Fri, 30 Dec 2022 19:26:42 -0000
 
 python-repoze.who (2.2-4) unstable; urgency=medium
 
diff --git a/docs/conf.py b/docs/conf.py
index f4936ac..656b7ce 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -49,14 +49,14 @@ master_doc = 'index'
 
 # General substitutions.
 project = 'repoze.who'
-copyright = '2008, Agendaless Consulting'
+copyright = '2008 - 2013, 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'
+version = '2.2'
 # The full version, including alpha/beta/rc tags.
 release = version
 
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 492664c..68cd9f9 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -103,7 +103,7 @@ An example configuration which uses the default plugins follows::
         return password == hashed
     htpasswd = HTPasswdPlugin(io, cleartext_check)
     basicauth = BasicAuthPlugin('repoze.who')
-    auth_tkt = AuthTktCookiePlugin('secret', 'auth_tkt')
+    auth_tkt = AuthTktCookiePlugin('secret', 'auth_tkt', digest_algo="sha512")
     redirector = RedirectorPlugin('/login.html')
     redirector.classifications = {IChallenger:['browser'],} # only for browser
     identifiers = [('auth_tkt', auth_tkt),
@@ -178,10 +178,15 @@ 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 ::
+config file. The global configuration dictionary is a dictonary passed 
+by PasteDeploy. The only key 'make_middleware_with_config' needs is 
+'here' pointing to the config file directory. For debugging people
+might find it useful to enable logging by adding the log_file argument,
+e.g. log_file="repoze_who.log" ::
 
     from repoze.who.config import make_middleware_with_config
-    who = make_middleware_with_config(app, global_conf, '/path/to/who.ini')
+    global_conf = {"here": "."}  # if this is not defined elsewhere
+    who = make_middleware_with_config(app, global_conf, 'who.ini')
 
 :mod:`repoze.who`'s configuration file can be pointed to within a PasteDeploy
 configuration file ::
@@ -215,6 +220,7 @@ nominated to act as authenticator plugins. ::
     cookie_name = oatmeal
     secure = False
     include_ip = False
+    digest_algo = sha512
 
     [plugin:basicauth]
     # identification and challenge
diff --git a/docs/examples/hybrid/example.py b/docs/examples/hybrid/example.py
index 720a8ff..4ce4fcf 100644
--- a/docs/examples/hybrid/example.py
+++ b/docs/examples/hybrid/example.py
@@ -183,7 +183,7 @@ if __name__ == '__main__':
 
     ## other plugins
     basicauth = BasicAuthPlugin('repoze.who')
-    auth_tkt = AuthTktCookiePlugin('secret', 'auth_tkt')
+    auth_tkt = AuthTktCookiePlugin('secret', 'auth_tkt', digest_algo="sha512")
     redirector = RedirectorPlugin(login_url='/login.html')
     redirector.classifications = {IChallenger:['browser'] } # only for browser
 
diff --git a/docs/examples/standalone_login.py b/docs/examples/standalone_login.py
index 37283ff..bfb07fd 100644
--- a/docs/examples/standalone_login.py
+++ b/docs/examples/standalone_login.py
@@ -44,6 +44,7 @@ secret = s33kr1t
 cookie_name = auth_cookie
 secure = True
 include_ip = True
+digest_algo = sha512
 
 [general]
 request_classifier = repoze.who.classifiers:default_request_classifier
diff --git a/docs/examples/standalone_login_no_who.py b/docs/examples/standalone_login_no_who.py
index 7d750e9..48defd4 100644
--- a/docs/examples/standalone_login_no_who.py
+++ b/docs/examples/standalone_login_no_who.py
@@ -75,7 +75,8 @@ def login(environ, start_response):
         if _validate(login_name, password):
             headers = [('Location', came_from)]
             ticket = auth_tkt.AuthTicket(SECRET, login_name, remote_addr,
-                                         cookie_name=COOKIE_NAME, secure=True)
+                                         cookie_name=COOKIE_NAME, secure=True,
+                                         digest_algo="sha512")
             headers = _get_cookies(environ, ticket.cookie_value())
             headers.append(('Location', came_from))
             start_response('302 Found', headers)
diff --git a/docs/index.rst b/docs/index.rst
index bf32a9d..a2127a0 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -58,11 +58,15 @@ contact the `Repoze-dev maillist
 IRC channel <irc://irc.freenode.net/#repoze>`_.
 
 Browse and check out tagged and trunk versions of :mod:`repoze.who`
-via the `Repoze Subversion repository
-<http://http://svn.repoze.org/repoze.who/>`_.  To check out the trunk
-via Subversion, use this command::
+via the `Repoze github repository
+<https://github.com/repoze/repoze.who/>`_.  To check out the trunk
+via git, use this command::
 
-  svn co http://svn.repoze.org/repoze.who/trunk repoze.who
+  git clone https://github.com:repoze/repoze.who.git
+
+Or, if you are logged in:
+
+  git clone git@github.com:repoze/repoze.who.git
 
 To find out how to become a contributor to :mod:`repoze.who`, please
 see the `contributor's page <http://repoze.org/contributing.html>`_.
diff --git a/docs/middleware.rst b/docs/middleware.rst
index 47b693c..8a37c6c 100644
--- a/docs/middleware.rst
+++ b/docs/middleware.rst
@@ -55,7 +55,7 @@ order during middleware ingress:
        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
+       WSGI application, which will be used for the remainder of the
        current request.
 
 #.  Request Classification
@@ -96,14 +96,14 @@ order during middleware ingress:
     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
+    be the same as the login name).  Successfully-authenticated identities 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
+    plugins configured as metadata providers, which may augment the
     authenticated identity with arbitrary metadata.
 
     For example, a metadata provider plugin might add the user's first,
@@ -143,9 +143,9 @@ order during middleware egress:
 
 #.  Challenge
     
-    The plugin then consults each of the set of plugins configured as
+    The plugin then consults each of the plugins configured as
     challengers for the current request classification:  the first plugin
-    which returns a non-None WSGI application will be used perform a
+    which returns a non-None WSGI application will be used to perform a
     challenge.
     
     Challenger plugins may use application-returned headers, the WSGI
diff --git a/docs/narr.rst b/docs/narr.rst
index 04a8902..e4d42ae 100644
--- a/docs/narr.rst
+++ b/docs/narr.rst
@@ -33,7 +33,7 @@ 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
+sign up online for membership 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
diff --git a/docs/plugins.rst b/docs/plugins.rst
index 529dc1c..2eb7996 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -69,7 +69,8 @@ authentication, identification, challenge and metadata provision.
 
   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.
+  This plugin uses the ``paste.auth.auth_tkt``"auth ticket" protocol and
+  is compatible with Apache's mod_auth_tkt.
   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
@@ -88,6 +89,20 @@ authentication, identification, challenge and metadata provision.
    <http://westpoint.ltd.uk/advisories/Paul_Johnston_GSEC.pdf>`_ reports
    that as many as 3% of users change their IP addresses legitimately
    during a session.
+   
+.. note::
+   Plugin supports remembering user data in the cookie by saving user dict into ``identity['userdata']``
+   parameter of ``remember`` method. They are sent unencrypted and protected by checksum.
+   Data will then be returned every time by ``identify``. This dict must be compatible with
+   ``urllib.urlencode`` function (``urllib.urlparse.urlencode`` in python 3).
+   Saving keys/values with unicode characters is supported only under python 3.
+
+.. note::
+   Plugin supports multiple digest algorithms. It defaults to md5 to match
+   the default for mod_auth_tkt and paste.auth.auth_tkt. However md5 is not
+   recommended as there are viable attacks against the hash. Any algorithm
+   from the hashlib library can be specified, currently only sha256 and sha512
+   are supported by mod_auth_tkt.
 
 .. module:: repoze.who.plugins.basicauth
 
diff --git a/repoze.who.egg-info/PKG-INFO b/repoze.who.egg-info/PKG-INFO
new file mode 100644
index 0000000..80f6e4d
--- /dev/null
+++ b/repoze.who.egg-info/PKG-INFO
@@ -0,0 +1,814 @@
+Metadata-Version: 2.1
+Name: repoze.who
+Version: 2.4.1
+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
+        ==========
+        
+        .. image:: https://travis-ci.org/repoze/repoze.who.png?branch=master
+                :target: https://travis-ci.org/repoze/repoze.who
+        
+        .. image:: https://readthedocs.org/projects/repozewho/badge/?version=latest
+                :target: http://repozewho.readthedocs.org/en/latest/
+                :alt: Documentation Status
+        
+        .. image:: https://img.shields.io/pypi/v/repoze.who.svg
+                :target: https://pypi.python.org/pypi/repoze.who
+        
+        .. image:: https://img.shields.io/pypi/pyversions/repoze.who.svg
+                :target: https://pypi.python.org/pypi/repoze.who
+        
+        ``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.
+        
+        Installation
+        ------------
+        
+        Install using setuptools, e.g. (within a virtualenv)::
+        
+         $ easy_install repoze.who
+        
+        or using pip::
+        
+         $ pip install repoze.who
+        
+        
+        Usage
+        -----
+        
+        For details on using the various components, please see the
+        documentation in ``docs/index.rst``.  A rendered version of that documentation
+        is also available online:
+        
+         - http://repozewho.readthedocs.org/en/latest/
+        
+        
+        Reporting Bugs 
+        --------------
+        
+        Please report bugs in this package to
+        
+          https://github.com/repoze/repoze.who/issues
+        
+        
+        Obtaining Source Code
+        ---------------------
+        
+        Download development or tagged versions of the software by visiting:
+        
+          https://github.com/repoze/repoze.who
+        
+        
+        
+        repoze.who Changelog
+        ====================
+        
+        2.4.1 (2022-02-01)
+        ------------------
+        
+        - Disallow separators in AuthTicket component values.  Closes #37.
+        
+        - Handle bytes / string correctly in 'repoze.who.plugins.htpasswd.sha1_check'.
+          Closes #28.
+        
+        - Switch to use ``pytest`` as the testrunner.  Closes #34.
+        
+        2.4 (2020-06-03)
+        ----------------
+        
+        - Add upport for Python 3.6, 3.7, and 3.8.
+        
+        - Drop support for Python 3.3.
+        
+        - Fix travis configuration.
+        
+        - Add ``samesite`` option to AuthTktCookiePlugin constructor.
+          If this is passed, it should be a string, and it will be used
+          to compose the Set-Cookie header's "SameSite" value, e.g.
+          if you pass ``samesite="Strict"`` into the constructor,
+          the cookie value for the auth tkt cooke will contain
+          ``SameSite=Strict``.
+        
+        2.3 (2016-05-31)
+        ----------------
+        
+        - Add support for Python 3.4, Python 3.5, and PyPy3.
+        
+        - Drop support for Python 2.6 and 3.2.
+        
+        - ``middleware``:  avoid passing extracted ``identity`` to ``remember``
+          during egress (the app may have called ``api.forget()``).  See #21.
+        
+        - ``_auth_tkt`` / ``plugins.auth_tkt``:  add support for any hash algorithm
+          supported by the ``hashlib`` module in Python's standard library.
+          Fixes #22 via #23.
+        
+        - ``plugins.auth_tkt``:  Fix storage of "userdata" to save dict.  Fixes
+          #14 via #18.
+        
+        - middleware:  avoid UnboundLocalError when wrapped generater yields no
+          items.  See:  http://bugs.repoze.org/issue184
+        
+        - Make cookie expiration date RFC-2616 compliant (independent of locale,
+          including 'GMT' zone). See #11.
+        
+        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: Development Status :: 5 - Production/Stable
+Classifier: Intended Audience :: Developers
+Classifier: Programming Language :: Python :: 2
+Classifier: Programming Language :: Python :: 2.7
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.4
+Classifier: Programming Language :: Python :: 3.5
+Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
+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
+Provides-Extra: docs
+Provides-Extra: testing
diff --git a/repoze.who.egg-info/SOURCES.txt b/repoze.who.egg-info/SOURCES.txt
new file mode 100644
index 0000000..a3a803f
--- /dev/null
+++ b/repoze.who.egg-info/SOURCES.txt
@@ -0,0 +1,73 @@
+.bzrignore
+.gitignore
+.travis.yml
+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.py
\ No newline at end of file
diff --git a/repoze.who.egg-info/dependency_links.txt b/repoze.who.egg-info/dependency_links.txt
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/repoze.who.egg-info/dependency_links.txt
@@ -0,0 +1 @@
+
diff --git a/repoze.who.egg-info/entry_points.txt b/repoze.who.egg-info/entry_points.txt
new file mode 100644
index 0000000..6e7517f
--- /dev/null
+++ b/repoze.who.egg-info/entry_points.txt
@@ -0,0 +1,6 @@
+      [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
+      
\ No newline at end of file
diff --git a/repoze.who.egg-info/namespace_packages.txt b/repoze.who.egg-info/namespace_packages.txt
new file mode 100644
index 0000000..7e372a1
--- /dev/null
+++ b/repoze.who.egg-info/namespace_packages.txt
@@ -0,0 +1,3 @@
+repoze
+repoze.who
+repoze.who.plugins
diff --git a/repoze.who.egg-info/not-zip-safe b/repoze.who.egg-info/not-zip-safe
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/repoze.who.egg-info/not-zip-safe
@@ -0,0 +1 @@
+
diff --git a/repoze.who.egg-info/requires.txt b/repoze.who.egg-info/requires.txt
new file mode 100644
index 0000000..18aca17
--- /dev/null
+++ b/repoze.who.egg-info/requires.txt
@@ -0,0 +1,15 @@
+WebOb
+setuptools
+zope.interface
+
+[docs]
+Sphinx
+WebOb
+repoze.sphinx.autointerface
+zope.interface
+
+[testing]
+WebOb
+coverage
+nose
+zope.interface
diff --git a/repoze.who.egg-info/top_level.txt b/repoze.who.egg-info/top_level.txt
new file mode 100644
index 0000000..f794622
--- /dev/null
+++ b/repoze.who.egg-info/top_level.txt
@@ -0,0 +1 @@
+repoze
diff --git a/repoze/who/_auth_tkt.py b/repoze/who/_auth_tkt.py
index a7f1ab7..313f900 100644
--- a/repoze/who/_auth_tkt.py
+++ b/repoze/who/_auth_tkt.py
@@ -36,7 +36,7 @@ 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 hashlib
 import time as time_mod
 
 from repoze.who._compat import encodestring
@@ -44,6 +44,17 @@ from repoze.who._compat import SimpleCookie
 from repoze.who._compat import url_quote
 from repoze.who._compat import url_unquote
 
+DEFAULT_DIGEST = hashlib.md5
+
+
+def _exclude_separator(separator, value, fieldname):
+    if isinstance(value, bytes):
+        separator = separator.encode("ascii")
+
+    if separator in value:
+        raise ValueError(
+            "{} may not contain '{}'".format(fieldname, separator)
+        )
 
 class AuthTicket(object):
 
@@ -52,8 +63,9 @@ class AuthTicket(object):
     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.
+    later scripts.  Lastly, you can override the timestamp, cookie name,
+    whether to secure the cookie and the digest algorithm (for details
+    look at ``AuthTKTMiddleware``).
 
     Once you provide all the arguments, use .cookie_value() to
     generate the appropriate authentication ticket.  .cookie()
@@ -64,10 +76,10 @@ class AuthTicket(object):
 
         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
+        print('Status: 200 OK')
+        print('Content-type: text/html')
+        print(token.cookie())
+        print("")
         ... redirect HTML ...
 
     Webware usage::
@@ -83,23 +95,40 @@ class AuthTicket(object):
 
     def __init__(self, secret, userid, ip, tokens=(), user_data='',
                  time=None, cookie_name='auth_tkt',
-                 secure=False):
+                 secure=False, digest_algo=DEFAULT_DIGEST):
         self.secret = secret
+
+        _exclude_separator('!', userid, "'userid'")
         self.userid = userid
+
         self.ip = ip
+
+        for token in tokens:
+            _exclude_separator(',', token, "'token' values")
+            _exclude_separator('!', token, "'token' values")
+
         self.tokens = ','.join(tokens)
+
+        _exclude_separator('!', user_data, "'user_data'")
         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
+        if isinstance(digest_algo, str):
+            # correct specification of digest from hashlib or fail
+            self.digest_algo = getattr(hashlib, digest_algo)
+        else:
+            self.digest_algo = digest_algo
 
     def digest(self):
         return calculate_digest(
             self.ip, self.time, self.secret, self.userid, self.tokens,
-            self.user_data)
+            self.user_data, self.digest_algo)
 
     def cookie_value(self):
         v = '%s%08x%s!' % (self.digest(), int(self.time),
@@ -132,21 +161,25 @@ class BadTicket(Exception):
         Exception.__init__(self, msg)
 
 
-def parse_ticket(secret, ticket, ip):
+def parse_ticket(secret, ticket, ip, digest_algo=DEFAULT_DIGEST):
     """
     Parse the ticket, returning (timestamp, userid, tokens, user_data).
 
     If the ticket cannot be parsed, ``BadTicket`` will be raised with
     an explanation.
     """
+    if isinstance(digest_algo, str):
+        # correct specification of digest from hashlib or fail
+        digest_algo = getattr(hashlib, digest_algo)
+    digest_hexa_size = digest_algo().digest_size * 2
     ticket = ticket.strip('"')
-    digest = ticket[:32]
+    digest = ticket[:digest_hexa_size]
     try:
-        timestamp = int(ticket[32:40], 16)
+        timestamp = int(ticket[digest_hexa_size:digest_hexa_size + 8], 16)
     except ValueError as e:
         raise BadTicket('Timestamp is not a hex integer: %s' % e)
     try:
-        userid, data = ticket[40:].split('!', 1)
+        userid, data = ticket[digest_hexa_size + 8:].split('!', 1)
     except ValueError:
         raise BadTicket('userid is not followed by !')
     userid = url_unquote(userid)
@@ -158,7 +191,8 @@ def parse_ticket(secret, ticket, ip):
         user_data = data
 
     expected = calculate_digest(ip, timestamp, secret,
-                                userid, tokens, user_data)
+                                userid, tokens, user_data,
+                                digest_algo)
 
     if expected != digest:
         raise BadTicket('Digest signature is not correct',
@@ -169,15 +203,16 @@ def parse_ticket(secret, ticket, ip):
     return (timestamp, userid, tokens, user_data)
 
 
-def calculate_digest(ip, timestamp, secret, userid, tokens, user_data):
+def calculate_digest(ip, timestamp, secret, userid, tokens, user_data,
+                     digest_algo):
     secret = maybe_encode(secret)
     userid = maybe_encode(userid)
     tokens = maybe_encode(tokens)
     user_data = maybe_encode(user_data)
-    digest0 = md5(
+    digest0 = digest_algo(
         encode_ip_timestamp(ip, timestamp) + secret + userid + b'\0'
         + tokens + b'\0' + user_data).hexdigest()
-    digest = md5(maybe_encode(digest0) + secret).hexdigest()
+    digest = digest_algo(maybe_encode(digest0) + secret).hexdigest()
     return digest
 
 
diff --git a/repoze/who/_compat.py b/repoze/who/_compat.py
index fdf9984..cdaf0b4 100644
--- a/repoze/who/_compat.py
+++ b/repoze/who/_compat.py
@@ -34,11 +34,12 @@ else: #pragma NO COVER Python >= 3.0
     from urllib.parse import parse_qsl
 
 try:
-    from ConfigParser import SafeConfigParser
+    import ConfigParser
 except ImportError: #pragma NO COVER Python >= 3.0
-    from configparser import SafeConfigParser
+    from configparser import ConfigParser
     from configparser import ParsingError
 else: #pragma NO COVER Python < 3.0
+    from ConfigParser import SafeConfigParser as ConfigParser
     from ConfigParser import ParsingError
 
 try:
diff --git a/repoze/who/api.py b/repoze/who/api.py
index f5c7a81..9f061b5 100644
--- a/repoze/who/api.py
+++ b/repoze/who/api.py
@@ -58,7 +58,8 @@ def verify(plugin, iface):
 
  
 def make_registries(identifiers, authenticators, challengers, mdproviders):
-    from zope.interface.verify import BrokenImplementation
+    from zope.interface.exceptions import Invalid
+    from zope.interface.verify import BrokenImplementation  # BBB, z.i < 5.0.x
     interface_registry = {}
     name_registry = {}
 
@@ -70,7 +71,7 @@ def make_registries(identifiers, authenticators, challengers, mdproviders):
         for name, value in supplied:
             try:
                 verify(value, iface)
-            except BrokenImplementation as why:
+            except (Invalid, BrokenImplementation) as why:
                 why = str(why)
                 raise ValueError(str(name) + ': ' + why)
             L = interface_registry.setdefault(iface, [])
diff --git a/repoze/who/config.py b/repoze/who/config.py
index baa7601..e849f97 100644
--- a/repoze/who/config.py
+++ b/repoze/who/config.py
@@ -15,12 +15,12 @@ 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 ConfigParser
 from repoze.who._compat import ParsingError
 
 def _resolve(name):
     if name:
-        return EntryPoint.parse('x=%s' % name).load(False)
+        return EntryPoint.parse('x=%s' % name).resolve()
 
 class WhoConfig:
     def __init__(self, here):
@@ -71,7 +71,7 @@ class WhoConfig:
     def parse(self, text):
         if getattr(text, 'readline', None) is None:
             text = StringIO(text)
-        cp = SafeConfigParser(defaults={'here': self.here})
+        cp = ConfigParser(defaults={'here': self.here})
         try:
             cp.read_file(text)
         except AttributeError: #pragma NO COVER Python < 3.0
diff --git a/repoze/who/middleware.py b/repoze/who/middleware.py
index 840d43e..d84c4ed 100644
--- a/repoze/who/middleware.py
+++ b/repoze/who/middleware.py
@@ -72,8 +72,7 @@ class PluggableAuthenticationMiddleware(object):
         logger = self.logger
         path_info = environ.get('PATH_INFO', None)
         logger and logger.info(_STARTED % path_info)
-        identity = None
-        identity = api.authenticate()
+        api.authenticate()  # identity saved in environ
 
         # allow identifier plugins to replace the downstream
         # application (to do redirection and unauthorized themselves
@@ -114,7 +113,7 @@ class PluggableAuthenticationMiddleware(object):
                 raise RuntimeError('no challengers found')
         else:
             logger and logger.info('no challenge required')
-            remember_headers = api.remember(identity)
+            remember_headers = api.remember()
             wrapper.finish_response(remember_headers)
 
         logger and logger.info(_ENDED % path_info)
@@ -135,6 +134,7 @@ def wrap_generator(result):
     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.
+    first = marker = []
     for iter in result:
         first = iter
         break
@@ -142,7 +142,8 @@ def wrap_generator(result):
     # Wrapper yields the first iteration, then passes result's iterations
     # directly up.
     def wrapper():
-        yield first
+        if first is not marker:
+            yield first
         for iter in result:
             # We'll let result's StopIteration bubble up directly.
             yield iter
diff --git a/repoze/who/plugins/auth_tkt.py b/repoze/who/plugins/auth_tkt.py
index a549fae..461309d 100644
--- a/repoze/who/plugins/auth_tkt.py
+++ b/repoze/who/plugins/auth_tkt.py
@@ -1,8 +1,16 @@
 import datetime
 from codecs import utf_8_decode
 from codecs import utf_8_encode
+import hashlib
 import os
 import time
+from wsgiref.handlers import _monthname     # Locale-independent, RFC-2616
+from wsgiref.handlers import _weekdayname   # Locale-independent, RFC-2616
+try:
+    from urllib.parse import urlencode, parse_qsl
+except ImportError:
+    from urllib import urlencode
+    from urlparse import parse_qsl
 
 from zope.interface import implementer
 
@@ -11,18 +19,17 @@ 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()
 
+_UTCNOW = None  # unit tests can replace
+def _utcnow():  #pragma NO COVERAGE
+    if _UTCNOW is not None:
+        return _UTCNOW
+    return datetime.datetime.utcnow()
 
 @implementer(IIdentifier, IAuthenticator)
 class AuthTktCookiePlugin(object):
 
+    userid_typename = 'userid_type'
     userid_type_decoders = {'int': int,
                             'unicode': lambda x: utf_8_decode(x)[0],
                            }
@@ -41,7 +48,9 @@ class AuthTktCookiePlugin(object):
  
     def __init__(self, secret, cookie_name='auth_tkt',
                  secure=False, include_ip=False,
-                 timeout=None, reissue_time=None, userid_checker=None):
+                 timeout=None, reissue_time=None, userid_checker=None,
+                 digest_algo=auth_tkt.DEFAULT_DIGEST,
+                 samesite=None):
         self.secret = secret
         self.cookie_name = cookie_name
         self.include_ip = include_ip
@@ -52,6 +61,8 @@ class AuthTktCookiePlugin(object):
         self.timeout = timeout
         self.reissue_time = reissue_time
         self.userid_checker = userid_checker
+        self.digest_algo = digest_algo
+        self.samesite = samesite
 
     # IIdentifier
     def identify(self, environ):
@@ -68,21 +79,19 @@ class AuthTktCookiePlugin(object):
         
         try:
             timestamp, userid, tokens, user_data = auth_tkt.parse_ticket(
-                self.secret, cookie.value, remote_addr)
+                self.secret, cookie.value, remote_addr, self.digest_algo)
         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)
+        user_data_dict = dict(parse_qsl(user_data))
+        userid_type = user_data_dict.get(self.userid_typename)
+        if userid_type:
+            decoder = self.userid_type_decoders.get(userid_type)
+            if decoder:
+                userid = decoder(userid)
             
         environ['REMOTE_USER_TOKENS'] = tokens
         environ['REMOTE_USER_DATA'] = user_data
@@ -92,7 +101,7 @@ class AuthTktCookiePlugin(object):
         identity['timestamp'] = timestamp
         identity['repoze.who.plugins.auth_tkt.userid'] = userid
         identity['tokens'] = tokens
-        identity['userdata'] = user_data
+        identity['userdata'] = user_data_dict
         return identity
 
     # IIdentifier
@@ -118,22 +127,24 @@ class AuthTktCookiePlugin(object):
         if old_cookie_value:
             try:
                 timestamp,userid,tokens,userdata = auth_tkt.parse_ticket(
-                    self.secret, old_cookie_value, remote_addr)
+                    self.secret, old_cookie_value, remote_addr,
+                    self.digest_algo)
             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', '')
+        who_userdata_dict = 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
-        
+            who_userdata_dict[self.userid_typename] = encoding
+
+        who_userdata = urlencode(who_userdata_dict)
+
         old_data = (userid, tokens, userdata)
         new_data = (who_userid, who_tokens, who_userdata)
 
@@ -146,7 +157,8 @@ class AuthTktCookiePlugin(object):
                 tokens=who_tokens,
                 user_data=who_userdata,
                 cookie_name=self.cookie_name,
-                secure=self.secure)
+                secure=self.secure,
+                digest_algo=self.digest_algo)
             new_cookie_value = ticket.cookie_value()
             
             if old_cookie_value != new_cookie_value:
@@ -166,9 +178,17 @@ class AuthTktCookiePlugin(object):
     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)
+            later = _utcnow() + datetime.timedelta(seconds=max_age)
             # Wdy, DD-Mon-YY HH:MM:SS GMT
-            expires = later.strftime('%a, %d %b %Y %H:%M:%S')
+            expires = "%s, %02d %3s %4d %02d:%02d:%02d GMT" % (
+                _weekdayname[later.weekday()],
+                later.day,
+                _monthname[later.month],
+                later.year,
+                later.hour,
+                later.minute,
+                later.second,
+            )
             # 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)
@@ -179,6 +199,9 @@ class AuthTktCookiePlugin(object):
         if self.secure:
             secure = '; secure; HttpOnly'
 
+        if self.samesite:
+            secure += '; SameSite=%s' % self.samesite
+
         cur_domain = environ.get('HTTP_HOST', environ.get('SERVER_NAME'))
         cur_domain = cur_domain.split(':')[0] # drop port
         wild_domain = '.' + cur_domain
@@ -209,6 +232,7 @@ def make_plugin(secret=None,
                 timeout=None,
                 reissue_time=None,
                 userid_checker=None,
+                digest_algo=auth_tkt.DEFAULT_DIGEST,
                ):
     from repoze.who.utils import resolveDotted
     if (secret is None and secretfile is None):
@@ -227,6 +251,12 @@ def make_plugin(secret=None,
         reissue_time = int(reissue_time)
     if userid_checker is not None:
         userid_checker = resolveDotted(userid_checker)
+    if isinstance(digest_algo, str):
+        try:
+            digest_algo = getattr(hashlib, digest_algo)
+        except AttributeError:
+            raise ValueError("No such 'digest_algo': %s" % digest_algo)
+
     plugin = AuthTktCookiePlugin(secret,
                                  cookie_name,
                                  _bool(secure),
@@ -234,6 +264,7 @@ def make_plugin(secret=None,
                                  timeout,
                                  reissue_time,
                                  userid_checker,
+                                 digest_algo,
                                  )
     return plugin
 
diff --git a/repoze/who/plugins/htpasswd.py b/repoze/who/plugins/htpasswd.py
index 353e23f..66ab153 100644
--- a/repoze/who/plugins/htpasswd.py
+++ b/repoze/who/plugins/htpasswd.py
@@ -100,8 +100,10 @@ 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))
+    b_password = must_encode(password)
+    b_sha1_digest = sha1(b_password).digest()
+    b_b64_sha1_digest = standard_b64encode(b_sha1_digest)
+    return _same_string(hashed, b"{SHA}" + b_b64_sha1_digest)
 
 def plain_check(password, hashed):
     return _same_string(password, hashed)
diff --git a/repoze/who/plugins/tests/test_authtkt.py b/repoze/who/plugins/tests/test_authtkt.py
index b7ab08f..46ce34d 100644
--- a/repoze/who/plugins/tests/test_authtkt.py
+++ b/repoze/who/plugins/tests/test_authtkt.py
@@ -15,12 +15,6 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         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
@@ -42,8 +36,7 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
     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
+                    time=None, digest_algo="md5"):
         import repoze.who._auth_tkt as auth_tkt
         ticket = auth_tkt.AuthTicket(
             'secret',
@@ -53,12 +46,13 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
             user_data=userdata,
             time=time,
             cookie_name=cookie_name,
-            secure=secure)
+            secure=secure,
+            digest_algo=digest_algo)
         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
+        auth_tkt._UTCNOW, self._now_testing = value, auth_tkt._UTCNOW
 
     def test_class_conforms_to_IIdentifier(self):
         from zope.interface.verify import verifyClass
@@ -99,58 +93,58 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         
     def test_identify_good_cookie_include_ip(self):
         plugin = self._makeOne('secret', include_ip=True)
-        val = self._makeTicket(remote_addr='1.1.1.1')
+        val = self._makeTicket(remote_addr='1.1.1.1', userdata='foo=123')
         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(result['userdata'], {'foo': '123'})
+        self.assertTrue('timestamp' in result)
         self.assertEqual(environ['REMOTE_USER_TOKENS'], [''])
-        self.assertEqual(environ['REMOTE_USER_DATA'],'userdata')
+        self.assertEqual(environ['REMOTE_USER_DATA'],'foo=123')
         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()
+        val = self._makeTicket(userdata='foo=123')
         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(result['userdata'], {'foo': '123'})
+        self.assertTrue('timestamp' in result)
         self.assertEqual(environ['REMOTE_USER_TOKENS'], [''])
-        self.assertEqual(environ['REMOTE_USER_DATA'],'userdata')
+        self.assertEqual(environ['REMOTE_USER_DATA'],'foo=123')
         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')
+        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(result['userdata'], {'userid_type': 'int'})
+        self.assertTrue('timestamp' in result)
         self.assertEqual(environ['REMOTE_USER_TOKENS'], [''])
-        self.assertEqual(environ['REMOTE_USER_DATA'],'userid_type:int')
+        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')
+        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(result['userdata'], {'userid_type':'unknown'})
+        self.assertTrue('timestamp' in result)
         self.assertEqual(environ['REMOTE_USER_TOKENS'], [''])
-        self.assertEqual(environ['REMOTE_USER_DATA'],'userid_type:unknown')
+        self.assertEqual(environ['REMOTE_USER_DATA'],'userid_type=unknown')
         self.assertEqual(environ['AUTH_TYPE'],'cookie')
 
     def test_identify_bad_cookie(self):
@@ -169,32 +163,117 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
 
     def test_identify_with_checker_and_existing_account(self):
         plugin = self._makeOne('secret', userid_checker=dummy_userid_checker)
-        val = self._makeTicket(userid='existing')
+        val = self._makeTicket(userid='existing', userdata='foo=123')
         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(result['userdata'], {'foo': '123'})
+        self.assertTrue('timestamp' in result)
+        self.assertEqual(environ['REMOTE_USER_TOKENS'], [''])
+        self.assertEqual(environ['REMOTE_USER_DATA'],'foo=123')
+        self.assertEqual(environ['AUTH_TYPE'],'cookie')
+
+    def test_identify_with_alternate_hash(self):
+        plugin = self._makeOne('secret', include_ip=False, digest_algo="sha256")
+        val = self._makeTicket(userdata='foo=123', digest_algo="sha256")
+        md5_val = self._makeTicket(userdata='foo=123')
+        self.assertNotEqual(val, md5_val)
+        # md5 is 16*2 characters long, sha256 is 32*2
+        self.assertEqual(len(val), len(md5_val)+32)
+        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'], {'foo': '123'})
+        self.assertTrue('timestamp' in result)
         self.assertEqual(environ['REMOTE_USER_TOKENS'], [''])
-        self.assertEqual(environ['REMOTE_USER_DATA'],'userdata')
+        self.assertEqual(environ['REMOTE_USER_DATA'],'foo=123')
         self.assertEqual(environ['AUTH_TYPE'],'cookie')
 
+    def test_identify_bad_cookie_with_alternate_hash(self):
+        plugin = self._makeOne('secret', include_ip=True, digest_algo="sha256")
+        environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=bogus'})
+        result = plugin.identify(environ)
+        self.assertEqual(result, None)
+    
     def test_remember_creds_same(self):
         plugin = self._makeOne('secret')
-        val = self._makeTicket(userid='userid')
+        val = self._makeTicket(userid='userid', userdata='foo=123')
+        environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % val})
+        result = plugin.remember(environ, {'repoze.who.userid':'userid',
+                                           'userdata':{'foo': '123'}})
+        self.assertEqual(result, None)
+
+    def test_remember_creds_same_alternate_hash(self):
+        plugin = self._makeOne('secret', digest_algo="sha1")
+        val = self._makeTicket(userid='userid', userdata='foo=123', digest_algo="sha1")
         environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % val})
         result = plugin.remember(environ, {'repoze.who.userid':'userid',
-                                           'userdata':'userdata'})
+                                           'userdata':{'foo': '123'}})
         self.assertEqual(result, None)
 
+    def test_remember_creds_hash_mismatch(self):
+        plugin = self._makeOne('secret', digest_algo="sha1")
+        old_val = self._makeTicket(userid='userid', userdata='foo=123', digest_algo="md5")
+        new_val = self._makeTicket(userid='userid', userdata='foo=123', digest_algo="sha1")
+        environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val})
+        result = plugin.remember(environ, {'repoze.who.userid':'userid',
+                                           'userdata':{'foo': '123'}})
+        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_secure_alternate_hash(self):
+        plugin = self._makeOne('secret', secure=True, digest_algo="sha512")
+        val = self._makeTicket(userid='userid', secure=True, userdata='foo=123', digest_algo="sha512")
+        environ = self._makeEnviron()
+        result = plugin.remember(environ, {'repoze.who.userid':'userid',
+                                           'userdata':{'foo':'123'}})
+        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_secure(self):
         plugin = self._makeOne('secret', secure=True)
-        val = self._makeTicket(userid='userid', secure=True)
+        val = self._makeTicket(userid='userid', secure=True, userdata='foo=123')
         environ = self._makeEnviron()
         result = plugin.remember(environ, {'repoze.who.userid':'userid',
-                                           'userdata':'userdata'})
+                                           'userdata':{'foo':'123'}})
         self.assertEqual(len(result), 3)
         self.assertEqual(result[0],
                          ('Set-Cookie',
@@ -217,13 +296,42 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
                            'secure; HttpOnly'
                             % val))
 
+    def test_remember_creds_samesite(self):
+        plugin = self._makeOne('secret', secure=False, samesite="Strict")
+        val = self._makeTicket(userid='userid', secure=False, userdata='foo=123')
+        environ = self._makeEnviron()
+        result = plugin.remember(environ, {'repoze.who.userid':'userid',
+                                           'userdata':{'foo':'123'}})
+        self.assertEqual(len(result), 3)
+        self.assertEqual(result[0],
+                         ('Set-Cookie',
+                          'auth_tkt="%s"; '
+                          'Path=/; '
+                          'SameSite=Strict' 
+                          % val))
+        self.assertEqual(result[1],
+                         ('Set-Cookie',
+                           'auth_tkt="%s"; '
+                           'Path=/; '
+                           'Domain=localhost; '
+                           'SameSite=Strict'
+                            % val))
+        self.assertEqual(result[2],
+                         ('Set-Cookie',
+                           'auth_tkt="%s"; '
+                           'Path=/; '
+                           'Domain=.localhost; '
+                           'SameSite=Strict'
+                            % 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')
+        new_val = self._makeTicket(userid='other', userdata='foo=123')
         result = plugin.remember(environ, {'repoze.who.userid':'other',
-                                           'userdata':'userdata'})
+                                           'userdata':{'foo':'123'}})
         self.assertEqual(len(result), 3)
         self.assertEqual(result[0],
                          ('Set-Cookie',
@@ -248,9 +356,9 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val,
                                      'HTTP_HOST': 'localhost:8080',
                                     })
-        new_val = self._makeTicket(userid='other', userdata='userdata')
+        new_val = self._makeTicket(userid='other', userdata='foo=123')
         result = plugin.remember(environ, {'repoze.who.userid':'other',
-                                           'userdata':'userdata'})
+                                           'userdata':{'foo': '123'}})
         self.assertEqual(len(result), 3)
         self.assertEqual(result[0],
                          ('Set-Cookie',
@@ -274,10 +382,10 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         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',
+                                   userdata='foo=123',
                                    remote_addr='1.1.1.1')
         result = plugin.remember(environ, {'repoze.who.userid':'other',
-                                           'userdata':'userdata'})
+                                           'userdata':{'foo': '123'}})
         self.assertEqual(len(result), 3)
         self.assertEqual(result[0],
                          ('Set-Cookie',
@@ -300,9 +408,9 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         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')
+        new_val = self._makeTicket(userid='other', userdata='foo=123')
         result = plugin.remember(environ, {'repoze.who.userid':'other',
-                                           'userdata':'userdata'})
+                                           'userdata':{'foo': '123'}})
         self.assertEqual(len(result), 3)
         self.assertEqual(result[0],
                          ('Set-Cookie',
@@ -326,11 +434,11 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         old_val = self._makeTicket(userid='userid')
         environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val})
         new_val = self._makeTicket(userid='userid',
-                                   userdata='userdata',
+                                   userdata='foo=123',
                                    tokens=['foo', 'bar'],
                                   )
         result = plugin.remember(environ, {'repoze.who.userid': 'userid',
-                                           'userdata': 'userdata',
+                                           'userdata': {'foo': '123'},
                                            'tokens': ['foo', 'bar'],
                                           })
         self.assertEqual(len(result), 3)
@@ -355,11 +463,11 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         old_val = self._makeTicket(userid='userid')
         environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val})
         new_val = self._makeTicket(userid='userid',
-                                   userdata='userdata',
+                                   userdata='foo=123',
                                    tokens=['foo', 'bar'],
                                   )
         result = plugin.remember(environ, {'repoze.who.userid': 'userid',
-                                           'userdata': 'userdata',
+                                           'userdata': {'foo': '123'},
                                            'tokens': ('foo', 'bar'),
                                           })
         self.assertEqual(len(result), 3)
@@ -384,10 +492,10 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         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')
+        new_val = self._makeTicket(userid='1', userdata='userid_type=int')
         result = plugin.remember(environ, {'repoze.who.userid':1,
-                                           'userdata':''})
-        
+                                           'userdata':{}})
+
         self.assertEqual(len(result), 3)
         self.assertEqual(result[0],
                          ('Set-Cookie',
@@ -401,9 +509,9 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         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')
+        new_val = self._makeTicket(userid='1', userdata='userid_type=int')
         result = plugin.remember(environ, {'repoze.who.userid':long(1),
-                                           'userdata':''})
+                                           'userdata':{}})
         self.assertEqual(len(result), 3)
         self.assertEqual(result[0],
                          ('Set-Cookie',
@@ -415,13 +523,13 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         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 = 'userid_type=unicode'
+        else: # pragma: no cover Py3k
             userdata = ''
         new_val = self._makeTicket(userid=userid.encode('utf-8'),
                                    userdata=userdata)
         result = plugin.remember(environ, {'repoze.who.userid':userid,
-                                           'userdata':''})
+                                           'userdata':{}})
         self.assertEqual(type(result[0][1]), str)
         self.assertEqual(len(result), 3)
         self.assertEqual(result[0],
@@ -443,36 +551,80 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
                          ('Set-Cookie',
                           'auth_tkt="%s"; Path=/' % new_val))
 
+    def test_remember_creds_reissue_alternate_hash(self):
+        import time
+        plugin = self._makeOne('secret', reissue_time=1, digest_algo="sha256")
+        old_val = self._makeTicket(userid='userid', userdata='',
+                                   time=time.time()-2, digest_algo="sha256")
+        environ = self._makeEnviron({'HTTP_COOKIE':'auth_tkt=%s' % old_val})
+        new_val = self._makeTicket(userid='userid', userdata='',
+                                   digest_algo="sha256")
+        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_l10n_sane_cookie_date(self):
+        from datetime import datetime
+
+        now = datetime(2009, 11, 8, 16, 15, 22)
+        self._setNowTesting(now)
+
+        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.assertTrue(
+            value.endswith('; Expires=Sun, 08 Nov 2009 16:23:42 GMT'))
+
     def test_remember_max_age(self):
+        from datetime import datetime
+
+        now = datetime(2009, 11, 8, 16, 15, 22)
+        self._setNowTesting(now)
+
         plugin = self._makeOne('secret')
-        environ = {'HTTP_HOST':'example.com'}
-        
+        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)
+        result = plugin.remember(environ, {'repoze.who.userid': 'chris',
+                                           'max_age': '500'})
+
+        name, value = result.pop(0)
         self.assertEqual('Set-Cookie', name)
-        self.failUnless(
+        self.assertTrue(
             value.startswith('auth_tkt="%s"; Path=/; Max-Age=500' % tkt),
             value)
-        self.failUnless('; Expires=' in value)
-        
-        name,value = result.pop(0)
+        self.assertTrue(
+            value.endswith('; Expires=Sun, 08 Nov 2009 16:23:42 GMT'))
+
+        name, value = result.pop(0)
         self.assertEqual('Set-Cookie', name)
-        self.failUnless(
+        self.assertTrue(
             value.startswith(
-            'auth_tkt="%s"; Path=/; Domain=example.com; Max-Age=500'
-            % tkt), value)
-        self.failUnless('; Expires=' in value)
+                'auth_tkt="%s"; Path=/; Domain=example.com; Max-Age=500'
+                % tkt), value)
+        self.assertTrue(
+            value.endswith('; Expires=Sun, 08 Nov 2009 16:23:42 GMT'))
 
-        name,value = result.pop(0)
+        name, value = result.pop(0)
         self.assertEqual('Set-Cookie', name)
-        self.failUnless(
+        self.assertTrue(
             value.startswith(
-            'auth_tkt="%s"; Path=/; Domain=.example.com; Max-Age=500' % tkt),
+                'auth_tkt="%s"; Path=/; Domain=.example.com; Max-Age=500' % tkt),
             value)
-        self.failUnless('; Expires=' in value)
+        self.assertTrue(
+            value.endswith('; Expires=Sun, 08 Nov 2009 16:23:42 GMT'))
 
     def test_forget(self):
         from datetime import datetime
@@ -487,21 +639,21 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
         self.assertEqual(name, 'Set-Cookie')
         self.assertEqual(value,
                          'auth_tkt="INVALID"; Path=/; '
-                         'Max-Age=0; Expires=Thu, 05 Nov 2009 16:15:22'
+                         'Max-Age=0; Expires=Thu, 05 Nov 2009 16:15:22 GMT'
                          )
         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'
+                         'Max-Age=0; Expires=Thu, 05 Nov 2009 16:15:22 GMT'
                          )
         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'
+                         'Max-Age=0; Expires=Thu, 05 Nov 2009 16:15:22 GMT'
                         )
 
     def test_authenticate_non_auth_tkt_credentials(self):
@@ -569,6 +721,23 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
             userid_checker='repoze.who.plugins.auth_tkt:make_plugin')
         self.assertEqual(plugin.userid_checker, make_plugin)
 
+    def test_factory_with_alternate_hash(self):
+        from repoze.who.plugins.auth_tkt import make_plugin
+        import hashlib
+        plugin = make_plugin('secret', digest_algo="sha1")
+        self.assertEqual(plugin.digest_algo, hashlib.sha1)
+
+    def test_factory_with_alternate_hash_func(self):
+        from repoze.who.plugins.auth_tkt import make_plugin
+        import hashlib
+        plugin = make_plugin('secret', digest_algo=hashlib.sha1)
+        self.assertEqual(plugin.digest_algo, hashlib.sha1)
+
+    def test_factory_with_bogus_hash(self):
+        from repoze.who.plugins.auth_tkt import make_plugin
+        self.assertRaises(ValueError, make_plugin,
+                          secret="fiddly", digest_algo='foo23')
+
     def test_remember_max_age_unicode(self):
         from repoze.who._compat import u
         plugin = self._makeOne('secret')
@@ -578,27 +747,27 @@ class TestAuthTktCookiePlugin(unittest.TestCase):
                                            'max_age': u('500')})
         name, value = result.pop(0)
         self.assertEqual('Set-Cookie', name)
-        self.failUnless(isinstance(value, str))
-        self.failUnless(
+        self.assertTrue(isinstance(value, str))
+        self.assertTrue(
             value.startswith('auth_tkt="%s"; Path=/; Max-Age=500' % tkt),
             (value, tkt))
-        self.failUnless('; Expires=' in value)
+        self.assertTrue('; Expires=' in value)
         
         name,value = result.pop(0)
         self.assertEqual('Set-Cookie', name)
-        self.failUnless(
+        self.assertTrue(
             value.startswith(
             'auth_tkt="%s"; Path=/; Domain=example.com; Max-Age=500'
             % tkt), value)
-        self.failUnless('; Expires=' in value)
+        self.assertTrue('; Expires=' in value)
 
         name,value = result.pop(0)
         self.assertEqual('Set-Cookie', name)
-        self.failUnless(
+        self.assertTrue(
             value.startswith(
             'auth_tkt="%s"; Path=/; Domain=.example.com; Max-Age=500' % tkt),
             value)
-        self.failUnless('; Expires=' in value)
+        self.assertTrue('; Expires=' in value)
 
 
 def dummy_userid_checker(userid):
diff --git a/repoze/who/plugins/tests/test_basicauth.py b/repoze/who/plugins/tests/test_basicauth.py
index a89fd38..37e8dc7 100644
--- a/repoze/who/plugins/tests/test_basicauth.py
+++ b/repoze/who/plugins/tests/test_basicauth.py
@@ -1,5 +1,6 @@
 import unittest
 
+
 class TestBasicAuthPlugin(unittest.TestCase):
 
     def _getTargetClass(self):
@@ -10,12 +11,6 @@ class TestBasicAuthPlugin(unittest.TestCase):
         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 = {}
@@ -42,7 +37,7 @@ class TestBasicAuthPlugin(unittest.TestCase):
         for item in app_iter:
             items.append(item)
         response = b''.join(items).decode('utf-8')
-        self.failUnless(response.startswith('401 Unauthorized'))
+        self.assertTrue(response.startswith('401 Unauthorized'))
 
     def test_identify_noauthinfo(self):
         plugin = self._makeOne('realm')
diff --git a/repoze/who/plugins/tests/test_htpasswd.py b/repoze/who/plugins/tests/test_htpasswd.py
index 1f84d8c..8041b0f 100644
--- a/repoze/who/plugins/tests/test_htpasswd.py
+++ b/repoze/who/plugins/tests/test_htpasswd.py
@@ -11,19 +11,11 @@ class TestHTPasswdPlugin(unittest.TestCase):
         plugin = self._getTargetClass()(*arg, **kw)
         return plugin
 
-    def _makeEnviron(self, kw=None):
+    def _makeEnviron(self):
         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
@@ -99,7 +91,7 @@ class TestHTPasswdPlugin(unittest.TestCase):
         import os
         here = os.path.abspath(os.path.dirname(__file__))
         htpasswd = os.path.join(here, 'fixtures', 'test.htpasswd.nonesuch')
-        def check(password, hashed):
+        def check(password, hashed): # pragma: no cover
             return True
         plugin = self._makeOne(htpasswd, check)
         environ = self._makeEnviron()
@@ -112,13 +104,13 @@ class TestHTPasswdPlugin(unittest.TestCase):
         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])
+        self.assertTrue('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:
+        if "win32" == sys.platform: # pragma: no cover
             return
 
         from crypt import crypt
@@ -128,23 +120,35 @@ class TestHTPasswdPlugin(unittest.TestCase):
         self.assertEqual(crypt_check('password', hashed), True)
         self.assertEqual(crypt_check('notpassword', hashed), False)
 
-    def test_sha1_check(self):
+    def test_sha1_check_w_password_str(self):
+        from base64 import standard_b64encode
+        from hashlib import sha1
+        from repoze.who.plugins.htpasswd import sha1_check
+
+        password = u'password'
+        b_password = password.encode("ascii")
+        encrypted_string = standard_b64encode(sha1(b_password).digest())
+        hashed = b"%s%s" % (b"{SHA}", encrypted_string)
+
+        self.assertTrue(sha1_check(password, hashed))
+        self.assertFalse(sha1_check('notpassword', hashed))
+
+    def test_sha1_check_w_password_bytes(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)
+        b_password = b'password'
+        encrypted_string = standard_b64encode(sha1(b_password).digest())
+        hashed = b"%s%s" % (b"{SHA}", encrypted_string)
+
+        self.assertTrue(sha1_check(b_password, hashed))
+        self.assertFalse(sha1_check(b'notpassword', hashed))
 
     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'))
+        self.assertTrue(plain_check('password', 'password'))
+        self.assertFalse(plain_check('notpassword', 'password'))
 
     def test_factory_no_filename_raises(self):
         from repoze.who.plugins.htpasswd import make_plugin
diff --git a/repoze/who/plugins/tests/test_redirector.py b/repoze/who/plugins/tests/test_redirector.py
index e0a2511..4645e28 100644
--- a/repoze/who/plugins/tests/test_redirector.py
+++ b/repoze/who/plugins/tests/test_redirector.py
@@ -1,15 +1,7 @@
 import 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):
+class TestRedirectorPlugin(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.plugins.redirector import RedirectorPlugin
@@ -26,22 +18,12 @@ class TestRedirectorPlugin(_Base):
                                       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):
+    def _makeEnviron(self, path_info='/', identifier=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)
+        content_type, body = encode_multipart_formdata()
         environ = {'wsgi.version': (1,0),
                    'wsgi.input': StringIO(body),
                    'wsgi.url_scheme':'http',
@@ -88,7 +70,7 @@ class TestRedirectorPlugin(_Base):
                                [('forget', '1')])
         sr = DummyStartResponse()
         result = b''.join(app(environ, sr)).decode('ascii')
-        self.failUnless(result.startswith('302 Found'))
+        self.assertTrue(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')
@@ -124,7 +106,7 @@ class TestRedirectorPlugin(_Base):
             [('forget', '1')])
         sr = DummyStartResponse()
         result = b''.join(app(environ, sr)).decode('ascii')
-        self.failUnless(result.startswith('302 Found'))
+        self.assertTrue(result.startswith('302 Found'))
         self.assertEqual(sr.headers[1][0], 'Location')
         url = sr.headers[1][1]
         parts = urlparse(url)
@@ -157,7 +139,7 @@ class TestRedirectorPlugin(_Base):
             [('forget', '1')])
         sr = DummyStartResponse()
         result = b''.join(app(environ, sr)).decode('ascii')
-        self.failUnless(result.startswith('302 Found'))
+        self.assertTrue(result.startswith('302 Found'))
         self.assertEqual(sr.headers[1][0], 'Location')
         url = sr.headers[1][1]
         parts = urlparse(url)
@@ -182,7 +164,7 @@ class TestRedirectorPlugin(_Base):
             [('forget', '1')])
         sr = DummyStartResponse()
         result = b''.join(app(environ, sr)).decode('ascii')
-        self.failUnless(result.startswith('302 Found'))
+        self.assertTrue(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')
@@ -208,7 +190,7 @@ class TestRedirectorPlugin(_Base):
             [('forget', '1')])
         sr = DummyStartResponse()
         result = b''.join(app(environ, sr)).decode('ascii')
-        self.failUnless(result.startswith('302 Found'))
+        self.assertTrue(result.startswith('302 Found'))
         self.assertEqual(sr.headers[1][0], 'Location')
         url = sr.headers[1][1]
         parts = urlparse(url)
@@ -237,7 +219,7 @@ class TestRedirectorPlugin(_Base):
             [('forget', '1')])
         sr = DummyStartResponse()
         result = b''.join(app(environ, sr)).decode('ascii')
-        self.failUnless(result.startswith('302 Found'))
+        self.assertTrue(result.startswith('302 Found'))
         self.assertEqual(sr.headers[1][0], 'Location')
         url = sr.headers[1][1]
         parts = urlparse(url)
@@ -266,7 +248,7 @@ class TestRedirectorPlugin(_Base):
             [('forget', '1')])
         sr = DummyStartResponse()
         result = b''.join(app(environ, sr)).decode('ascii')
-        self.failUnless(result.startswith('302 Found'))
+        self.assertTrue(result.startswith('302 Found'))
         self.assertEqual(sr.headers[1][0], 'Location')
         url = sr.headers[1][1]
         parts = urlparse(url)
@@ -293,13 +275,13 @@ class TestRedirectorPlugin(_Base):
             [])
         sr = DummyStartResponse()
         result = b''.join(app(environ, sr)).decode('ascii')
-        self.failUnless(result.startswith('302 Found'))
+        self.assertTrue(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):
+class Test_make_redirecting_plugin(unittest.TestCase):
 
     def _callFUT(self, *args, **kw):
         from repoze.who.plugins.redirector import make_plugin
@@ -341,7 +323,7 @@ class Test_make_redirecting_plugin(_Base):
         self.assertEqual(plugin.reason_param, 'why')
         self.assertEqual(plugin.reason_header, 'X-Reason')
 
-class DummyIdentifier:
+class DummyIdentifier(object):
     forgotten = False
     remembered = False
 
@@ -352,19 +334,6 @@ class DummyIdentifier:
         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
@@ -372,15 +341,10 @@ class DummyStartResponse:
         self.exc_info = exc_info
         return []
 
-def encode_multipart_formdata(fields):
+def encode_multipart_formdata():
     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)
diff --git a/repoze/who/plugins/tests/test_sql.py b/repoze/who/plugins/tests/test_sql.py
index a71cb57..0fd0738 100644
--- a/repoze/who/plugins/tests/test_sql.py
+++ b/repoze/who/plugins/tests/test_sql.py
@@ -1,14 +1,7 @@
 import 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):
+class TestSQLAuthenticatorPlugin(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.plugins.sql import SQLAuthenticatorPlugin
@@ -18,11 +11,9 @@ class TestSQLAuthenticatorPlugin(_Base):
         plugin = self._getTargetClass()(*arg, **kw)
         return plugin
 
-    def _makeEnviron(self, kw=None):
+    def _makeEnviron(self):
         environ = {}
         environ['wsgi.version'] = (1,0)
-        if kw is not None:
-            environ.update(kw)
         return environ
 
     def test_implements(self):
@@ -75,7 +66,7 @@ class TestSQLAuthenticatorPlugin(_Base):
         self.assertEqual(dummy_factory.query, None)
         self.assertEqual(dummy_factory.closed, False)
 
-class TestDefaultPasswordCompare(_Base):
+class TestDefaultPasswordCompare(unittest.TestCase):
 
     def _getFUT(self):
         from repoze.who.plugins.sql import default_password_compare
@@ -84,9 +75,9 @@ class TestDefaultPasswordCompare(_Base):
     def _get_sha_hex_digest(self, clear='password'):
         try:
             from hashlib import sha1
-        except ImportError:
+        except ImportError:  # pragma: no cover Py3k
             from sha import new as sha1
-        if not isinstance(clear, type(b'')):
+        if not isinstance(clear, type(b'')):  # pragma: no cover Py3k
             clear = clear.encode('utf-8')
         return sha1(clear).hexdigest()
 
@@ -121,7 +112,7 @@ class TestDefaultPasswordCompare(_Base):
         result = compare('notpassword', stored)
         self.assertEqual(result, False)
 
-class TestSQLMetadataProviderPlugin(_Base):
+class TestSQLMetadataProviderPlugin(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.plugins.sql import SQLMetadataProviderPlugin
@@ -149,9 +140,9 @@ class TestSQLMetadataProviderPlugin(_Base):
         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)
+        self.assertFalse('__userid' in identity)
 
-class TestMakeSQLAuthenticatorPlugin(_Base):
+class TestMakeSQLAuthenticatorPlugin(unittest.TestCase):
 
     def _getFUT(self):
         from repoze.who.plugins.sql import make_authenticator_plugin
@@ -188,7 +179,7 @@ class TestMakeSQLAuthenticatorPlugin(_Base):
         self.assertEqual(plugin.conn_factory, DummyConnFactory)
         self.assertEqual(plugin.compare_fn, make_dummy_connfactory)
 
-class TestMakeSQLMetadataProviderPlugin(_Base):
+class TestMakeSQLMetadataProviderPlugin(unittest.TestCase):
 
     def _getFUT(self):
         from repoze.who.plugins.sql import make_metadata_plugin
diff --git a/repoze/who/restrict.py b/repoze/who/restrict.py
index 28cbb18..4bb29d3 100644
--- a/repoze/who/restrict.py
+++ b/repoze/who/restrict.py
@@ -29,5 +29,5 @@ def make_authenticated_restriction(app, global_config, enabled=True):
 def make_predicate_restriction(app, global_config,
                                predicate, enabled=True, **kw):
     if isinstance(predicate, STRING_TYPES):
-        predicate = EntryPoint.parse('x=%s' % predicate).load(False)
+        predicate = EntryPoint.parse('x=%s' % predicate).resolve()
     return PredicateRestriction(app, predicate, enabled, **kw)
diff --git a/repoze/who/tests/test__auth_tkt.py b/repoze/who/tests/test__auth_tkt.py
index cf85f3c..6d8ee90 100644
--- a/repoze/who/tests/test__auth_tkt.py
+++ b/repoze/who/tests/test__auth_tkt.py
@@ -1,14 +1,7 @@
 import 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):
+class AuthTicketTests(unittest.TestCase):
 
     def _getTargetClass(self):
         from .._auth_tkt import AuthTicket
@@ -18,6 +11,7 @@ class AuthTicketTests(_Base):
         return self._getTargetClass()(*args, **kw)
 
     def test_ctor_defaults(self):
+        import hashlib
         from .. import _auth_tkt
         with _Monkey(_auth_tkt, time_mod=_Timemod):
             tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4')
@@ -29,11 +23,50 @@ class AuthTicketTests(_Base):
         self.assertEqual(tkt.time, _WHEN)
         self.assertEqual(tkt.cookie_name, 'auth_tkt')
         self.assertEqual(tkt.secure, False)
+        self.assertEqual(tkt.digest_algo, hashlib.md5)
+
+    def test_ctor_w_userid_w_embedded_bang(self):
+        tokens = ('a,b',)  # cannot be safely round-tripped
+
+        with self.assertRaises(ValueError) as exc:
+            self._makeOne('SEEKRIT', 'USER!ID', '1.2.3.4')
+
+        self.assertEqual(str(exc.exception), "'userid' may not contain '!'")
+
+    def test_ctor_w_token_w_embedded_bang(self):
+        tokens = ('a!b',)  # cannot be safely round-tripped
+
+        with self.assertRaises(ValueError) as exc:
+            self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', tokens=tokens)
+
+        self.assertEqual(
+            str(exc.exception), "'token' values may not contain '!'"
+        )
+
+    def test_ctor_w_token_w_embedded_comma(self):
+        tokens = ('a,b',)  # cannot be safely round-tripped
+
+        with self.assertRaises(ValueError) as exc:
+            self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', tokens=tokens)
+
+        self.assertEqual(
+            str(exc.exception), "'token' values may not contain ','"
+        )
+
+    def test_ctor_w_user_data_w_embedded_bang(self):
+        user_data = 'DATA!HERE' # cannot be safely round-tripped
+
+        with self.assertRaises(ValueError) as exc:
+            self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', user_data=user_data)
+
+        self.assertEqual(str(exc.exception), "'user_data' may not contain '!'")
 
     def test_ctor_explicit(self):
+        import hashlib
         tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', tokens=('a', 'b'),
                             user_data='DATA', time=_WHEN,
-                            cookie_name='oatmeal', secure=True)
+                            cookie_name='oatmeal', secure=True,
+                            digest_algo=hashlib.sha512)
         self.assertEqual(tkt.secret, 'SEEKRIT')
         self.assertEqual(tkt.userid, 'USERID')
         self.assertEqual(tkt.ip, '1.2.3.4')
@@ -42,38 +75,57 @@ class AuthTicketTests(_Base):
         self.assertEqual(tkt.time, _WHEN)
         self.assertEqual(tkt.cookie_name, 'oatmeal')
         self.assertEqual(tkt.secure, True)
+        self.assertEqual(tkt.digest_algo, hashlib.sha512)
+
+    def test_ctor_string_algorithm(self):
+        import hashlib
+        tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', tokens=('a', 'b'),
+                            user_data='DATA', time=_WHEN,
+                            cookie_name='oatmeal', secure=True,
+                            digest_algo='sha1')
+        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)
+        self.assertEqual(tkt.digest_algo, hashlib.sha1)
 
     def test_digest(self):
-        from .._auth_tkt import calculate_digest
+        from .._auth_tkt import calculate_digest, hashlib
         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')
+                                  'a,b', 'DATA', hashlib.md5)
         self.assertEqual(tkt.digest(), digest)
 
     def test_cookie_value_wo_tokens_or_userdata(self):
-        from .._auth_tkt import calculate_digest
+        from .._auth_tkt import calculate_digest, hashlib
         tkt = self._makeOne('SEEKRIT', 'USERID', '1.2.3.4', time=_WHEN)
-        digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID', '', '')
+        digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID',
+                                  '', '', hashlib.md5)
         self.assertEqual(tkt.cookie_value(),
                          '%s%08xUSERID!' % (digest, _WHEN))
 
     def test_cookie_value_w_tokens_and_userdata(self):
-        from .._auth_tkt import calculate_digest
+        from .._auth_tkt import calculate_digest, hashlib
         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')
+                                  'a,b', 'DATA', hashlib.md5)
         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 .._auth_tkt import calculate_digest, hashlib
         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', '', '')
+        digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID',
+                                  '', '', hashlib.md5)
         cookie = tkt.cookie()
         self.assertEqual(cookie['oatmeal'].value,
                          encodestring('%s%08xUSERID!' % (digest, _WHEN)
@@ -82,22 +134,22 @@ class AuthTicketTests(_Base):
         self.assertEqual(cookie['oatmeal']['secure'], '')
 
     def test_cookie_secure_w_tokens_and_userdata(self):
-        from .._auth_tkt import calculate_digest
+        from .._auth_tkt import calculate_digest, hashlib
         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')
+                                  'a,b', 'DATA', hashlib.md5)
         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):
+
+class BadTicketTests(unittest.TestCase):
 
     def _getTargetClass(self):
         from .._auth_tkt import BadTicket
@@ -117,11 +169,12 @@ class BadTicketTests(_Base):
         self.assertEqual(exc.expected, 'foo')
 
 
-class Test_parse_ticket(_Base):
+class Test_parse_ticket(unittest.TestCase):
 
-    def _callFUT(self, secret='SEEKRIT', ticket=None, ip='1.2.3.4'):
+    def _callFUT(self, secret='SEEKRIT', ticket=None,
+                 ip='1.2.3.4', digest="md5"):
         from .._auth_tkt import parse_ticket
-        return parse_ticket(secret, ticket, ip)
+        return parse_ticket(secret, ticket, ip, digest)
 
     def test_bad_timestamp(self):
         from .._auth_tkt import BadTicket
@@ -129,9 +182,9 @@ class Test_parse_ticket(_Base):
         try:
             self._callFUT(ticket=TICKET)
         except BadTicket as e:
-            self.failUnless(e.args[0].startswith(
+            self.assertTrue(e.args[0].startswith(
                             'Timestamp is not a hex integer:'))
-        else:
+        else:  # pragma: no cover
             self.fail('Did not raise')
 
     def test_no_bang_after_userid(self):
@@ -141,7 +194,7 @@ class Test_parse_ticket(_Base):
             self._callFUT(ticket=TICKET)
         except BadTicket as e:
             self.assertEqual(e.args[0], 'userid is not followed by !')
-        else:
+        else:  # pragma: no cover
             self.fail('Did not raise')
 
     def test_wo_tokens_or_data_bad_digest(self):
@@ -151,12 +204,13 @@ class Test_parse_ticket(_Base):
             self._callFUT(ticket=TICKET)
         except BadTicket as e:
             self.assertEqual(e.args[0], 'Digest signature is not correct')
-        else:
+        else:  # pragma: no cover
             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', '', '')
+        from .._auth_tkt import calculate_digest, hashlib
+        digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID',
+                                  '', '', hashlib.md5)
         TICKET = '%s%08xUSERID!' % (digest, _WHEN)
         timestamp, userid, tokens, user_data = self._callFUT(ticket=TICKET)
         self.assertEqual(timestamp, _WHEN)
@@ -165,9 +219,9 @@ class Test_parse_ticket(_Base):
         self.assertEqual(user_data, '')
 
     def test_w_tokens_and_data_ok_digest(self):
-        from .._auth_tkt import calculate_digest
+        from .._auth_tkt import calculate_digest, hashlib
         digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID',
-                                  'a,b', 'DATA')
+                                  'a,b', 'DATA', hashlib.md5)
         TICKET = '%s%08xUSERID!a,b!DATA' % (digest, _WHEN)
         timestamp, userid, tokens, user_data = self._callFUT(ticket=TICKET)
         self.assertEqual(timestamp, _WHEN)
@@ -175,10 +229,22 @@ class Test_parse_ticket(_Base):
         self.assertEqual(tokens, ['a', 'b'])
         self.assertEqual(user_data, 'DATA')
 
+    def test_w_tokens_and_data_ok_alternate_digest(self):
+        from .._auth_tkt import calculate_digest, hashlib
+        digest = calculate_digest('1.2.3.4', _WHEN, 'SEEKRIT', 'USERID',
+                                  'a,b', 'DATA', hashlib.sha256)
+        TICKET = '%s%08xUSERID!a,b!DATA' % (digest, _WHEN)
+        timestamp, userid, tokens, user_data = self._callFUT(
+            ticket=TICKET, digest=hashlib.sha256)
+        self.assertEqual(timestamp, _WHEN)
+        self.assertEqual(userid, 'USERID')
+        self.assertEqual(tokens, ['a', 'b'])
+        self.assertEqual(user_data, 'DATA')
+
 
-class Test_helpers(_Base):
+class Test_helpers(unittest.TestCase):
 
-    # calculate_digest is not very testable, and fully exercised throug callers.
+    # calculate_digest is not very testable, fully exercised through callers.
 
     def test_ints_to_bytes(self):
         from struct import pack
@@ -194,7 +260,7 @@ class Test_helpers(_Base):
     def test_maybe_encode_bytes(self):
         from .._auth_tkt import maybe_encode
         foo = b'foo'
-        self.failUnless(maybe_encode(foo) is foo)
+        self.assertTrue(maybe_encode(foo) is foo)
 
     def test_maybe_encode_native_string(self):
         from .._auth_tkt import maybe_encode
diff --git a/repoze/who/tests/test__compat.py b/repoze/who/tests/test__compat.py
index ba94c86..76e4f29 100644
--- a/repoze/who/tests/test__compat.py
+++ b/repoze/who/tests/test__compat.py
@@ -1,12 +1,7 @@
 import unittest
 
-class CompatTests(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 CompatTests(unittest.TestCase):
 
     def test_REQUEST_METHOD_miss(self):
         # PEP 3333 says CONTENT_TYPE is mandatory
@@ -48,7 +43,7 @@ class CompatTests(unittest.TestCase):
         from .._compat import SimpleCookie
         environ = {'HTTP_COOKIE': 'qux=spam'}
         cookies = get_cookies(environ)
-        self.failUnless(isinstance(cookies, SimpleCookie))
+        self.assertTrue(isinstance(cookies, SimpleCookie))
         self.assertEqual(len(cookies), 1)
         self.assertEqual(cookies['qux'].value, 'spam')
         self.assertEqual(environ['paste.cookies'], (cookies, 'qux=spam'))
@@ -60,7 +55,7 @@ class CompatTests(unittest.TestCase):
                    'paste.cookies': (object(), 'foo=bar'),
                   }
         cookies = get_cookies(environ)
-        self.failUnless(isinstance(cookies, SimpleCookie))
+        self.assertTrue(isinstance(cookies, SimpleCookie))
         self.assertEqual(len(cookies), 1)
         self.assertEqual(cookies['qux'].value, 'spam')
         self.assertEqual(environ['paste.cookies'], (cookies, 'qux=spam'))
@@ -74,7 +69,7 @@ class CompatTests(unittest.TestCase):
                    'paste.cookies': (existing, 'qux=spam'),
                   }
         cookies = get_cookies(environ)
-        self.failUnless(cookies is existing)
+        self.assertTrue(cookies is existing)
 
     def test_construct_url(self):
         from .._compat import construct_url
@@ -95,13 +90,13 @@ class CompatTests(unittest.TestCase):
     def test_must_decode_non_string(self):
         from .._compat import must_decode
         foo = object()
-        self.failUnless(must_decode(foo) is foo)
+        self.assertTrue(must_decode(foo) is foo)
 
     def test_must_decode_unicode(self):
         from .._compat import must_decode
         from .._compat import u
         foo = u('foo')
-        self.failUnless(must_decode(foo) is foo)
+        self.assertTrue(must_decode(foo) is foo)
 
     def test_must_decode_utf8(self):
         from .._compat import must_decode
@@ -116,7 +111,7 @@ class CompatTests(unittest.TestCase):
     def test_must_encode_non_string(self):
         from .._compat import must_encode
         foo = object()
-        self.failUnless(must_encode(foo) is foo)
+        self.assertTrue(must_encode(foo) is foo)
 
     def test_must_encode_unicode(self):
         from .._compat import must_encode
@@ -127,10 +122,10 @@ class CompatTests(unittest.TestCase):
     def test_must_encode_utf8(self):
         from .._compat import must_encode
         foo = b'b\xc3\xa2tard'
-        self.failUnless(must_encode(foo) is foo)
+        self.assertTrue(must_encode(foo) is foo)
 
     def test_must_encode_latin1(self):
         from .._compat import must_encode
         foo = b'b\xe2tard'
-        self.failUnless(must_encode(foo) is foo)
+        self.assertTrue(must_encode(foo) is foo)
 
diff --git a/repoze/who/tests/test_api.py b/repoze/who/tests/test_api.py
index 6434067..02f5282 100644
--- a/repoze/who/tests/test_api.py
+++ b/repoze/who/tests/test_api.py
@@ -1,14 +1,7 @@
 import 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):
+class Test_get_api(unittest.TestCase):
 
     def _callFUT(self, environ):
         from repoze.who.api import get_api
@@ -17,15 +10,15 @@ class Test_get_api(_Base):
     def test___call___empty_environ(self):
         environ = {}
         api = self._callFUT(environ)
-        self.failUnless(api is None)
+        self.assertTrue(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)
+        self.assertTrue(api is expected)
 
-class APIFactoryTests(_Base):
+class APIFactoryTests(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.api import APIFactory
@@ -87,18 +80,18 @@ class APIFactoryTests(_Base):
         environ = {}
         factory = self._makeOne()
         api = factory(environ)
-        self.failUnless(isinstance(api, API))
-        self.failUnless(environ['repoze.who.api'] is api)
+        self.assertTrue(isinstance(api, API))
+        self.assertTrue(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)
+        self.assertTrue(api is expected)
 
 
-class TestMakeRegistries(_Base):
+class TestMakeRegistries(unittest.TestCase):
 
     def _callFUT(self, identifiers, authenticators, challengers, mdproviders):
         from repoze.who.api import make_registries
@@ -111,8 +104,10 @@ class TestMakeRegistries(_Base):
         self.assertEqual(name_reg, {})
 
     def test_brokenimpl(self):
-        self.assertRaises(ValueError, self._callFUT,
-                          [(None, object())], [], [], [])
+        from zope.interface.exceptions import Invalid
+        expected_exc = (Invalid, ValueError)  # BBB for zope.interface < 5.0.0
+        with self.assertRaises(expected_exc):
+            self._callFUT([(None, object())], [], [], [])
 
     def test_ok(self):
         from repoze.who.interfaces import IIdentifier
@@ -142,7 +137,7 @@ class TestMakeRegistries(_Base):
         self.assertEqual(name_reg['challenger'], dummy_challenger)
         self.assertEqual(name_reg['mdprovider'], dummy_mdprovider)
 
-class TestMatchClassification(_Base):
+class TestMatchClassification(unittest.TestCase):
 
     def _getFUT(self):
         from repoze.who.api import match_classification
@@ -169,7 +164,7 @@ class TestMatchClassification(_Base):
         # any for either
         self.assertEqual(f(IAuthenticator, plugins, 'buz'), [multi1, multi2])
 
-class APITests(_Base):
+class APITests(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.api import API
@@ -277,8 +272,8 @@ class APITests(_Base):
                             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.assertTrue(identity['identifier'] is identifier)
+        self.assertTrue(identity['authenticator'] is authenticator)
 
         self.assertEqual(len(logger._info), 1)
         self.assertEqual(logger._info[0], 'request classification: browser')
@@ -302,9 +297,9 @@ class APITests(_Base):
         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(
+        self.assertTrue(logger._debug[0].startswith(
                                         'challengers registered: ['))
-        self.failUnless(logger._debug[1].startswith(
+        self.assertTrue(logger._debug[1].startswith(
                                         'challengers matched for '
                                         'classification "match": ['))
 
@@ -326,13 +321,13 @@ class APITests(_Base):
         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(
+        self.assertTrue(logger._info[1].startswith('challenger plugin '))
+        self.assertTrue(logger._info[1].endswith(
                          '"challenge" returned an app'))
         self.assertEqual(len(logger._debug), 2)
-        self.failUnless(logger._debug[0].startswith(
+        self.assertTrue(logger._debug[0].startswith(
                                         'challengers registered: ['))
-        self.failUnless(logger._debug[1].startswith(
+        self.assertTrue(logger._debug[1].startswith(
                                         'challengers matched for '
                                         'classification "match": ['))
 
@@ -356,13 +351,14 @@ class APITests(_Base):
         self.assertEqual(result, None)
         self.assertEqual(environ['challenged'], None)
         self.assertEqual(identifier.forgotten, identity)
-        self.assertEqual(len(logger._info), 2)
+        self.assertEqual(len(logger._info), 3)
         self.assertEqual(logger._info[0], 'request classification: match')
-        self.assertEqual(logger._info[1], 'no challenge app returned')
+        self.assertTrue(logger._info[1].startswith('forgetting via headers '))
+        self.assertEqual(logger._info[2], 'no challenge app returned')
         self.assertEqual(len(logger._debug), 2)
-        self.failUnless(logger._debug[0].startswith(
+        self.assertTrue(logger._debug[0].startswith(
                                         'challengers registered: ['))
-        self.failUnless(logger._debug[1].startswith(
+        self.assertTrue(logger._debug[1].startswith(
                                         'challengers matched for '
                                         'classification "match": ['))
 
@@ -387,15 +383,16 @@ class APITests(_Base):
         self.assertEqual(result, app)
         self.assertEqual(environ['challenged'], app)
         self.assertEqual(identifier.forgotten, identity)
-        self.assertEqual(len(logger._info), 2)
+        self.assertEqual(len(logger._info), 3)
         self.assertEqual(logger._info[0], 'request classification: match')
-        self.failUnless(logger._info[1].startswith('challenger plugin '))
-        self.failUnless(logger._info[1].endswith(
+        self.assertTrue(logger._info[1].startswith('forgetting via headers '))
+        self.assertTrue(logger._info[2].startswith('challenger plugin '))
+        self.assertTrue(logger._info[2].endswith(
                          '"challenge" returned an app'))
         self.assertEqual(len(logger._debug), 2)
-        self.failUnless(logger._debug[0].startswith(
+        self.assertTrue(logger._debug[0].startswith(
                                         'challengers registered: ['))
-        self.failUnless(logger._debug[1].startswith(
+        self.assertTrue(logger._debug[1].startswith(
                                         'challengers matched for '
                                         'classification "match": ['))
 
@@ -424,16 +421,16 @@ class APITests(_Base):
         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(
+        self.assertTrue(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(
+        self.assertTrue(logger._info[1].endswith(repr(FORGET_HEADERS)))
+        self.assertTrue(logger._info[2].startswith('challenger plugin '))
+        self.assertTrue(logger._info[2].endswith(
                          '"challenge" returned an app'))
         self.assertEqual(len(logger._debug), 2)
-        self.failUnless(logger._debug[0].startswith(
+        self.assertTrue(logger._debug[0].startswith(
                                         'challengers registered: ['))
-        self.failUnless(logger._debug[1].startswith(
+        self.assertTrue(logger._debug[1].startswith(
                                         'challengers matched for '
                                         'classification "match": ['))
 
@@ -504,14 +501,7 @@ class APITests(_Base):
         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()}
+        identity = {'identifier': DummyNoResultsIdentifier()}
         api = self._makeOne()
         headers = api.remember(identity=identity)
         self.assertEqual(tuple(headers), ())
@@ -527,18 +517,16 @@ class APITests(_Base):
     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()}
+        environ['repoze.who.identity'] = {
+            'identifier': DummyIdentifier(remember_headers=HEADERS)}
         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(
+        self.assertTrue(logger._info[1].startswith(
                                         'remembering via headers from'))
-        self.failUnless(logger._info[1].endswith(repr(HEADERS)))
+        self.assertTrue(logger._info[1].endswith(repr(HEADERS)))
         self.assertEqual(len(logger._debug), 0)
 
     def test_remember_w_identity_passed_no_identifier(self):
@@ -554,29 +542,19 @@ class APITests(_Base):
     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()}
+        identity = {'identifier': DummyIdentifier(remember_headers=HEADERS)}
         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(
+        self.assertTrue(logger._info[1].startswith(
                                         'remembering via headers from'))
-        self.failUnless(logger._info[1].endswith(repr(HEADERS)))
+        self.assertTrue(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()}
+        identity = {'identifier': DummyNoResultsIdentifier()}
         api = self._makeOne()
         headers = api.forget(identity=identity)
         self.assertEqual(tuple(headers), ())
@@ -593,18 +571,16 @@ class APITests(_Base):
     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()}
+        environ['repoze.who.identity'] = {
+            'identifier': DummyIdentifier(forget_headers=HEADERS)}
         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(
+        self.assertTrue(logger._info[1].startswith(
                                         'forgetting via headers from'))
-        self.failUnless(logger._info[1].endswith(repr(HEADERS)))
+        self.assertTrue(logger._info[1].endswith(repr(HEADERS)))
         self.assertEqual(len(logger._debug), 0)
 
     def test_forget_w_identity_passed_no_identifier(self):
@@ -620,70 +596,42 @@ class APITests(_Base):
     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()}
+        identity = {'identifier': DummyIdentifier(forget_headers=HEADERS)}
         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(
+        self.assertTrue(logger._info[1].startswith(
                                         'forgetting via headers from'))
-        self.failUnless(logger._info[1].endswith(repr(HEADERS)))
+        self.assertTrue(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()),
+        identifiers = [('bogus', DummyNoResultsIdentifier()),
+                       ('valid', DummyIdentifier(
+                                    remember_headers=REMEMBER_HEADERS)),
                       ]
         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:])
+        self.assertEqual(headers, REMEMBER_HEADERS)
 
     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()),
+        identifiers = [('bogus', DummyIdentifier(
+                                    remember_headers=REMEMBER_HEADERS[:1])),
+                       ('valid', DummyIdentifier(
+                                    remember_headers=REMEMBER_HEADERS[1:])),
                       ]
         api = self._makeOne(identifiers=identifiers,
                             authenticators=[('authentic', authenticator)],
@@ -695,24 +643,12 @@ class APITests(_Base):
     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()),
+        identifiers = [('bogus', DummyNoResultsIdentifier()),
+                       ('valid', DummyIdentifier(
+                                    remember_headers=REMEMBER_HEADERS,
+                                    forget_headers=FORGET_HEADERS)),
                       ]
         api = self._makeOne(identifiers=identifiers,
                             authenticators=[('authentic', authenticator)],
@@ -723,23 +659,11 @@ class APITests(_Base):
 
     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()),
+        identifiers = [('valid', DummyIdentifier(
+                                    forget_headers=FORGET_HEADERS[:1])),
+                       ('bogus', DummyIdentifier(
+                                    forget_headers=FORGET_HEADERS[1:])),
                       ]
         api = self._makeOne(identifiers=identifiers,
                             environ=environ)
@@ -748,23 +672,10 @@ class APITests(_Base):
 
     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()),
+        identifiers = [('bogus', DummyNoResultsIdentifier()),
+                       ('valid', DummyIdentifier(
+                                    forget_headers=FORGET_HEADERS)),
                       ]
         api = self._makeOne(identifiers=identifiers,
                             environ=environ)
@@ -774,24 +685,11 @@ class APITests(_Base):
     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()),
+        identifiers = [('bogus', DummyNoResultsIdentifier()),
+                       ('valid', DummyIdentifier(
+                                    forget_headers=FORGET_HEADERS)),
                       ]
         api = self._makeOne(identifiers=identifiers,
                             authenticators=[('authentic', authenticator)],
@@ -800,22 +698,15 @@ class APITests(_Base):
         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())]
+        identifiers = [('valid', DummyNoResultsIdentifier())]
         api = self._makeOne(identifiers=identifiers,
                             authenticators=[('authentic', authenticator)],
                             environ=environ)
         api.logout()
-        self.failIf('repoze.who.identity' in environ)
+        self.assertFalse('repoze.who.identity' in environ)
 
     def test__identify_success(self):
         environ = self._makeEnviron()
@@ -854,14 +745,14 @@ class APITests(_Base):
         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(
+        self.assertTrue(logger._debug[0].startswith(
                                         'identifier plugins registered: ['))
-        self.failUnless(logger._debug[1].startswith(
+        self.assertTrue(logger._debug[1].startswith(
                                         'identifier plugins matched for '
                                         'classification "browser": ['))
-        self.failUnless(logger._debug[2].startswith(
+        self.assertTrue(logger._debug[2].startswith(
                                         'no identity returned from <'))
-        self.failUnless(logger._debug[2].endswith('> (None)'))
+        self.assertTrue(logger._debug[2].endswith('> (None)'))
         self.assertEqual(logger._debug[3], 'identities found: []')
 
     def test__identify_success_skip_noresults(self):
@@ -988,15 +879,15 @@ class APITests(_Base):
         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(
+        self.assertTrue(logger._debug[0].startswith(
                                         'authenticator plugins registered: ['))
-        self.failUnless(logger._debug[1].startswith(
+        self.assertTrue(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(
+        self.assertTrue(logger._debug[2].startswith('no userid returned from'))
+        self.assertTrue(logger._debug[3].startswith('userid returned from'))
+        self.assertTrue(logger._debug[3].endswith('"chris"'))
+        self.assertTrue(logger._debug[4].startswith(
                                          'identities authenticated: [((1, 0),'))
 
     def test__authenticate_success_multiresult(self):
@@ -1031,16 +922,16 @@ class APITests(_Base):
         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(
+        self.assertTrue(logger._debug[0].startswith(
                                         'authenticator plugins registered: ['))
-        self.failUnless(logger._debug[1].startswith(
+        self.assertTrue(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(
+        self.assertTrue(logger._debug[2].startswith('userid returned from'))
+        self.assertTrue(logger._debug[2].endswith('"chris_id1"'))
+        self.assertTrue(logger._debug[3].startswith('userid returned from'))
+        self.assertTrue(logger._debug[3].endswith('"chris_id2"'))
+        self.assertTrue(logger._debug[4].startswith(
                                          'identities authenticated: [((0, 0),')
                                          )
 
@@ -1131,7 +1022,7 @@ class APITests(_Base):
         self.assertEqual(identity.get('fuz'), None)
 
 
-class TestIdentityDict(_Base):
+class TestIdentityDict(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.api import Identity
@@ -1143,30 +1034,27 @@ class TestIdentityDict(_Base):
 
     def test_str(self):
         identity = self._makeOne(foo=1)
-        self.failUnless(str(identity).startswith('<repoze.who identity'))
+        self.assertTrue(str(identity).startswith('<repoze.who identity'))
         self.assertEqual(identity['foo'], 1)
 
     def test_repr(self):
         identity = self._makeOne(foo=1)
-        self.failUnless(str(identity).startswith('<repoze.who identity'))
+        self.assertTrue(str(identity).startswith('<repoze.who identity'))
         self.assertEqual(identity['foo'], 1)
 
 
 
-class DummyIdentifier:
+class DummyIdentifier(object):
     forgotten = False
     remembered = False
 
-    def __init__(self, credentials=None, remember_headers=None,
-                 forget_headers=None, replace_app=None):
+    def __init__(self, credentials=None,
+                 remember_headers=(), forget_headers=()):
         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):
@@ -1178,19 +1066,19 @@ class DummyIdentifier:
         return self.remember_headers
 
 
-class DummyNoResultsIdentifier:
+class DummyNoResultsIdentifier(object):
 
     def identify(self, environ):
         return None
 
     def remember(self, *arg, **kw):
-        pass
+        return ()
 
     def forget(self, *arg, **kw):
-        pass
+        return ()
 
 
-class DummyAuthenticator:
+class DummyAuthenticator(object):
     def __init__(self, userid=None):
         self.userid = userid
 
@@ -1200,12 +1088,12 @@ class DummyAuthenticator:
         return self.userid
 
 
-class DummyFailAuthenticator:
+class DummyFailAuthenticator(object):
     def authenticate(self, environ, credentials):
         return None
 
 
-class DummyChallenger:
+class DummyChallenger(object):
     _challenged_with = None
     def __init__(self, app=None):
         self.app = app
@@ -1216,7 +1104,7 @@ class DummyChallenger:
         return self.app
 
 
-class DummyMDProvider:
+class DummyMDProvider(object):
     def __init__(self, metadata=None):
         self._metadata = metadata
 
@@ -1224,30 +1112,25 @@ class DummyMDProvider:
         return identity.update(self._metadata)
 
 
-class DummyMultiPlugin:
+class DummyMultiPlugin(object):
     pass
 
 
-class DummyRequestClassifier:
+class DummyRequestClassifier(object):
     def __call__(self, environ):
         return 'browser'
 
 
-class DummyChallengeDecider:
-    def __call__(self, environ, status, headers):
-        if status.startswith('401 '):
-            return True
+class DummyChallengeDecider(object):
+    pass
 
 
-class DummyLogger:
+class DummyLogger(object):
     _info = _debug = ()
     def info(self, msg):
         self._info += (msg,)
     def debug(self, msg):
         self._debug += (msg,)
 
-class DummyApp:
+class DummyApp(object):
     environ = None
-    def __call__(self, environ, start_response):
-        self.environ = environ
-        return []
diff --git a/repoze/who/tests/test_classifiers.py b/repoze/who/tests/test_classifiers.py
index 44af225..a6834a2 100644
--- a/repoze/who/tests/test_classifiers.py
+++ b/repoze/who/tests/test_classifiers.py
@@ -1,14 +1,7 @@
 import 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):
+class TestDefaultRequestClassifier(unittest.TestCase):
 
     def _getFUT(self):
         from repoze.who.classifiers import default_request_classifier
@@ -24,7 +17,7 @@ class TestDefaultRequestClassifier(_Base):
 
     def test_conforms_to_IRequestClassifier(self):
         from repoze.who.interfaces import IRequestClassifier
-        self.failUnless(IRequestClassifier.providedBy(self._getFUT()))
+        self.assertTrue(IRequestClassifier.providedBy(self._getFUT()))
 
     def test_classify_dav_method(self):
         classifier = self._getFUT()
@@ -71,60 +64,46 @@ class TestDefaultRequestClassifier(_Base):
         self.assertEqual(result, 'browser')
 
 
-class TestDefaultChallengeDecider(_Base):
+class TestDefaultChallengeDecider(unittest.TestCase):
 
     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()))
+        self.assertTrue(IChallengeDecider.providedBy(self._getFUT()))
 
     def test_challenges_on_401(self):
         decider = self._getFUT()
-        self.failUnless(decider({}, '401 Unauthorized', []))
+        self.assertTrue(decider({}, '401 Unauthorized', []))
 
     def test_doesnt_challenges_on_non_401(self):
         decider = self._getFUT()
-        self.failIf(decider({}, '200 Ok', []))
+        self.assertFalse(decider({}, '200 Ok', []))
 
-class TestPassthroughChallengeDecider(_Base):
+class TestPassthroughChallengeDecider(unittest.TestCase):
 
     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()))
+        self.assertTrue(IChallengeDecider.providedBy(self._getFUT()))
 
     def test_challenges_on_bare_401(self):
         decider = self._getFUT()
-        self.failUnless(decider({}, '401 Unauthorized', []))
+        self.assertTrue(decider({}, '401 Unauthorized', []))
 
     def test_doesnt_challenges_on_non_401(self):
         decider = self._getFUT()
-        self.failIf(decider({}, '200 Ok', []))
+        self.assertFalse(decider({}, '200 Ok', []))
 
     def test_doesnt_challenges_on_401_with_WWW_Authenticate(self):
         decider = self._getFUT()
-        self.failIf(decider({}, '401 Ok', [('WWW-Authenticate', 'xxx')]))
+        self.assertFalse(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')]))
+        self.assertFalse(decider({}, '401 Ok', [('Content-Type', 'text/html')]))
diff --git a/repoze/who/tests/test_config.py b/repoze/who/tests/test_config.py
index 0be7266..0028144 100644
--- a/repoze/who/tests/test_config.py
+++ b/repoze/who/tests/test_config.py
@@ -1,14 +1,7 @@
 import 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 TestWhoConfig(_Base):
+class TestWhoConfig(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.config import WhoConfig
@@ -63,10 +56,10 @@ class TestWhoConfig(_Base):
         config = self._makeOne()
         config.parse(PLUGINS_ONLY)
         self.assertEqual(len(config.plugins), 2)
-        self.failUnless(isinstance(config.plugins['foo'],
+        self.assertTrue(isinstance(config.plugins['foo'],
                                    DummyPlugin))
         bar = config.plugins['bar']
-        self.failUnless(isinstance(bar, DummyPlugin))
+        self.assertTrue(isinstance(bar, DummyPlugin))
         self.assertEqual(bar.credentials, 'qux')
 
     def test_parse_general_empty(self):
@@ -85,8 +78,8 @@ class TestWhoConfig(_Base):
         PLUGIN_CLASS = self._getDummyPluginClass(IDummy)
         config = self._makeOne()
         config.parse(GENERAL_ONLY)
-        self.failUnless(isinstance(config.request_classifier, PLUGIN_CLASS))
-        self.failUnless(isinstance(config.challenge_decider, PLUGIN_CLASS))
+        self.assertTrue(isinstance(config.request_classifier, PLUGIN_CLASS))
+        self.assertTrue(isinstance(config.challenge_decider, PLUGIN_CLASS))
         self.assertEqual(config.remote_user_key, 'ANOTHER_REMOTE_USER')
         self.assertEqual(len(config.plugins), 0)
 
@@ -98,8 +91,8 @@ class TestWhoConfig(_Base):
         PLUGIN_CLASS = self._getDummyPluginClass(IDummy)
         config = self._makeOne()
         config.parse(GENERAL_WITH_PLUGINS)
-        self.failUnless(isinstance(config.request_classifier, PLUGIN_CLASS))
-        self.failUnless(isinstance(config.challenge_decider, PLUGIN_CLASS))
+        self.assertTrue(isinstance(config.request_classifier, PLUGIN_CLASS))
+        self.assertTrue(isinstance(config.challenge_decider, PLUGIN_CLASS))
 
     def test_parse_identifiers_only(self):
         from repoze.who.interfaces import IIdentifier
@@ -110,11 +103,11 @@ class TestWhoConfig(_Base):
         self.assertEqual(len(identifiers), 2)
         first, second = identifiers
         self.assertEqual(first[0], 'repoze.who.tests.test_config:DummyPlugin')
-        self.failUnless(isinstance(first[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(first[1], PLUGIN_CLASS))
         self.assertEqual(len(first[1].classifications), 1)
         self.assertEqual(first[1].classifications[IIdentifier], 'klass1')
         self.assertEqual(second[0], 'repoze.who.tests.test_config:DummyPlugin')
-        self.failUnless(isinstance(second[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(second[1], PLUGIN_CLASS))
 
     def test_parse_identifiers_with_plugins(self):
         from repoze.who.interfaces import IIdentifier
@@ -125,11 +118,11 @@ class TestWhoConfig(_Base):
         self.assertEqual(len(identifiers), 2)
         first, second = identifiers
         self.assertEqual(first[0], 'foo')
-        self.failUnless(isinstance(first[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(first[1], PLUGIN_CLASS))
         self.assertEqual(len(first[1].classifications), 1)
         self.assertEqual(first[1].classifications[IIdentifier], 'klass1')
         self.assertEqual(second[0], 'bar')
-        self.failUnless(isinstance(second[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(second[1], PLUGIN_CLASS))
 
     def test_parse_authenticators_only(self):
         from repoze.who.interfaces import IAuthenticator
@@ -140,11 +133,11 @@ class TestWhoConfig(_Base):
         self.assertEqual(len(authenticators), 2)
         first, second = authenticators
         self.assertEqual(first[0], 'repoze.who.tests.test_config:DummyPlugin')
-        self.failUnless(isinstance(first[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(first[1], PLUGIN_CLASS))
         self.assertEqual(len(first[1].classifications), 1)
         self.assertEqual(first[1].classifications[IAuthenticator], 'klass1')
         self.assertEqual(second[0], 'repoze.who.tests.test_config:DummyPlugin')
-        self.failUnless(isinstance(second[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(second[1], PLUGIN_CLASS))
 
     def test_parse_authenticators_with_plugins(self):
         from repoze.who.interfaces import IAuthenticator
@@ -155,11 +148,11 @@ class TestWhoConfig(_Base):
         self.assertEqual(len(authenticators), 2)
         first, second = authenticators
         self.assertEqual(first[0], 'foo')
-        self.failUnless(isinstance(first[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(first[1], PLUGIN_CLASS))
         self.assertEqual(len(first[1].classifications), 1)
         self.assertEqual(first[1].classifications[IAuthenticator], 'klass1')
         self.assertEqual(second[0], 'bar')
-        self.failUnless(isinstance(second[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(second[1], PLUGIN_CLASS))
 
     def test_parse_challengers_only(self):
         from repoze.who.interfaces import IChallenger
@@ -170,11 +163,11 @@ class TestWhoConfig(_Base):
         self.assertEqual(len(challengers), 2)
         first, second = challengers
         self.assertEqual(first[0], 'repoze.who.tests.test_config:DummyPlugin')
-        self.failUnless(isinstance(first[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(first[1], PLUGIN_CLASS))
         self.assertEqual(len(first[1].classifications), 1)
         self.assertEqual(first[1].classifications[IChallenger], 'klass1')
         self.assertEqual(second[0], 'repoze.who.tests.test_config:DummyPlugin')
-        self.failUnless(isinstance(second[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(second[1], PLUGIN_CLASS))
 
     def test_parse_challengers_with_plugins(self):
         from repoze.who.interfaces import IChallenger
@@ -185,11 +178,11 @@ class TestWhoConfig(_Base):
         self.assertEqual(len(challengers), 2)
         first, second = challengers
         self.assertEqual(first[0], 'foo')
-        self.failUnless(isinstance(first[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(first[1], PLUGIN_CLASS))
         self.assertEqual(len(first[1].classifications), 1)
         self.assertEqual(first[1].classifications[IChallenger], 'klass1')
         self.assertEqual(second[0], 'bar')
-        self.failUnless(isinstance(second[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(second[1], PLUGIN_CLASS))
 
     def test_parse_mdproviders_only(self):
         from repoze.who.interfaces import IMetadataProvider
@@ -200,11 +193,11 @@ class TestWhoConfig(_Base):
         self.assertEqual(len(mdproviders), 2)
         first, second = mdproviders
         self.assertEqual(first[0], 'repoze.who.tests.test_config:DummyPlugin')
-        self.failUnless(isinstance(first[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(first[1], PLUGIN_CLASS))
         self.assertEqual(len(first[1].classifications), 1)
         self.assertEqual(first[1].classifications[IMetadataProvider], 'klass1')
         self.assertEqual(second[0], 'repoze.who.tests.test_config:DummyPlugin')
-        self.failUnless(isinstance(second[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(second[1], PLUGIN_CLASS))
 
     def test_parse_mdproviders_with_plugins(self):
         from repoze.who.interfaces import IMetadataProvider
@@ -215,11 +208,11 @@ class TestWhoConfig(_Base):
         self.assertEqual(len(mdproviders), 2)
         first, second = mdproviders
         self.assertEqual(first[0], 'foo')
-        self.failUnless(isinstance(first[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(first[1], PLUGIN_CLASS))
         self.assertEqual(len(first[1].classifications), 1)
         self.assertEqual(first[1].classifications[IMetadataProvider], 'klass1')
         self.assertEqual(second[0], 'bar')
-        self.failUnless(isinstance(second[1], PLUGIN_CLASS))
+        self.assertTrue(isinstance(second[1], PLUGIN_CLASS))
 
     def test_parse_make_plugin_names(self):
         # see http://bugs.repoze.org/issue92
@@ -227,7 +220,7 @@ class TestWhoConfig(_Base):
         config.parse(MAKE_PLUGIN_ARG_NAMES)
         self.assertEqual(len(config.plugins), 1)
         foo = config.plugins['foo']
-        self.failUnless(isinstance(foo, DummyPlugin))
+        self.assertTrue(isinstance(foo, DummyPlugin))
         self.assertEqual(foo.iface, 'iface')
         self.assertEqual(foo.name, 'name')
         self.assertEqual(foo.template, '%(template)s')
@@ -355,7 +348,7 @@ template = %%(template)s
 template_with_eq = template_with_eq = %%(template_with_eq)s
 """
 
-class TestConfigMiddleware(_Base):
+class TestConfigMiddleware(unittest.TestCase):
     _tempdir = None
 
     def setUp(self):
@@ -394,7 +387,7 @@ class TestConfigMiddleware(_Base):
         self.assertEqual(len(api_factory.authenticators), 1)
         self.assertEqual(len(api_factory.challengers), 2)
         self.assertEqual(len(api_factory.mdproviders), 0)
-        self.failUnless(middleware.logger, middleware.logger)
+        self.assertTrue(middleware.logger, middleware.logger)
         self.assertEqual(middleware.logger.getEffectiveLevel(), logging.DEBUG)
 
     def test_sample_config_no_log_level(self):
@@ -420,7 +413,7 @@ class TestConfigMiddleware(_Base):
         self.assertEqual(middleware.logger.getEffectiveLevel(), logging.WARN)
         handlers = middleware.logger.handlers
         self.assertEqual(len(handlers), 1)
-        self.failUnless(isinstance(handlers[0], logging.StreamHandler))
+        self.assertTrue(isinstance(handlers[0], logging.StreamHandler))
         self.assertEqual(handlers[0].stream.name, logfile)
         logging.shutdown()
         handlers[0].stream.close()
@@ -436,10 +429,10 @@ class TestConfigMiddleware(_Base):
         self.assertEqual(middleware.logger.getEffectiveLevel(), logging.INFO)
         handlers = middleware.logger.handlers
         self.assertEqual(len(handlers), 1)
-        self.failUnless(isinstance(handlers[0], NullHandler))
+        self.assertTrue(isinstance(handlers[0], NullHandler))
         logging.shutdown()
 
-class NullHandlerTests(_Base):
+class NullHandlerTests(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.config import NullHandler
@@ -451,15 +444,14 @@ class NullHandlerTests(_Base):
     def test_inheritance(self):
         import logging
         handler = self._makeOne()
-        self.failUnless(isinstance(handler, logging.Handler))
+        self.assertTrue(isinstance(handler, logging.Handler))
 
     def test_emit_doesnt_raise_NotImplementedError(self):
         handler = self._makeOne()
         handler.emit(object())
 
-class Test_make_api_factory_with_config(_Base):
+class Test_make_api_factory_with_config(unittest.TestCase):
     _tempdir = None
-    _warning_filters = None
 
     def setUp(self):
         pass
@@ -468,9 +460,6 @@ class Test_make_api_factory_with_config(_Base):
         if self._tempdir is not None:
             import shutil
             shutil.rmtree(self._tempdir)
-        if self._warning_filters is not None:
-            import warnings
-            warnings.filters[:] = self._warning_filters
 
     def _getFactory(self):
         from repoze.who.config import make_api_factory_with_config
@@ -499,8 +488,8 @@ class Test_make_api_factory_with_config(_Base):
             self.assertEqual(len(api_factory.challengers), 0)
             self.assertEqual(len(api_factory.mdproviders), 0)
             self.assertEqual(api_factory.remote_user_key, 'REMOTE_USER')
-            self.failUnless(api_factory.logger is None)
-            self.failUnless(warned)
+            self.assertTrue(api_factory.logger is None)
+            self.assertTrue(warned)
 
     def test_bad_config_content(self):
         import warnings
@@ -514,8 +503,8 @@ class Test_make_api_factory_with_config(_Base):
             self.assertEqual(len(api_factory.challengers), 0)
             self.assertEqual(len(api_factory.mdproviders), 0)
             self.assertEqual(api_factory.remote_user_key, 'REMOTE_USER')
-            self.failUnless(api_factory.logger is None)
-            self.failUnless(warned)
+            self.assertTrue(api_factory.logger is None)
+            self.assertTrue(warned)
 
     def test_sample_config_no_logger(self):
         factory = self._getFactory()
@@ -527,7 +516,7 @@ class Test_make_api_factory_with_config(_Base):
         self.assertEqual(len(api_factory.challengers), 2)
         self.assertEqual(len(api_factory.mdproviders), 0)
         self.assertEqual(api_factory.remote_user_key, 'REMOTE_USER')
-        self.failUnless(api_factory.logger is None)
+        self.assertTrue(api_factory.logger is None)
 
     def test_sample_config_w_remote_user_key(self):
         factory = self._getFactory()
@@ -551,7 +540,7 @@ class Test_make_api_factory_with_config(_Base):
         self.assertEqual(len(api_factory.authenticators), 1)
         self.assertEqual(len(api_factory.challengers), 2)
         self.assertEqual(len(api_factory.mdproviders), 0)
-        self.failUnless(api_factory.logger is logger)
+        self.assertTrue(api_factory.logger is logger)
 
 SAMPLE_CONFIG = """\
 [plugin:redirector]
@@ -598,8 +587,3 @@ plugins =
 
 class DummyApp:
     environ = None
-    def __call__(self, environ, start_response):
-        self.environ = environ
-        return []
-
-
diff --git a/repoze/who/tests/test_middleware.py b/repoze/who/tests/test_middleware.py
index aa9412c..f53e81a 100644
--- a/repoze/who/tests/test_middleware.py
+++ b/repoze/who/tests/test_middleware.py
@@ -1,14 +1,7 @@
 import 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 TestMiddleware(_Base):
+class TestMiddleware(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.middleware import PluggableAuthenticationMiddleware
@@ -231,6 +224,29 @@ class TestMiddleware(_Base):
         self.assertEqual(start_response.status, '200 OK')
         self.assertEqual(start_response.headers, headers)
 
+    def test_call_200_no_challengers_app_calls_forget(self):
+        # See https://github.com/repoze/repoze.who/issues/21
+        environ = self._makeEnviron()
+        remember_headers = [('remember', '1')]
+        forget_headers = [('forget', '1')]
+        app = DummyLogoutApp('200 OK')
+        credentials = {'login':'chris', 'password':'password'}
+        identifier = DummyIdentifier(
+            credentials,
+            remember_headers=remember_headers,
+            forget_headers=forget_headers)
+        identifiers = [ ('identifier', identifier) ]
+        authenticator = DummyAuthenticator()
+        authenticators = [ ('authenticator', authenticator) ]
+        mw = self._makeOne(
+            app=app, identifiers=identifiers, authenticators=authenticators)
+        start_response = DummyStartResponse()
+        result = mw(environ, start_response)
+        self.assertEqual(mw.app.environ, environ)
+        self.assertEqual(result, ['body'])
+        self.assertEqual(start_response.status, '200 OK')
+        self.assertEqual(start_response.headers, forget_headers)
+
     def test_call_401_no_identifiers(self):
         from webob.exc import HTTPUnauthorized
         environ = self._makeEnviron()
@@ -243,7 +259,7 @@ class TestMiddleware(_Base):
         start_response = DummyStartResponse()
         result = b''.join(mw(environ, start_response)).decode('ascii')
         self.assertEqual(environ['challenged'], challenge_app)
-        self.failUnless(result.startswith('401 Unauthorized'))
+        self.assertTrue(result.startswith('401 Unauthorized'))
 
     def test_call_401_challenger_and_identifier_no_authenticator(self):
         from webob.exc import HTTPUnauthorized
@@ -262,7 +278,7 @@ class TestMiddleware(_Base):
 
         result = b''.join(mw(environ, start_response)).decode('ascii')
         self.assertEqual(environ['challenged'], challenge_app)
-        self.failUnless(result.startswith('401 Unauthorized'))
+        self.assertTrue(result.startswith('401 Unauthorized'))
         self.assertEqual(identifier.forgotten, False)
         self.assertEqual(environ.get('REMOTE_USER'), None)
 
@@ -285,7 +301,7 @@ class TestMiddleware(_Base):
         start_response = DummyStartResponse()
         result = b''.join(mw(environ, start_response)).decode('ascii')
         self.assertEqual(environ['challenged'], challenge_app)
-        self.failUnless(result.startswith('401 Unauthorized'))
+        self.assertTrue(result.startswith('401 Unauthorized'))
         # @@ unfuck
 ##         self.assertEqual(identifier.forgotten, identifier.credentials)
         self.assertEqual(environ['REMOTE_USER'], 'chris')
@@ -394,7 +410,7 @@ class TestMiddleware(_Base):
                            mdproviders=mdproviders)
         start_response = DummyStartResponse()
         result = b''.join(mw(environ, start_response)).decode('ascii')
-        self.failUnless(result.startswith('302 Found'))
+        self.assertTrue(result.startswith('302 Found'))
         self.assertEqual(start_response.status, '302 Found')
         headers = start_response.headers
         #self.assertEqual(len(headers), 3, headers)
@@ -405,7 +421,7 @@ class TestMiddleware(_Base):
         self.assertEqual(headers[3],
                          ('a', '1'))
         self.assertEqual(start_response.exc_info, None)
-        self.failIf('repoze.who.application' in environ)
+        self.assertFalse('repoze.who.application' in environ)
 
     def test_call_app_doesnt_call_start_response(self):
         from webob.exc import HTTPUnauthorized
@@ -452,8 +468,8 @@ class TestMiddleware(_Base):
                            mdproviders=mdproviders)
         start_response = DummyStartResponse()
         result = b''.join(mw(environ, start_response)).decode('ascii')
-        self.failUnless(result.startswith('401 Unauthorized'))
-        self.failUnless(app._iterable._closed)
+        self.assertTrue(result.startswith('401 Unauthorized'))
+        self.assertTrue(app._iterable._closed)
 
     def test_call_w_challenge_but_no_challenger_still_closes_iterable(self):
         environ = self._makeEnviron()
@@ -473,12 +489,12 @@ class TestMiddleware(_Base):
                            mdproviders=mdproviders)
         start_response = DummyStartResponse()
         self.assertRaises(RuntimeError, mw, environ, start_response)
-        self.failUnless(app._iterable._closed)
+        self.assertTrue(app._iterable._closed)
 
     # XXX need more call tests:
     #  - auth_id sorting
 
-class TestStartResponseWrapper(_Base):
+class TestStartResponseWrapper(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.middleware import StartResponseWrapper
@@ -492,7 +508,7 @@ class TestStartResponseWrapper(_Base):
         wrapper = self._makeOne(None)
         self.assertEqual(wrapper.start_response, None)
         self.assertEqual(wrapper.headers, [])
-        self.failUnless(wrapper.buffer)
+        self.assertTrue(wrapper.buffer)
 
     def test_finish_response(self):
         from repoze.who._compat import StringIO
@@ -523,7 +539,7 @@ class TestStartResponseWrapper(_Base):
         self.assertEqual(datases[0], 'written')
         self.assertEqual(closededs[0], True)
 
-class WrapGeneratorTests(_Base):
+class WrapGeneratorTests(unittest.TestCase):
 
     def _callFUT(self, iterable):
         from repoze.who.middleware import wrap_generator
@@ -539,29 +555,38 @@ class WrapGeneratorTests(_Base):
         self.assertEqual(L, ['yo!'])
         self.assertEqual(list(newgen), ['a', 'b'])
 
+    def test_w_empty_generator(self):
+        def gen():
+            if False:
+                yield 'a'  # pragma: no cover
+        newgen = self._callFUT(gen())
+        self.assertEqual(list(newgen), [])
+
     def test_w_iterator_having_close(self):
         def gen():
             yield 'a'
             yield 'b'
         iterable = DummyIterableWithClose(gen())
         newgen = self._callFUT(iterable)
-        self.failIf(iterable._closed)
+        self.assertFalse(iterable._closed)
         self.assertEqual(list(newgen), ['a', 'b'])
-        self.failUnless(iterable._closed)
+        self.assertTrue(iterable._closed)
 
-class TestMakeTestMiddleware(_Base):
+class TestMakeTestMiddleware(unittest.TestCase):
 
     def setUp(self):
         import os
-        self._old_WHO_LOG = os.environ.get('WHO_LOG')
+        try:
+            del os.environ['WHO_LOG']
+        except KeyError:
+            pass
 
     def tearDown(self):
         import os
-        if self._old_WHO_LOG is not None:
-            os.environ['WHO_LOG'] = self._old_WHO_LOG
-        else:
-            if 'WHO_LOG' in os.environ:
-                del os.environ['WHO_LOG']
+        try:
+            del os.environ['WHO_LOG']
+        except KeyError:
+            pass
 
     def _getFactory(self):
         from repoze.who.middleware import make_test_middleware
@@ -589,13 +614,13 @@ class TestMakeTestMiddleware(_Base):
         middleware = factory(app, global_conf)
         self.assertEqual(middleware.logger.getEffectiveLevel(), logging.DEBUG)
 
-class DummyApp:
+class DummyApp(object):
     environ = None
     def __call__(self, environ, start_response):
         self.environ = environ
         return []
 
-class DummyWorkingApp:
+class DummyWorkingApp(object):
     def __init__(self, status, headers):
         self.status = status
         self.headers = headers
@@ -605,7 +630,18 @@ class DummyWorkingApp:
         start_response(self.status, self.headers)
         return ['body']
 
-class DummyGeneratorApp:
+class DummyLogoutApp(object):
+    def __init__(self, status):
+        self.status = status
+
+    def __call__(self, environ, start_response):
+        self.environ = environ
+        api = environ['repoze.who.api']
+        headers = api.logout()
+        start_response(self.status, headers)
+        return ['body']
+
+class DummyGeneratorApp(object):
     def __init__(self, status, headers):
         self.status = status
         self.headers = headers
@@ -617,7 +653,7 @@ class DummyGeneratorApp:
             yield 'body'
         return gen()
 
-class DummyIterableWithClose:
+class DummyIterableWithClose(object):
     _closed = False
     def __init__(self, iterable):
         self._iterable = iterable
@@ -626,7 +662,7 @@ class DummyIterableWithClose:
     def close(self):
         self._closed = True
 
-class DummyIterableWithCloseApp:
+class DummyIterableWithCloseApp(object):
     def __init__(self, status, headers):
         self.status = status
         self.headers = headers
@@ -637,7 +673,7 @@ class DummyIterableWithCloseApp:
         start_response(self.status, self.headers)
         return self._iterable
 
-class DummyIdentityResetApp:
+class DummyIdentityResetApp(object):
     def __init__(self, status, headers, new_identity):
         self.status = status
         self.headers = headers
@@ -650,7 +686,7 @@ class DummyIdentityResetApp:
         start_response(self.status, self.headers)
         return ['body']
 
-class DummyChallenger:
+class DummyChallenger(object):
     def __init__(self, app=None):
         self.app = app
 
@@ -658,7 +694,7 @@ class DummyChallenger:
         environ['challenged'] = self.app
         return self.app
 
-class DummyIdentifier:
+class DummyIdentifier(object):
     forgotten = False
     remembered = False
 
@@ -682,51 +718,29 @@ class DummyIdentifier:
         self.remembered = identity
         return self.remember_headers
 
-class DummyAuthenticator:
-    def __init__(self, userid=None):
-        self.userid = userid
-
-    def authenticate(self, environ, credentials):
-        if self.userid is None:
-            return credentials['login']
-        return self.userid
-
-class DummyFailAuthenticator:
+class DummyAuthenticator(object):
     def authenticate(self, environ, credentials):
-        return None
+        return credentials['login']
 
-class DummyRequestClassifier:
+class DummyRequestClassifier(object):
     def __call__(self, environ):
         return 'browser'
 
-class DummyChallengeDecider:
+class DummyChallengeDecider(object):
     def __call__(self, environ, status, headers):
         if status.startswith('401 '):
             return True
 
-class DummyNoResultsIdentifier:
-    def identify(self, environ):
-        return None
-
-    def remember(self, *arg, **kw):
-        pass
-
-    def forget(self, *arg, **kw):
-        pass
-
-class DummyStartResponse:
+class DummyStartResponse(object):
     def __call__(self, status, headers, exc_info=None):
         self.status = status
         self.headers = headers
         self.exc_info = exc_info
         return []
 
-class DummyMDProvider:
+class DummyMDProvider(object):
     def __init__(self, metadata=None):
         self._metadata = metadata
 
     def add_metadata(self, environ, identity):
         return identity.update(self._metadata)
-
-class DummyMultiPlugin:
-    pass
diff --git a/repoze/who/tests/test_restrict.py b/repoze/who/tests/test_restrict.py
index 4e85d81..6f7cd30 100644
--- a/repoze/who/tests/test_restrict.py
+++ b/repoze/who/tests/test_restrict.py
@@ -1,14 +1,6 @@
 import 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 AuthenticatedPredicateTests(_Base):
+class AuthenticatedPredicateTests(unittest.TestCase):
 
     def _getFUT(self):
         from repoze.who.restrict import authenticated_predicate
@@ -17,38 +9,37 @@ class AuthenticatedPredicateTests(_Base):
     def test___call___no_identity_returns_False(self):
         predicate = self._getFUT()
         environ = {}
-        self.failIf(predicate(environ))
+        self.assertFalse(predicate(environ))
 
     def test___call___w_REMOTE_AUTH_returns_True(self):
         predicate = self._getFUT()
         environ = {'REMOTE_USER': 'fred'}
-        self.failUnless(predicate(environ))
+        self.assertTrue(predicate(environ))
 
     def test___call___w_repoze_who_identity_returns_True(self):
         predicate = self._getFUT()
         environ = {'repoze.who.identity': {'login': 'fred'}}
-        self.failUnless(predicate(environ))
+        self.assertTrue(predicate(environ))
 
-class MakeAuthenticatedRestrictionTests(_Base):
+class MakeAuthenticatedRestrictionTests(unittest.TestCase):
 
     def _getFUT(self):
         from repoze.who.restrict import make_authenticated_restriction
         return make_authenticated_restriction
 
     def test_enabled(self):
-        from repoze.who.restrict import authenticated_predicate
         fut = self._getFUT()
         app = DummyApp()
 
         filter = fut(app, {}, enabled=True)
 
-        self.failUnless(filter.app is app)
-        self.failUnless(filter.enabled)
+        self.assertTrue(filter.app is app)
+        self.assertTrue(filter.enabled)
         predicate = filter.predicate
-        self.failUnless(predicate({'REMOTE_USER': 'fred'}))
-        self.failUnless(predicate({'repoze.who.identity': {'login': 'fred'}}))
+        self.assertTrue(predicate({'REMOTE_USER': 'fred'}))
+        self.assertTrue(predicate({'repoze.who.identity': {'login': 'fred'}}))
 
-class PredicateRestrictionTests(_Base):
+class PredicateRestrictionTests(unittest.TestCase):
 
     def _getTargetClass(self):
         from repoze.who.restrict import PredicateRestriction
@@ -62,21 +53,18 @@ class PredicateRestrictionTests(_Base):
     def test___call___disabled_predicate_false_calls_app_not_predicate(self):
         _tested = []
         def _factory():
-            def _predicate(env):
-                _tested.append(env)
-                return False
+            def _predicate(env):  # pragma: no cover
+                assert False
             return _predicate
 
-        _started = []
         def _start_response(status, headers):
-            _started.append((status, headers))
+            assert False  # pragma: no cover
         environ = {'testing': True}
 
         restrict = self._makeOne(predicate=_factory, enabled=False)
         restrict(environ, _start_response)
 
         self.assertEqual(len(_tested), 0)
-        self.assertEqual(len(_started), 0)
         self.assertEqual(restrict.app.environ, environ)
 
     def test___call___enabled_predicate_false_returns_401(self):
@@ -108,19 +96,17 @@ class PredicateRestrictionTests(_Base):
                 return True
             return _predicate
 
-        _started = []
         def _start_response(status, headers):
-            _started.append((status, headers))
+            assert False  # pragma: no cover
         environ = {'testing': True, 'REMOTE_USER': 'fred'}
 
         restrict = self._makeOne(predicate=_factory)
         restrict(environ, _start_response)
 
         self.assertEqual(len(_tested), 1)
-        self.assertEqual(len(_started), 0)
         self.assertEqual(restrict.app.environ, environ)
 
-class MakePredicateRestrictionTests(_Base):
+class MakePredicateRestrictionTests(unittest.TestCase):
 
     def _getFUT(self):
         from repoze.who.restrict import make_predicate_restriction
@@ -130,15 +116,15 @@ class MakePredicateRestrictionTests(_Base):
         fut = self._getFUT()
         app = DummyApp()
         def _predicate(env):
-            return True
+            return True  # pragma: no cover
         def _factory():
             return _predicate
 
         filter = fut(app, {}, predicate=_factory)
 
-        self.failUnless(filter.app is app)
-        self.failUnless(filter.predicate is _predicate)
-        self.failUnless(filter.enabled)
+        self.assertTrue(filter.app is app)
+        self.assertTrue(filter.predicate is _predicate)
+        self.assertTrue(filter.enabled)
 
     def test_disabled_non_string_predicate_w_args(self):
         fut = self._getFUT()
@@ -147,10 +133,10 @@ class MakePredicateRestrictionTests(_Base):
         filter = fut(app, {}, predicate=DummyPredicate, enabled=False,
                      foo='Foo')
 
-        self.failUnless(filter.app is app)
-        self.failUnless(isinstance(filter.predicate, DummyPredicate))
+        self.assertTrue(filter.app is app)
+        self.assertTrue(isinstance(filter.predicate, DummyPredicate))
         self.assertEqual(filter.predicate.foo, 'Foo')
-        self.failIf(filter.enabled)
+        self.assertFalse(filter.enabled)
 
     def test_enabled_string_predicate_w_args(self):
         fut = self._getFUT()
@@ -160,20 +146,18 @@ class MakePredicateRestrictionTests(_Base):
                      predicate='repoze.who.tests.test_restrict:DummyPredicate',
                      enabled=True, foo='Foo')
 
-        self.failUnless(filter.app is app)
-        self.failUnless(isinstance(filter.predicate, DummyPredicate))
+        self.assertTrue(filter.app is app)
+        self.assertTrue(isinstance(filter.predicate, DummyPredicate))
         self.assertEqual(filter.predicate.foo, 'Foo')
-        self.failUnless(filter.enabled)
+        self.assertTrue(filter.enabled)
 
 
-class DummyApp:
+class DummyApp(object):
     environ = None
     def __call__(self, environ, start_response):
         self.environ = environ
         return []
 
-class DummyPredicate:
+class DummyPredicate(object):
     def __init__(self, **kw):
         self.__dict__.update(kw)
-    def __call__(self, env):
-        return True
diff --git a/repoze/who/utils.py b/repoze/who/utils.py
index fcaf706..857dd2d 100644
--- a/repoze/who/utils.py
+++ b/repoze/who/utils.py
@@ -2,4 +2,4 @@ def resolveDotted(dotted_or_ep):
     """ Resolve a dotted name or setuptools entry point to a callable.
     """
     from pkg_resources import EntryPoint
-    return EntryPoint.parse('x=%s' % dotted_or_ep).load(False)
+    return EntryPoint.parse('x=%s' % dotted_or_ep).resolve()
diff --git a/setup.cfg b/setup.cfg
index bf2c4f7..33d2ed0 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -2,15 +2,15 @@
 zip_ok = false
 
 [nosetests]
-cover-package = repoze.who
 nocapture = 1
+cover-package = repoze.who
 cover-erase = 1
+cover-min-percentage = 100
+
+[aliases]
+dev = develop easy_install repoze.who[testing]
 
 [egg_info]
 tag_build = 
 tag_date = 0
-tag_svn_revision = 0
-
-[aliases]
-dev = develop easy_install repoze.who[testing]
 
diff --git a/setup.py b/setup.py
index 75d6fd1..3359670 100644
--- a/setup.py
+++ b/setup.py
@@ -17,25 +17,35 @@ 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()
+def _read_file(filename):
+    try:
+        with open(os.path.join(here, filename)) as f:
+            return f.read()
+    except IOError:  # Travis???
+        return ''
+
+README = _read_file('README.rst')
+CHANGES = _read_file('CHANGES.rst')
 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',
+      version='2.4.1',
       description=('repoze.who is an identification and authentication '
                    'framework for WSGI.'),
       long_description='\n\n'.join([README, CHANGES]),
       classifiers=[
+        "Development Status :: 5 - Production/Stable",
         "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 :: 3.4",
+        "Programming Language :: Python :: 3.5",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Topic :: Internet :: WWW/HTTP",
diff --git a/tox.ini b/tox.ini
index 3932529..f641e1d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,35 +1,36 @@
 [tox]
 envlist = 
-    py26,py27,py32,py33,pypy,cover,docs
+    py27,pypy,py34,py35,py36,py37,py38,pypy3,cover,docs
 
 [testenv]
 commands = 
-    python setup.py test -q
+    python -m pytest --cov=repoze.who --cov-append --cov-report= {toxinidir}/repoze/who/tests/ {toxinidir}/repoze/who/plugins/tests/
+usedevelop=true
 deps =
     zope.interface
     WebOb
     virtualenv
+    pytest
+    pytest-cov
+setenv =
+    COVERAGE_FILE=.coverage.{envname}
 
 [testenv:cover]
+skip_install = true
 basepython =
-    python2.6
+    python3.8
 commands = 
-    nosetests --with-xunit --with-xcoverage
+    coverage combine
+    coverage report --fail-under=100 --show-missing --omit="*fixture*"
+    coverage xml
 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.
+setenv =
+    COVERAGE_FILE=.coverage
 
 [testenv:docs]
 basepython =
-    python2.6
+    python3.8
 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

Debdiff

[The following lists of changes regard files as different if they have different names, permissions or owners.]

Files in second set of .debs but not in first

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.4.1-nspkg.pth
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.4.1.egg-info/PKG-INFO
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.4.1.egg-info/dependency_links.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.4.1.egg-info/entry_points.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.4.1.egg-info/namespace_packages.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.4.1.egg-info/not-zip-safe
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.4.1.egg-info/requires.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.4.1.egg-info/top_level.txt

Files in first set of .debs but not in second

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.2-nspkg.pth
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.2.egg-info/PKG-INFO
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.2.egg-info/dependency_links.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.2.egg-info/entry_points.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.2.egg-info/namespace_packages.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.2.egg-info/not-zip-safe
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.2.egg-info/requires.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/repoze.who-2.2.egg-info/top_level.txt

No differences were encountered in the control files

More details

Full run details