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
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
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.
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)
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Hashes for Products.LoginLockout-0.5.0.tar.gz
Algorithm | Hash digest | |
---|---|---|
SHA256 | 97b952548e79708472dbd808a957212aa21a42e42da48f281c0bfc8d2d7a9981 |
|
MD5 | 10ae420a38ea7d3a3e7ffb62ab01f247 |
|
BLAKE2b-256 | 0b19e441b4a3a1b86e2cee310a70e0c0915168bbfbccab7943439bb33701a8e7 |
Hashes for Products.LoginLockout-0.5.0-py2-none-any.whl
Algorithm | Hash digest | |
---|---|---|
SHA256 | 9f930ca9dc803764a07f6f322a7982532ed07e5c08413d3e29364b531772ee56 |
|
MD5 | 7201e1b4f34622186c516ff09550ffbc |
|
BLAKE2b-256 | 9146751f0702358bb02521a93707beffe2fe37c853bb872a84470783290ae3ad |