Skip to main content

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.

Project description

LoginLockout

https://github.com/collective/Products.LoginLockout/workflows/CI/badge.svg https://coveralls.io/repos/collective/Products.LoginLockout/badge.svg?branch=master&service=github

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

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

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.

  1. 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?

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)

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

Products.LoginLockout-0.5.0.tar.gz (57.5 kB view hashes)

Uploaded source

Built Distribution

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page