[Checkins] SVN: z3c.password/trunk/ merge of branch adamg-options, with new features:
Adam Groszer
agroszer at gmail.com
Wed Jun 17 06:32:19 EDT 2009
Log message for revision 101100:
merge of branch adamg-options, with new features:
- change password on next login,
- lockout period for automatic timed unlock
- ``IPasswordOptionsUtility`` to have global password options
Changed:
U z3c.password/trunk/CHANGES.txt
U z3c.password/trunk/buildout.cfg
U z3c.password/trunk/src/z3c/password/README.txt
U z3c.password/trunk/src/z3c/password/interfaces.py
U z3c.password/trunk/src/z3c/password/principal.py
A z3c.password/trunk/src/z3c/password/principal.txt
U z3c.password/trunk/src/z3c/password/tests.py
-=-
Modified: z3c.password/trunk/CHANGES.txt
===================================================================
--- z3c.password/trunk/CHANGES.txt 2009-06-17 10:29:25 UTC (rev 101099)
+++ z3c.password/trunk/CHANGES.txt 2009-06-17 10:32:18 UTC (rev 101100)
@@ -2,8 +2,27 @@
CHANGES
=======
-0.5.1 (unreleased)
+0.6.0 (unreleased)
+- Features:
+ ``PrincipalMixIn`` got some new properties:
+ * ``passwordExpired``: to force the expiry of the password
+ * ``lockOutPeriod``: to enable automatic lock and unlock on too many bad tries
+
+ ``IPasswordOptionsUtility`` to have global password options:
+ * ``changePasswordOnNextLogin``: not implemented here,
+ use PrincipalMixIn.passwordExpired
+ * ``lockOutPeriod``: global counterpart of the PrincipalMixIn property
+ * ``passwordExpiresAfter``: global counterpart of the PrincipalMixIn property
+ * ``maxFailedAttempts``: global counterpart of the PrincipalMixIn property
+
+ Password checking goes like this (on the high level):
+ 1. raise AccountLocked if too many bad tries and account should be locked
+ 2. raise PasswordExpired if expired AND password matches
+ 3. raise TooManyLoginFailures if too many bad tries
+ 4. return whether password matches
+ More details in ``principal.txt``
+
- Added Russian translation
- Refactor PrincipalMixIn now() into a separate method to facilitate
Modified: z3c.password/trunk/buildout.cfg
===================================================================
--- z3c.password/trunk/buildout.cfg 2009-06-17 10:29:25 UTC (rev 101099)
+++ z3c.password/trunk/buildout.cfg 2009-06-17 10:32:18 UTC (rev 101100)
@@ -1,6 +1,6 @@
[buildout]
develop = .
-parts = test checker coverage i18n
+parts = test checker coverage-test coverage-report i18n
[test]
recipe = zc.recipe.testrunner
@@ -10,9 +10,16 @@
recipe = lovely.recipe:importchecker
path = src/z3c/password
-[coverage]
+[coverage-test]
+recipe = zc.recipe.testrunner
+eggs = z3c.password [test]
+defaults = ['--coverage', '../../coverage']
+
+[coverage-report]
recipe = zc.recipe.egg
eggs = z3c.coverage
+scripts = coverage=coverage-report
+arguments = ('coverage', 'coverage/report')
[i18n]
recipe = lovely.recipe:i18n
Modified: z3c.password/trunk/src/z3c/password/README.txt
===================================================================
--- z3c.password/trunk/src/z3c/password/README.txt 2009-06-17 10:29:25 UTC (rev 101099)
+++ z3c.password/trunk/src/z3c/password/README.txt 2009-06-17 10:32:18 UTC (rev 101100)
@@ -126,6 +126,11 @@
...
TooManyGroupCharacters
+ >>> pwd.verify(unichr(0x0e1)*8)
+ Traceback (most recent call last):
+ ...
+ TooManyGroupCharacters
+
Let's now verify a list of password that were provided by a bank:
>>> for new in ('K7PzX2JZ', 'DznMLIww', 'ks59Ursq', 'YUcsuIrQ', 'bPEUFGSa',
@@ -142,7 +147,13 @@
>>> pwd.generate()
'Us;iwbzM[J'
+Force a LOT to make coverage happy:
+ >>> for x in xrange(256):
+ ... _ =pwd.generate()
+
+
+
The Password Field
------------------
@@ -171,120 +182,3 @@
...
TooShortPassword
-
-The Principal Mix-in
---------------------
-
-The principal mixin is a quick and functional example on how to use the
-password utility and field. The mix-in class defines the following additional
-attributes:
-
-
-- ``passwordExpiresAfter``
-
- A time delta object that describes for how long the password is valid before
- a new one has to be specified. If ``None``, the password will never expire.
-
-- ``maxFailedAttempts``
-
- An integer specifying the amount of failed attempts allowed to check the
- password before the password is locked and no new password can be provided.
-
-- ``passwordSetOn``
-
- The date/time at which the password was last set. This value is used to
- determine the expiration of a password.
-
-- ``failedAttempts``
-
- This is a counter that keeps track of the amount of failed login attempts
- since the last successful one. This value is used to determine when to lock
- the account after the maximum amount of failures has been reached.
-
-Let's now create a principal:
-
- >>> from zope.app.authentication import principalfolder
- >>> from z3c.password import principal
-
- >>> class MyPrincipal(principal.PrincipalMixIn,
- ... principalfolder.InternalPrincipal):
- ... pass
-
- >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
-
-Since the password has been immediately set, the ```passwordSetOn`` attribute
-should have a value:
-
- >>> user.passwordSetOn
- datetime.datetime(...)
-
-Initially, the amount of failed attempts is zero, ...
-
- >>> user.failedAttempts
- 0
-
-but after checking the password incorrectly, the value is updated:
-
- >>> user.checkPassword('456456')
- False
- >>> user.failedAttempts
- 1
-
-Initially there is no constraint on user, but let's add some:
-
- >>> user.passwordExpiresAfter
- >>> user.passwordExpiresAfter = datetime.timedelta(180)
-
- >>> user.maxFailedAttempts
- >>> user.maxFailedAttempts = 3
-
-Let's now provide the incorrect password a couple more times:
-
- >>> user.checkPassword('456456')
- False
- >>> user.checkPassword('456456')
- False
- >>> user.checkPassword('456456')
- Traceback (most recent call last):
- ...
- TooManyLoginFailures: The password was entered incorrectly too often.
-
-As you can see, once the maximum mount of attempts is reached, the system does
-not allow you to log in at all anymore. At this point the password has to be
-reset otherwise. However, you can tell the ``check()`` method explicitly to
-ignore the failure count:
-
- >>> user.checkPassword('456456', ignoreFailures=True)
- False
-
-Let's now reset the failure count.
-
- >>> user.failedAttempts = 0
-
-Next we expire the password:
-
- >>> user.passwordSetOn = datetime.datetime.now() + datetime.timedelta(-181)
-
-A corresponding exception should be raised:
-
- >>> user.checkPassword('456456')
- False
-
-Not yet, because the password did not match.
-
-Once we match the password it is raised:
-
- >>> user.checkPassword('123123')
- Traceback (most recent call last):
- ...
- PasswordExpired: The password has expired.
-
-Like for the too-many-failures exception above, you can explicitely turn off
-the expiration check:
-
- >>> user.checkPassword('456456', ignoreExpiration=True)
- False
-
-It is the responsibility of the presentation code to provide views for those
-two exceptions. For the latter, it is common to allow the user to enter a new
-password after providing the old one as verification.
Modified: z3c.password/trunk/src/z3c/password/interfaces.py
===================================================================
--- z3c.password/trunk/src/z3c/password/interfaces.py 2009-06-17 10:29:25 UTC (rev 101099)
+++ z3c.password/trunk/src/z3c/password/interfaces.py 2009-06-17 10:32:18 UTC (rev 101100)
@@ -54,7 +54,15 @@
self.principal = principal
Exception.__init__(self, self.__doc__)
+class AccountLocked(Exception):
+ __doc__ = _('The account is locked, because the password was '
+ 'entered incorrectly too often.')
+ def __init__(self, principal):
+ self.principal = principal
+ Exception.__init__(self, self.__doc__)
+
+
class IPasswordUtility(zope.interface.Interface):
"""Component to verify and generate passwords.
@@ -126,3 +134,38 @@
description=(u'The similarity ratio between the new and old password.'),
required=False,
default=None)
+
+
+class IPasswordOptionsUtility(zope.interface.Interface):
+ """Different general security options.
+
+ The purpose of this utility is to make common password-related options
+ available
+ """
+
+ changePasswordOnNextLogin = zope.schema.Bool(
+ title=_(u'Password must be changed on next login'),
+ description=_(u'Password must be changed on next login'),
+ required=False,
+ default=False)
+
+ passwordExpiresAfter = zope.schema.Int(
+ title=_(u'Password expires after (days)'),
+ description=_(u'Password expires after (days)'),
+ required=False,
+ default=None)
+
+ lockOutPeriod = zope.schema.Int(
+ title=_(u'Lockout period (minutes)'),
+ description=_(u'Lockout the user after too many failed password entries'
+ 'for this many minutes. The user can try again after.'),
+ required=False,
+ default=None)
+
+ maxFailedAttempts = zope.schema.Int(
+ title=_(u'Max. number of failed password entries before account is locked'),
+ description=_(u'Specifies the amount of failed attempts allowed to check '
+ 'the password before the password is locked and no new '
+ 'password can be provided.'),
+ required=False,
+ default=None)
Modified: z3c.password/trunk/src/z3c/password/principal.py
===================================================================
--- z3c.password/trunk/src/z3c/password/principal.py 2009-06-17 10:29:25 UTC (rev 101099)
+++ z3c.password/trunk/src/z3c/password/principal.py 2009-06-17 10:32:18 UTC (rev 101100)
@@ -17,6 +17,7 @@
"""
__docformat__ = "reStructuredText"
import datetime
+import zope.component
from z3c.password import interfaces
class PrincipalMixIn(object):
@@ -24,10 +25,14 @@
principal."""
passwordExpiresAfter = None
- maxFailedAttempts = None
+ passwordSetOn = None
+ passwordExpired = False #force PasswordExpired,
+ #e.g. for changePasswordOnNextLogin
- passwordSetOn = None
failedAttempts = 0
+ maxFailedAttempts = None
+ lastFailedAttempt = None
+ lockOutPeriod = None
def getPassword(self):
return super(PrincipalMixIn, self).getPassword()
@@ -36,6 +41,8 @@
super(PrincipalMixIn, self).setPassword(password, passwordManagerName)
self.passwordSetOn = self.now()
self.failedAttempts = 0
+ self.lastFailedAttempt = None
+ self.passwordExpired = False
password = property(getPassword, setPassword)
@@ -44,25 +51,128 @@
return datetime.datetime.now()
def checkPassword(self, pwd, ignoreExpiration=False, ignoreFailures=False):
+ # keep this as fast as possible, because it will be called (usually)
+ # for EACH request
+
# Check the password
same = super(PrincipalMixIn, self).checkPassword(pwd)
- # If this was a failed attempt, record it, otherwise reset the failures
- if same and self.failedAttempts != 0:
- self.failedAttempts = 0
- if not same:
- self.failedAttempts += 1
- # If the maximum amount of failures has been reached notify the system
- # by raising an error.
- if not ignoreFailures and self.maxFailedAttempts is not None:
- if (self.maxFailedAttempts and
- self.failedAttempts > self.maxFailedAttempts):
- raise interfaces.TooManyLoginFailures(self)
+ if not ignoreFailures and self.lastFailedAttempt is not None:
+ if self.tooManyLoginFailures():
+ locked = self.accountLocked()
+ if locked is None:
+ #no lockPeriod
+ pass
+ elif locked:
+ #account locked by tooManyLoginFailures and within lockPeriod
+ if not same:
+ self.lastFailedAttempt = self.now()
+ raise interfaces.AccountLocked(self)
+ else:
+ #account locked by tooManyLoginFailures and out of lockPeriod
+ self.failedAttempts = 0
+ self.lastFailedAttempt = None
+
if same:
- # Make sure the password has not been expired
- if not ignoreExpiration and self.passwordExpiresAfter is not None:
- expirationDate = self.passwordSetOn + self.passwordExpiresAfter
- if expirationDate < self.now():
+ #successful attempt
+ if not ignoreExpiration:
+ if self.passwordExpired:
raise interfaces.PasswordExpired(self)
+ # Make sure the password has not been expired
+ expiresOn = self.passwordExpiresOn()
+ if expiresOn is not None:
+ if expiresOn < self.now():
+ raise interfaces.PasswordExpired(self)
+ add = 0
+ else:
+ #failed attempt, record it, increase counter
+ self.failedAttempts += 1
+ self.lastFailedAttempt = self.now()
+ add = 1
+
+ # If the maximum amount of failures has been reached notify the
+ # system by raising an error.
+ if not ignoreFailures:
+ if self.tooManyLoginFailures(add):
+ raise interfaces.TooManyLoginFailures(self)
+
+ if same and self.failedAttempts != 0:
+ #if all nice and good clear failure counter
+ self.failedAttempts = 0
+ self.lastFailedAttempt = None
+
return same
+
+ def tooManyLoginFailures(self, add = 0):
+ attempts = self._maxFailedAttempts()
+ #this one needs to be >=, because... data just does not
+ #get saved on an exception when running under of a full Zope env.
+ #the dance around ``add`` has the same roots
+ #we need to be able to increase the failedAttempts count and not raise
+ #at the same time
+ if attempts is not None:
+ attempts += add
+ if self.failedAttempts >= attempts:
+ return True
+ return False
+
+ def accountLocked(self):
+ lockPeriod = self._lockOutPeriod()
+ if lockPeriod is not None:
+ #check if the user locked himself
+ if (self.lastFailedAttempt is not None
+ and self.lastFailedAttempt + lockPeriod > self.now()):
+ return True
+ else:
+ return False
+ return None
+
+ def passwordExpiresOn(self):
+ expires = self._passwordExpiresAfter()
+ if expires is None:
+ return None
+ return self.passwordSetOn + expires
+
+ def _optionsUtility(self):
+ return zope.component.queryUtility(
+ interfaces.IPasswordOptionsUtility, default=None)
+
+ def _passwordExpiresAfter(self):
+ if self.passwordExpiresAfter is not None:
+ return self.passwordExpiresAfter
+
+ options = self._optionsUtility()
+ if options is None:
+ return self.passwordExpiresAfter
+ else:
+ if options.passwordExpiresAfter is not None:
+ return datetime.timedelta(days=options.passwordExpiresAfter)
+ else:
+ return self.passwordExpiresAfter
+
+ def _lockOutPeriod(self):
+ if self.lockOutPeriod is not None:
+ return self.lockOutPeriod
+
+ options = self._optionsUtility()
+ if options is None:
+ return self.lockOutPeriod
+ else:
+ if options.lockOutPeriod is not None:
+ return datetime.timedelta(minutes=options.lockOutPeriod)
+ else:
+ return self.lockOutPeriod
+
+ def _maxFailedAttempts(self):
+ if self.maxFailedAttempts is not None:
+ return self.maxFailedAttempts
+
+ options = self._optionsUtility()
+ if options is None:
+ return self.maxFailedAttempts
+ else:
+ if options.maxFailedAttempts is not None:
+ return options.maxFailedAttempts
+ else:
+ return self.maxFailedAttempts
\ No newline at end of file
Copied: z3c.password/trunk/src/z3c/password/principal.txt (from rev 101085, z3c.password/branches/adamg-options/src/z3c/password/principal.txt)
===================================================================
--- z3c.password/trunk/src/z3c/password/principal.txt (rev 0)
+++ z3c.password/trunk/src/z3c/password/principal.txt 2009-06-17 10:32:18 UTC (rev 101100)
@@ -0,0 +1,627 @@
+============================
+Advnaced Password Management
+============================
+
+The Principal Mix-in
+--------------------
+
+The principal mixin is a quick and functional example on how to use the
+password utility and field. The mix-in class defines the following additional
+attributes:
+
+- ``passwordExpiresAfter``
+
+ A time delta object that describes for how long the password is valid before
+ a new one has to be specified. If ``None``, the password will never expire.
+
+- ``passwordSetOn``
+
+ The date/time at which the password was last set. This value is used to
+ determine the expiration of a password.
+
+- ``passwordExpired``
+
+ Boolean. If set to True raises PasswordExpired regardless of it's expired by
+ passwordExpiresAfter. Gets reset by setPassword.
+ Handy feature to implement 'Password must be changed on next login'
+
+- ``maxFailedAttempts``
+
+ An integer specifying the amount of failed attempts allowed to check the
+ password before the password is locked and no new password can be provided.
+ (or lockOutPeriod kicks in)
+
+- ``failedAttempts``
+
+ This is a counter that keeps track of the amount of failed login attempts
+ since the last successful one. This value is used to determine when to lock
+ the account after the maximum amount of failures has been reached.
+
+- ``lastFailedAttempt``
+
+ The date/time at which the failedAttempts last bad password was entered.
+ Used to implement automatic account lock after too many login failures.
+ Cleared when a good password is entered.
+
+- ``lockOutPeriod``
+
+ A time delta object after the user can try again after too many login
+ failures.
+ If ``None`` login will enabled by a correct password.
+
+There is the IPasswordOptionsUtility utility, with which you can provide
+options for some features.
+Strategy is that if the same option/property exists on the principal
+and it's not None then the principal's property takes priority.
+
+- ``changePasswordOnNextLogin``
+
+ Set to True if the principal has to change it's password on next login.
+ Not implemented, because it's not that easy. Thoug added to the utility
+ to keep thing together. Use the passwordExpired property of the principal.
+
+- ``passwordExpiresAfter``
+
+ Number of days (integer!) after the password expires.
+ Describes for how long the password is valid before
+ a new one has to be specified. If ``None``, the password will never expire.
+ (integer because it's easier to build a UI for an integer)
+
+- ``lockOutPeriod``
+
+ Number of minutes (integer!) after the user can try again after too many login
+ failures.
+ If ``None`` login will enabled by a correct password.
+
+- ``maxFailedAttempts``
+
+ An integer specifying the amount of failed attempts allowed to check the
+ password before the password is locked and no new password can be provided.
+ (or lockOutPeriod kicks in)
+
+
+Let's now create a principal:
+
+ >>> from zope.app.authentication import principalfolder
+ >>> from z3c.password import principal
+
+ >>> class MyPrincipal(principal.PrincipalMixIn,
+ ... principalfolder.InternalPrincipal):
+ ... #override the now function to feed in the datetime
+ ... def now(self):
+ ... return NOW
+
+Nail the date:
+
+ >>> import datetime
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)
+
+ >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+
+Since the password has been immediately set, the ```passwordSetOn`` attribute
+should have a value:
+
+ >>> user.passwordSetOn
+ datetime.datetime(2009, 6, 14, 13, 0)
+
+The good password validates fine:
+
+ >>> user.checkPassword('123123')
+ True
+
+Initially, the amount of failed attempts is zero, ...
+
+ >>> user.failedAttempts
+ 0
+
+but after checking the password incorrectly, the value is updated:
+
+ >>> user.checkPassword('456456')
+ False
+ >>> user.failedAttempts
+ 1
+
+Initially there is no constraint on user, but let's add some:
+
+ >>> user.passwordExpiresAfter
+ >>> user.passwordExpiresAfter = datetime.timedelta(180)
+
+ >>> user.maxFailedAttempts
+ >>> user.maxFailedAttempts = 3
+
+Let's now provide the incorrect password a couple more times:
+
+ >>> user.checkPassword('456456')
+ False
+ >>> user.checkPassword('456456')
+ False
+ >>> user.checkPassword('456456')
+ Traceback (most recent call last):
+ ...
+ TooManyLoginFailures: The password was entered incorrectly too often.
+
+As you can see, once the maximum mount of attempts is reached, the system does
+not allow you to log in at all anymore.
+
+ >>> user.checkPassword('123123')
+ Traceback (most recent call last):
+ ...
+ TooManyLoginFailures: The password was entered incorrectly too often.
+
+At this point the password has to be reset otherwise.
+However, you can tell the ``check()`` method explicitly to
+ignore the failure count:
+
+ >>> user.checkPassword('456456', ignoreFailures=True)
+ False
+
+Let's now reset the failure count.
+
+ >>> user.failedAttempts = 0
+
+Next we expire the password:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0) + datetime.timedelta(181)
+
+A corresponding exception should be raised:
+
+ >>> user.checkPassword('456456')
+ False
+
+Not yet, because the password did not match.
+
+Once we match the password it is raised:
+
+ >>> user.checkPassword('123123')
+ Traceback (most recent call last):
+ ...
+ PasswordExpired: The password has expired.
+
+Like for the too-many-failures exception above, you can explicitely turn off
+the expiration check:
+
+ >>> user.checkPassword('456456', ignoreExpiration=True)
+ False
+
+If we set the password, the user can login again:
+
+ >>> user.setPassword('234234')
+
+ >>> user.checkPassword('234234')
+ True
+
+It is the responsibility of the presentation code to provide views for those
+two exceptions. For the latter, it is common to allow the user to enter a new
+password after providing the old one as verification.
+
+
+To check the new features we need a utility that provides the options.
+
+ >>> import zope.interface
+ >>> import zope.component
+ >>> from z3c.password import interfaces
+ >>> class PasswordOptionsUtility(object):
+ ... zope.interface.implements(interfaces.IPasswordOptionsUtility)
+ ...
+ ... changePasswordOnNextLogin = False
+ ... passwordExpiresAfter = None
+ ... lockOutPeriod = None
+ ... maxFailedAttempts = None
+
+ >>> poptions = PasswordOptionsUtility()
+ >>> zope.component.provideUtility(poptions)
+
+
+Expire password on next login
+-----------------------------
+
+``IPasswordOptionsUtility`` ``changePasswordOnNextLogin``
+
+We do not directly support this option, because it's not possible to tell
+when to set this flag.
+We provide the ``passwordExpired`` property on the PrincipalMixIn for this.
+
+ >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+
+While it's False, the user can login as usual.
+
+ >>> user.passwordExpired
+ False
+
+ >>> user.checkPassword('123123')
+ True
+
+When the admin sets it to True, the PasswordExpired exception will be raised.
+
+ >>> user.passwordExpired = True
+
+ >>> user.checkPassword('123123')
+ Traceback (most recent call last):
+ ...
+ PasswordExpired: The password has expired.
+
+At this point your application should provide a form to change the user's
+password.
+
+When the user sets the password, the flag gets reset.
+
+ >>> user.setPassword('456456')
+
+ >>> user.passwordExpired
+ False
+
+And the user can login again.
+
+ >>> user.checkPassword('456456')
+ True
+
+
+Password expiration
+-------------------
+
+``IPasswordOptionsUtility`` ``passwordExpiresAfter``
+
+With this option password expiration can be set globally.
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)
+
+ >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+
+Set it at 180 days:
+
+ >>> poptions.passwordExpiresAfter = 180
+
+While we're within the 180 days the user can login:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0) + datetime.timedelta(days=1)
+ >>> user.checkPassword('123123')
+ True
+
+Once we go behind 180 days he can't:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0) + datetime.timedelta(days=181)
+ >>> user.checkPassword('123123')
+ Traceback (most recent call last):
+ ...
+ PasswordExpired: The password has expired.
+
+Unless we override on the principal itself:
+
+ >>> user.passwordExpiresAfter = datetime.timedelta(days=365)
+ >>> user.checkPassword('123123')
+ True
+
+Setting the property to None will use the globals again:
+
+ >>> user.passwordExpiresAfter = None
+ >>> user.checkPassword('123123')
+ Traceback (most recent call last):
+ ...
+ PasswordExpired: The password has expired.
+
+After setting the password again it's all good:
+
+ >>> user.setPassword('234234')
+ >>> user.checkPassword('234234')
+ True
+
+
+Max. failed attempts
+--------------------
+
+``IPasswordOptionsUtility`` ``maxFailedAttempts``
+
+With this option the amount of failed attempts allowed to check the password
+before the password is locked and no new password can be set globally.
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)
+
+ >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+
+Set the count at 3 attempts:
+
+ >>> poptions.maxFailedAttempts = 3
+
+Initially, the amount of failed attempts is zero, ...
+
+ >>> user.failedAttempts
+ 0
+
+but after checking the password incorrectly, the value is updated:
+
+ >>> user.checkPassword('456456')
+ False
+ >>> user.failedAttempts
+ 1
+
+Let's now provide the incorrect password a couple more times:
+
+ >>> user.checkPassword('456456')
+ False
+ >>> user.checkPassword('456456')
+ False
+
+On the 4th bad try we get the exception:
+
+ >>> user.checkPassword('456456')
+ Traceback (most recent call last):
+ ...
+ TooManyLoginFailures: The password was entered incorrectly too often.
+
+Unless we override on the principal itself:
+
+ >>> user.maxFailedAttempts = 10
+ >>> user.checkPassword('456456')
+ False
+
+Setting the property back to None will use the globals again:
+
+ >>> user.maxFailedAttempts = None
+ >>> user.checkPassword('456456')
+ Traceback (most recent call last):
+ ...
+ TooManyLoginFailures: The password was entered incorrectly too often.
+
+It does not matter if we go ahead in time:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0) + datetime.timedelta(days=365)
+ >>> user.checkPassword('456456')
+ Traceback (most recent call last):
+ ...
+ TooManyLoginFailures: The password was entered incorrectly too often.
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0) + datetime.timedelta(days=1)
+
+The user cannot login again with the right password either:
+
+ >>> user.checkPassword('123123')
+ Traceback (most recent call last):
+ ...
+ TooManyLoginFailures: The password was entered incorrectly too often.
+
+The admin(?) has to reset the password of the user.
+
+ >>> user.password = '234234'
+
+ >>> user.checkPassword('234234')
+ True
+
+
+Timed lockout
+-------------
+
+``IPasswordOptionsUtility`` ``lockOutPeriod``
+
+Use this option together with ``maxFailedAttempts``.
+With this option the number of minutes for which the user will be locked
+can be set globally.
+Once this option is set, the user will be unable to login even if he hits
+the correct password before the specified time passes.
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)
+
+ >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+
+maxFailedAttempts is still at 3:
+
+ >>> poptions.maxFailedAttempts
+ 3
+
+Set lockOutPeriod to 60 minutes:
+
+ >>> poptions.lockOutPeriod = 60
+
+Bang on with the bad password:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=1)
+ >>> user.checkPassword('456456')
+ False
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=2)
+ >>> user.checkPassword('456456')
+ False
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=3)
+ >>> user.checkPassword('456456')
+ False
+
+The timestamp of the last bad try is recorded:
+
+ >>> user.lastFailedAttempt
+ datetime.datetime(2009, 6, 14, 13, 3)
+
+The user cannot login within the next 60 minutes.
+
+Be it with the bad password:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=15)
+
+ >>> user.checkPassword('456456')
+ Traceback (most recent call last):
+ ...
+ AccountLocked: The account is locked, because the password was entered incorrectly too often.
+
+BTW, beating on the bad password starts the 60 minutes again:
+
+ >>> user.lastFailedAttempt
+ datetime.datetime(2009, 6, 14, 13, 15)
+
+Where the good password just does not let the user in:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=30)
+
+ >>> user.checkPassword('123123')
+ Traceback (most recent call last):
+ ...
+ AccountLocked: The account is locked, because the password was entered incorrectly too often.
+
+ >>> user.lastFailedAttempt
+ datetime.datetime(2009, 6, 14, 13, 15)
+
+The user has to wait, till the time has passed:
+(remember the last bad try was at +15mins, so we need to wait until +76mins)
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=76)
+
+ >>> user.checkPassword('123123')
+ True
+
+The good login resets all the properties:
+
+ >>> user.lastFailedAttempt
+ >>> user.failedAttempts
+ 0
+
+
+The same works if the lockOutPeriod is set on the principal:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)
+
+ >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+
+maxFailedAttempts is still at 3:
+
+ >>> poptions.maxFailedAttempts
+ 3
+
+Set lockOutPeriod to 60 minutes, but on the principal we have to set a timedelta:
+
+ >>> poptions.lockOutPeriod = None
+ >>> user.lockOutPeriod = datetime.timedelta(minutes=60)
+
+Bang on with the bad password:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=1)
+ >>> user.checkPassword('456456')
+ False
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=2)
+ >>> user.checkPassword('456456')
+ False
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=3)
+ >>> user.checkPassword('456456')
+ False
+
+The timestamp of the last bad try is recorded:
+
+ >>> user.lastFailedAttempt
+ datetime.datetime(2009, 6, 14, 13, 3)
+
+The user cannot login within the next 60 minutes.
+
+Be it with the bad password:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=15)
+
+ >>> user.checkPassword('456456')
+ Traceback (most recent call last):
+ ...
+ AccountLocked: The account is locked, because the password was entered incorrectly too often.
+
+BTW, beating on the bad password starts the 60 minutes again:
+
+ >>> user.lastFailedAttempt
+ datetime.datetime(2009, 6, 14, 13, 15)
+
+Where the good password just does not let the user in:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=30)
+
+ >>> user.checkPassword('123123')
+ Traceback (most recent call last):
+ ...
+ AccountLocked: The account is locked, because the password was entered incorrectly too often.
+
+ >>> user.lastFailedAttempt
+ datetime.datetime(2009, 6, 14, 13, 15)
+
+The user has to wait, till the time has passed:
+(remember the last bad try was at +15mins, so we need to wait until +76mins)
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=76)
+
+ >>> user.checkPassword('123123')
+ True
+
+The good login resets all the properties:
+
+ >>> user.lastFailedAttempt
+ >>> user.failedAttempts
+ 0
+
+
+Edge case.
+The number of bad login tries has to exceed ``maxFailedAttempts`` within the
+``lockOutPeriod``.
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)
+
+ >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+ >>> user.lockOutPeriod = datetime.timedelta(minutes=60)
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=1)
+ >>> user.checkPassword('456456')
+ False
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=2)
+ >>> user.checkPassword('456456')
+ False
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=3)
+ >>> user.checkPassword('456456')
+ False
+
+ >>> user.lastFailedAttempt
+ datetime.datetime(2009, 6, 14, 13, 3)
+ >>> user.failedAttempts
+ 3
+
+If the user tries again after the lockOutPeriod passed, the ``failedAttempts``
+get reset:
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)+datetime.timedelta(minutes=65)
+ >>> user.checkPassword('456456')
+ False
+
+ >>> user.lastFailedAttempt
+ datetime.datetime(2009, 6, 14, 14, 5)
+ >>> user.failedAttempts
+ 1
+
+
+Edge case.
+When there is no ``maxFailedAttempts`` set, we can bang on with a bad password
+forever.
+
+ >>> poptions.maxFailedAttempts = None
+ >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+ >>> user.maxFailedAttempts = None
+
+ >>> NOW = datetime.datetime(2009, 6, 14, 13, 0)
+
+ >>> for x in xrange(256):
+ ... NOW = datetime.datetime(2009, 6, 14, 13)+datetime.timedelta(minutes=x)
+ ... if user.checkPassword('456456'):
+ ... print 'bug'
+
+
+Coverage happiness
+------------------
+
+ >>> class MyOtherPrincipal(principal.PrincipalMixIn,
+ ... principalfolder.InternalPrincipal):
+ ... pass
+
+ >>> user = MyOtherPrincipal('srichter', '123123', u'Stephan Richter')
+ >>> user.passwordSetOn
+ datetime.datetime(...)
+
+accountLocked should not burp when there was no failure yet:
+
+ >>> user.accountLocked() is None
+ True
+
+ >>> user.lockOutPeriod = 30
+ >>> user.accountLocked()
+ False
Modified: z3c.password/trunk/src/z3c/password/tests.py
===================================================================
--- z3c.password/trunk/src/z3c/password/tests.py 2009-06-17 10:29:25 UTC (rev 101099)
+++ z3c.password/trunk/src/z3c/password/tests.py 2009-06-17 10:32:18 UTC (rev 101100)
@@ -30,6 +30,10 @@
setUp=testing.setUp, tearDown=testing.tearDown,
optionflags=doctest.NORMALIZE_WHITESPACE|doctest.ELLIPSIS,
),
+ DocFileSuite('principal.txt',
+ setUp=testing.setUp, tearDown=testing.tearDown,
+ optionflags=doctest.NORMALIZE_WHITESPACE|doctest.ELLIPSIS,
+ ),
))
if __name__ == '__main__':
More information about the Checkins
mailing list