Metadata-Version: 2.1
Name: Products.LoginLockout
Version: 0.5.0
Summary: This Pluggable Authentication Service (PAS) plugin will lock a                  login after a predetermined number of incorrect attempts. Once                  locked, the user will be shown a page that tells them to contact                  their administrator to unlock.
Home-page: https://github.com/collective/Products.LoginLockout
Author: Dylan Jay
Author-email: software@pretaweb.com
License: GPL
Keywords: PAS Plugins Zope Login Lockout Plone security
Platform: UNKNOWN
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Plugins
Classifier: Environment :: Web Environment
Classifier: Framework :: Plone :: 4.3
Classifier: Framework :: Plone :: 5.0
Classifier: Framework :: Plone :: 5.1
Classifier: Framework :: Plone :: 5.2
Classifier: Framework :: Plone :: 6.0
Classifier: Framework :: Zope2
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: BSD License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Zope
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
Requires-Dist: setuptools
Requires-Dist: ipaddress
Requires-Dist: six
Requires-Dist: collective.monkeypatcher
Provides-Extra: test
Requires-Dist: plone.app.testing ; extra == 'test'
Requires-Dist: zope.testing ; extra == 'test'

LoginLockout
============

.. image:: https://github.com/collective/Products.LoginLockout/workflows/CI/badge.svg
  :target: https://github.com/collective/Products.LoginLockout/actions

.. image:: https://coveralls.io/repos/collective/Products.LoginLockout/badge.svg?branch=master&service=github
  :target: https://coveralls.io/github/collective/Products.LoginLockout?branch=master


This Pluggable Authentication Service (PAS) plugin will lock a
login after a predetermined number of incorrect attempts. Once
locked, the user will be shown a page that tells them to contact
their administrator to unlock.


Requires:
---------

- PluggableAuthService and its dependencies

- (optional) PlonePAS and its dependencies

- (optional) Plone 4.1.x-6.0.x

.. image:: http://github-actions.40ants.com/collective/Products.LoginLockout/matrix.svg
   :target: https://github.com/collective/Products.LoginLockout


Features
--------

- Configurable number of allowed incorrect attempts before lockout
- Account will be usable again after a configurable amount of time
  (the "reset period")
  If the first login attempt after the reset period is invalid, the
  invalid login counter is set to 1.
