[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