- The user is presented with a message saying that the account was locked,
  and for how long.
  (It doesn't show remaining time, just the total lockout time.)
- You can restrict users to come from certain IP networks. You don't have to
  use the incorrect login attempts to use this feature.


Configuration
-------------

**NOTE** *If upgrading from 0.4.0 you will need run the upgrade or manually reset the PAS plugin order as below as this has changed.*

You can use this plugin with Zope without Plone, or with Plone. When using it with Plone you will configure it via the
Plone registry (plone 5+) or via portal_properties if plone 4.

Go to the Plone Control Panel -> LoginLockout Settings , there you can changes these defaults:

    >>> admin_browser = make_admin_browser('/')
    >>> admin_browser.getLink('Site Setup').click()
    >>> admin_browser.getLink('LoginLockout').click()
    >>> admin_browser.getLink('Lockout Settings').click()

- allowed incorrect attempts: 3
- reset period: 24 hours
- whitelist_ips: [] # any origin IP is allowed
- Fake Client IP: false

    >>> admin_browser.getControl("Max Attempts").value
    '3'
    >>> admin_browser.getControl("Reset Period (hours)").value
    '24.0'
    >>> admin_browser.getControl('Lock logins to IP Ranges').value
    ''
    >>> admin_browser.getControl('Fake Client IP').selected
    False


Let's ensure that the settings actually change

    >>> admin_browser.getControl('Fake Client IP').selected = True
    >>> get_loginlockout_settings().fake_client_ip
    False
    >>> admin_browser.getControl('Save').click()
    >>> 'Changes saved.' in admin_browser.contents
    True
    >>> get_loginlockout_settings().fake_client_ip
    True



Details
-------

LoginLockout can be used as a Plone plugin or with zope and PAS alone.
First we'll show you how it works with Plone.


To Install
----------

Install into Plone via Add/Remove Products. If you are installing into zope without
plone then you will need to follow these manual install steps.

This will install and activate a two PAS plugins.

Manual Installation
-------------------

This plugin needs to be installed in two places, the instance PAS where logins
occur and the root acl_users.

 1. Place the Product directory 'LoginLockout' in your 'Products/'
 directory. Restart Zope.

 2. In your instance PAS 'acl_users', select 'LoginLockout' from the add
 list.  Give it an id and title, and push the add button.

 3. Enable the 'Authentication', and the 'Update Credentials'
 plugin interfaces in the after-add screen.


 4. Repeat the above for your root PAS but as a plugin to

    -  Anonymoususerfactory


   and ensure LoginLockout is the first Anonymoususerfactory plugin

Steps 2 through 4 below will be done for you by the Plone installer.

That's it! Test it out.


Plone LoginLockout PAS Plugin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

It's very important the plugin is the *first* Authentication plugin in the activated plugins list.
This ensures we prevent a person attempting to make a login into a locked account and display a status message.
This also collects the username and login and will prevent a login should it be locked.

   >>> plone_pas = portal.acl_users.plugins
   >>> IAuthenticationPlugin = plone_pas._getInterfaceFromName('IAuthenticationPlugin')
   >>> plone_pas.listPlugins(IAuthenticationPlugin)
   [('login_lockout_plugin', <LoginLockout at /plone/acl_users/login_lockout_plugin>)...]


and a ICredentialsUpdatePlugin. This records when a login was successful to reset attempt data.


   >>> ICredentialsUpdatePlugin = plone_pas._getInterfaceFromName('ICredentialsUpdatePlugin')
   >>> 'login_lockout_plugin' in [p[0] for p in plone_pas.listPlugins(ICredentialsUpdatePlugin)]
   True


Root Zope LoginLockout PAS Plugin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

It will also install a plugin at the root of the zope instance.

It's important this is also the *first* IAnonymousUserFactoryPlugin. On a normal Zope instance it will be the only one.
This ensures it collects data on unsuccessful attempted logins.

   >>> root_pas = portal.getPhysicalRoot().acl_users.plugins
   >>> IAnonymousUserFactoryPlugin = plone_pas._getInterfaceFromName('IAnonymousUserFactoryPlugin')
   >>> root_pas.listPlugins(IAnonymousUserFactoryPlugin)
   [('login_lockout_plugin', <LoginLockout at /acl_users/login_lockout_plugin>)]



Lockout on incorrect password attempts
--------------------------------------

First login as manager

Now we'll open up a new browser and attempt to login::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = user_password
    >>> anon_browser.getControl('Log in').click()
    >>> 'Login failed' in anon_browser.contents
    False
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...You are now logged in...

    >>> anon_browser.open(portal.absolute_url()+'/logout')


Let's try again with another password::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = 'notpassword'
    >>> anon_browser.getControl('Log in').click()
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...Login failed...
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...You have 2 attempts left before this account is locked...


this incorrect attempt  will show up in the log


We've installed a Control panel to monitor the login attempts

    >>> admin_browser = make_admin_browser('/loginlockout_settings')
    >>> print(admin_browser.contents)
    <BLANKLINE>
    ...<td>test-user</td>...
    ...<td>1</td>...



If we try twice more we will be locked out::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = 'notpassword2'
    >>> anon_browser.getControl('Log in').click()
    >>> 'Login failed' in  anon_browser.contents
    True
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...You have 1 attempts left before this account is locked...
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = 'notpassword3'
    >>> anon_browser.getControl('Log in').click()
    >>> 'Login failed' in  anon_browser.contents
    True
    >>> 'attempts left' not in anon_browser.contents
    True

    >>> print(anon_browser.contents)
    <...
    ...This account has now been locked for security purposes...


Now even the correct password won't work::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = user_password
    >>> anon_browser.getControl('Log in').click()

    Not logged in
    >>> print(anon_browser.contents)
    <...
    ...This account has now been locked for security purposes...
    ...

    >>> "now logged in" not in anon_browser.contents
    True

    >>> anon_browser.getLink("Home").click()
    >>> anon_browser.getLink('Log in')
    <Link...>



The administrator can reset this persons account::

    >>> admin_browser = make_admin_browser('/loginlockout_settings')
    >>> print(admin_browser.contents)
    <BLANKLINE>
    ...<td>test-user</td>...
    ...<td>3</td>...
    >>> admin_browser.getControl(name='reset_nonploneusers:list').value = ['test-user']
    >>> admin_browser.getControl('Reset selected accounts').click()
    >>> print(admin_browser.contents)
    <BLANKLINE>
    ...Accounts were reset for these login names: test-user...

and now they can log in again::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = user_password
    >>> anon_browser.getControl('Log in').click()
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...You are now logged in...




IP Lockdown
-----------

You can optionally ensure logins are only possible for certain IP address ranges.

By default IP Locking is disabled.

NOTE: If you are using Zope behind a proxy then you must enable X-Forward-For headers on
each proxy otherwise this plugin will incorrectly use REMOTE_ADDR which will be a local IP.

To enable this go into the ZMI and enter the ranges in the whitelist_ips property::

    >>> config_property( whitelist_ips = u'10.1.1.1' )

If there are proxies infront of zope you will have to ensure they set the ```X-Forwarded-For``` header.
Note only the first forwarded IP will be used.::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.addHeader('X-Forwarded-For', '10.1.1.1, 192.168.1.1')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = user_password
    >>> anon_browser.getControl('Log in').click()
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...You are now logged in...

    >>> anon_browser.open(portal.absolute_url()+'/logout')

If not from a valid IP then the login will fail::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.addHeader('X-Forwarded-For', '2.2.2.2')

    >>> anon_browser.open(portal.absolute_url()+'/login_form')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = user_password
    >>> anon_browser.getControl('Log in').click()
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...Login currently unavailable...
    >>> anon_browser.getLink('Log in')
    <Link text='Log in'...>


Basic Auth will works with the right IP::

    >>> anon_browser = make_anon_browser()
    >>> anon_browser.addHeader('Authorization', 'Basic %s:%s' % (user_id,user_password))
    >>> anon_browser.addHeader('X-Forwarded-For', '10.1.1.1')

    >>> anon_browser.open(portal.absolute_url())
    >>> anon_browser.getLink('Log out')
    <Link text='Log out'...>


and basic auth fails with the wrong IP::

    >>> anon_browser = make_anon_browser()
    >>> anon_browser.addHeader('Authorization', 'Basic %s:%s' % (user_id,user_password))
    >>> anon_browser.addHeader('X-Forwarded-For', '2.2.2.2')

    >>> anon_browser.open(portal.absolute_url())
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...Login currently unavailable...
    >>> anon_browser.getLink('Log in')
    <Link text='Log in'...>


We can still use a root login at the root::

    >>> anon_browser = make_anon_browser()
    >>> anon_browser.addHeader('Authorization', 'Basic %s:%s' % (base_id, base_password))
    >>> anon_browser.addHeader('X-Forwarded-For', '2.2.2.2')

    >>> anon_browser.open(portal.absolute_url()+'/../manage_main')
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...manage_workspace...

But we can't get into the plone site with a root id any more::

    >>> anon_browser.open(portal.absolute_url()+'/manage_main')
    Traceback (most recent call last):
    ...
    Unauthorized: You are not authorized to access this resource.


You can also set IP ranges e.g.::

    >>> config_property( whitelist_ips = u"""10.1.1.1
    ... 10.1.0.0/16 # range 1
    ... 2.2.0.0/16 # range 2
    ... """)

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.addHeader('X-Forwarded-For', '2.2.2.2')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = user_password
    >>> anon_browser.getControl('Log in').click()
    >>> print(anon_browser.contents)
    <BLANKLINE>
    ...You are now logged in...

    >>> anon_browser.open(portal.absolute_url()+'/logout')

You can also set a env variable LOGINLOCKOUT_IP_WHITELIST which is merged with the config.
This allows those with filesystem access a way to get in if they have set their config wrong.
It also allows a set of IP ranges to be set for any site in a Plone multisite setup as long
as the site has loginlockout installed.::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.getLink('Log in')
    <Link text='Log in'...

    >>> import os; os.environ["LOGINLOCKOUT_IP_WHITELIST"] = "3.3.3.3"

    >>> anon_browser.addHeader('Authorization', 'Basic %s:%s' % (user_id,user_password))
    >>> anon_browser.addHeader('X-Forwarded-For', '3.3.3.3')

    >>> anon_browser.open(portal.absolute_url())
    >>> anon_browser.getLink('Log out')
    <Link text='Log out'...>


Note that you still have to have the IP lockout config set otherwise logins are allowed from anywhere
even with the env variable set::

    >>> config_property( whitelist_ips = u"""
    ... """)
    >>> anon_browser = make_anon_browser()
    >>> anon_browser.addHeader('Authorization', 'Basic %s:%s' % (user_id,user_password))
    >>> anon_browser.addHeader('X-Forwarded-For', '4.4.4.4')

    >>> anon_browser.open(portal.absolute_url())
    >>> anon_browser.getLink('Log out')
    <Link text='Log out'...>


    >>> del os.environ["LOGINLOCKOUT_IP_WHITELIST"]


If you are unsure of what is being detected as your current Client IP you can see it in
the control panel::

    >>> admin_browser = make_admin_browser('/')
    >>> admin_browser.addHeader('X-Forwarded-For', '10.1.1.1, 192.168.1.1')

    >>> admin_browser.getLink('Site Setup').click()
    >>> admin_browser.getLink('LoginLockout').click()
    >>> print(admin_browser.contents)
    <BLANKLINE>
    ...Current detected Client IP: <span>10.1.1.1</span>...


Login History
-------------

It is also possible to view a history of successful logins for a particular user. Note this is the user id rather
than user login and they can be different. User ``test_user_1_`` had 4 successful logins.::

    >>> admin_browser = make_admin_browser('/loginlockout_settings')
    >>> admin_browser.getLink('Login history').click()
    >>> admin_browser.getControl('Username pattern').value = 'test_user_1_'
    >>> admin_browser.getControl('Search records').click()
    >>> print(admin_browser.contents)
    <BLANKLINE>
    ...
                        <td valign="top">test_user_1_</td>
                        <td valign="top">
                            <ul>
                                <li>
                                    ...
                                    ()
                                </li>
                                <li>
                                    ...
                                    ()
                                </li>
                                <li>
                                    ...
                                    (10.1.1.1)
                                </li>
                                <li>
                                    ...
                                    (2.2.2.2)
                                </li>
                            </ul>
    ...



Password Reset History
----------------------

When a user changes their password::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = user_password
    >>> anon_browser.getControl('Log in').click()

    >>> anon_browser.getLink("Preferences").click()
    >>> anon_browser.getLink("Password").click()
    >>> anon_browser.getControl('Current password').value = user_password
    >>> anon_browser.getControl('New password').value = '12345678'
    >>> anon_browser.getControl('Confirm password').value = '12345678'
    >>> anon_browser.getControl('Change Password').click()
    >>> print(anon_browser.contents)
    <...
    ...Password changed... 
    ...

This changed the password::

    >>> anon_browser = make_anon_browser('/login_form')
    >>> anon_browser.getControl('Login Name').value = user_id
    >>> anon_browser.getControl('Password').value = '12345678'
    >>> anon_browser.getControl('Log in').click()
    >>> anon_browser.getLink("Preferences").click()

The the administrators can see the password was changed::

    >>> admin_browser = make_admin_browser('/loginlockout_settings')
    >>> admin_browser.getLink('History password changes').click()
    >>> print(admin_browser.contents)
    <...
    ...
            <tr class="even">
                <td>test_user_1_</td>
                <td>...</td>
            </tr>
    ...

Other support
--------------

Root users can also be locked out and with basic authentication too::

    >>> def try_base_login(pw):
    ...    anon_browser = make_anon_browser()  # Can't redefine header in older testbrowser
    ...    anon_browser.addHeader('Authorization', 'Basic %s:%s' % (base_id, pw))
    ...    anon_browser.open(portal.absolute_url())
    ...    print(anon_browser.contents)
    >>> try_base_login("attempt1")
    <...
    ...You have 2 attempts left before this account is locked...

    >>> try_base_login("attempt2")
    <...
    ...You have 1 attempts left before this account is locked...
    >>> try_base_login("attempt3")
    <...
    ...This account has now been locked for security purposes...
    ...
    >>> try_base_login(base_password)
    <...
    ...This account has now been locked for security purposes...
    ...



Implementation
--------------

If the root anonymoususerfactory plugin is activated following an
authentication plugin activation then this is an unsuccesful login
attempt. If the password was different from the last unsuccessful
attempt then we increment a counter in data stored persistently
in the root plugin.

If the instance plugin tries to authenticate a user that has been
marked has having too many attempts then Unauthorised will be raised.
This will activate the challenge plugin which will display a locked
out message instead of another login form.

updateCredentials is called when the login was successful and in this
case we reset the unsuccessful login count.


Troubleshooting
---------------

AttributeError: manage_addLoginLockout
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

If, while running test, you get ``AttributeError: manage_addLoginLockout``,
this is likely due to the fact that the ``initialize()`` method from ``__init__.py``
isn't run during test setup.

To resolve, explicitly call::

    z2.installProduct(portal, 'Products.LoginLockout')


Developing
----------

It's great that you want to help advance this add-on!

To start development:

::

    git clone git@github.com:collective/Products.LoginLockout.git
    cd Products.LoginLockout
    virtualenv .
    ./bin/python bootstrap.py
    ./bin/buildout
    ./bin/test


Please observe the following:

* Only start work when tests are currently passing.
  If not, fix them, or ask someone (*) for help.

* Make your work in a branch and create a pull request for it on github.
  Ask for someone (*) to merge it.

* Please adhere to guidelines: pep8.
  We use plone.recipe.codeanalysis to enforce some of these.

(*) People that might be able to help you out:
    khink, djay, ajung, macagua


TODO
----
Things that could be done on the LoginLockout product:

- Move skins to browser views

- get rid of overrides for pw resets. Should be able to do in PAS or using events

- optional path to store attempts db so it can be stored in historyless db.

- perhaps have a short lock or a captcha to prevent rapid attempts instead of a full lockout

- Only restrict certain groups to certain IP networks e.g. administrators. Maybe roles too?



Copyright, License, Author
--------------------------

Copyright (c) 2007, PretaWeb, Australia,
 and the respective authors. All rights reserved.

Author: Dylan Jay <software pretaweb com>

License BSD-ish, see LICENSE.txt


Credits
-------

Dylan Jay, original code.

Contributors:

* Kees Hink
* Andreas Jung
* Leonardo J. Caballero G.
* Wolfgang Thomas
* Peter Uittenbroek
* Ovidiu Miron
* Ludolf Takens
* Maarten Kling

Thanks to Daniel Nouri and BlueDynamics for their
NoDuplicateLogin which served as the base for this.


Changes
=======

0.5.0 (2024-03-08)
------------------

- Make Python 3 and Plone 5.2 compatible [HybridAU]
- Made changes so the basics works in 5.2 and 6.0 (classic) [djay]
- Changed plugin operation to reset credentials instead of raise UnAuthorised so status messages work and switched to using status messages. 
  This does require a different order of PAS Plugins.
  [djay]
- Added warnings of attempts left
  [djay]
- Included the ability to restrict requests from certain IP networks. Config page shows current client IP [djay]
- Moved attempts storage to Plone site so no data leakage between sites
  [djay]
- Plone 5+ now uses registry for config [djay]
- Remove 'select all' buttons.
  [ivanteoh]
- Corrected private method bug.
  Added french translations.
  Corrected translation domain.
  Added some translations.
  Corrected control panel icon.
  [sgeulette]
- Corrected uninstall profile
  [sgeulette]
- Fixed change password history for versions 4.1-6.0
  [djay]
- Removed testing for 4.1
  [djay]

0.4.0 (2015-11-25)
------------------

- Fix incorrect flake8 in skins template python script.
  [khink]


0.3.9 (2015-11-18)
------------------

- Don't unicode error in portal message when resetting
  [maartenkling]


0.3.8 (2015-10-17)
------------------

- Include Travis build badge.
  Fixed test setup, make code-analysis work, update README with development info.
  (khink)


0.3.7 (2015-06-08)
------------------

- Reset counter after reset period.
  (ltakens)

0.3.6 (2015-04-08)
------------------

- Render the lockout message in the site layout.
  Show the reset period in the lockout message,
  so people don't have to contact the site administrator again.
  (khink)


0.3.5 (2015-04-02)
------------------

- Make number of allowed attempts configurable through the ZMI
  (khink)


0.3.4 (2015-04-01)
------------------

- Make reset_period configurable through the ZMI (khink)
- Added more strings classifiers items for this packages. (macagua)
- Added plone_deprecated skins for gif icon. (macagua)
- Added support for Configlet with GenericSetup profile. (macagua)
- Added Spanish translation. (macagua)
- Added i18n support. (macagua)

- LoginLockout interface updated as follows (omiron):
    - group user lockouts separate from bogus info
    - links to users profile page
    - provide full user name and email to ease "find in page"
- Introduct 'select all' option in configlet (thepjot)
- Re-enable 'reset_period', after reset_period has expired, user gets another chance (thepjot)


0.3.3 (2013-11-20)
------------------

- check for fake_client_ip in a more defensive way (pysailor)


0.3.2 (2012-03-12)
------------------

- fixed deprecation warnings (Andreas Jung)


0.3.1 (2012-02-13)
------------------

- fixed some restructured text bugs in documentation  (Andreas Jung)


0.3 (2011-03-04)
----------------

- internal cleanup

- using GenericSetup where possible

- added support for logging successful login attempts

- added support logging password changes

(Andreas Jung)


0.2 (2009-04-20)
----------------

- Eggified merged configlet version

- Started doctest

(Dylan Jay)


(2009-03-10)
------------

- Added configlet for viewing failed attempts and resetting accounts from the
  plone control panel.

- Quite probably, dropped support for pure Zope usage.

(Kees Hink)


(2008-12-18)
------------

- Added installer, using Extensions/Install.py.
  (Unfortunately, Generic Setup does not yet seem to support uninstalling, but
  the methods in setuphandlers.py and the import profile (profiles/default) are
  there for when you want to use them. Just uncomment the relevant zcml
  directives.)

(Kees Hink)


0.1 (unknown)
-------------

- Initial Version (Dylan Jay)


