[Checkins] SVN: z3c.password/trunk/ merge of branch adamg-evenhigher:

Adam Groszer agroszer at gmail.com
Mon Jun 22 04:25:20 EDT 2009


Log message for revision 101210:
  merge of branch adamg-evenhigher:
  - Feature: Even harder password settings:
    * ``minLowerLetter``
    * ``minUpperLetter``
    * ``minDigits``
    * ``minSpecials``
    * ``minOthers``
    * ``minUniqueCharacters``
    * ``minUniqueLetters``: count and do not allow less then specified number
  - Feature:
    * ``disallowPasswordReuse``: do not allow to set a previously used password
  - 100% test coverage

Changed:
  U   z3c.password/trunk/CHANGES.txt
  U   z3c.password/trunk/src/z3c/password/README.txt
  U   z3c.password/trunk/src/z3c/password/field.py
  U   z3c.password/trunk/src/z3c/password/interfaces.py
  U   z3c.password/trunk/src/z3c/password/password.py
  U   z3c.password/trunk/src/z3c/password/principal.py
  U   z3c.password/trunk/src/z3c/password/principal.txt

-=-
Modified: z3c.password/trunk/CHANGES.txt
===================================================================
--- z3c.password/trunk/CHANGES.txt	2009-06-22 07:56:49 UTC (rev 101209)
+++ z3c.password/trunk/CHANGES.txt	2009-06-22 08:25:20 UTC (rev 101210)
@@ -5,8 +5,20 @@
 0.7.0 (unreleased)
 ------------------
 
-- ...
+- Feature: Even harder password settings:
+  * ``minLowerLetter``
+  * ``minUpperLetter``
+  * ``minDigits``
+  * ``minSpecials``
+  * ``minOthers``
+  * ``minUniqueCharacters``
+  * ``minUniqueLetters``: count and do not allow less then specified number
 
+- Feature:
+  * ``disallowPasswordReuse``: do not allow to set a previously used password
+
+- 100% test coverage
+
 0.6.0 (2009-06-17)
 ------------------
 

Modified: z3c.password/trunk/src/z3c/password/README.txt
===================================================================
--- z3c.password/trunk/src/z3c/password/README.txt	2009-06-22 07:56:49 UTC (rev 101209)
+++ z3c.password/trunk/src/z3c/password/README.txt	2009-06-22 08:25:20 UTC (rev 101210)
@@ -153,13 +153,187 @@
   ...     _ =pwd.generate()
 
 
+Even higher security settings
+-----------------------------
 
+We can specify how many of a selected character group we want to have in the
+password.
+
+We want to have at least 5 lowercase letters in the password:
+
+  >>> pwd = password.HighSecurityPasswordUtility(seed=8)
+  >>> pwd.minLowerLetter = 5
+
+  >>> pwd.verify('FOOBAR123')
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('foobAR123')
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('foobaR123')
+
+  >>> pwd.generate()
+  'Us;iwbzM[J'
+
+  >>> pwd.generate()
+  'soXVg[V$uw'
+
+
+We want to have at least 5 uppercase letters in the password:
+
+  >>> pwd = password.HighSecurityPasswordUtility(seed=8)
+  >>> pwd.minUpperLetter = 5
+
+  >>> pwd.verify('foobar123')
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('FOOBar123')
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('fOOBAR123')
+
+  >>> pwd.generate()
+  'OvMPN3Bi'
+
+  >>> pwd.generate()
+  'l:zB.VA at MH'
+
+
+We want to have at least 5 digits in the password:
+
+  >>> pwd = password.HighSecurityPasswordUtility(seed=8)
+  >>> pwd.minDigits = 5
+
+  >>> pwd.verify('foobar123')
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('FOOBa1234')
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('fOBA12345')
+
+  >>> pwd.generate()
+  '(526vK(>Z42v'
+
+  >>> pwd.generate()
+  '3Z&Mtq35Y840'
+
+
+We want to have at least 5 specials in the password:
+
+  >>> pwd = password.HighSecurityPasswordUtility(seed=8)
+  >>> pwd.minSpecials = 5
+
+  >>> pwd.verify('foo(bar)')
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('FO.#(Ba1)')
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('fO.,;()5')
+
+  >>> pwd.generate()
+  '?d{*~2q|P'
+
+  >>> pwd.generate()
+  '(8a5\\(^}vB'
+
+We want to have at least 5 others in the password:
+
+  >>> pwd = password.HighSecurityPasswordUtility(seed=8)
+  >>> pwd.minOthers = 5
+
+  >>> pwd.verify('foobar'+unichr(0x0c3)+unichr(0x0c4))
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('foobar'+unichr(0x0c3)+unichr(0x0c4)+unichr(0x0e1))
+  Traceback (most recent call last):
+  ...
+  TooFewGroupCharacters
+
+  >>> pwd.verify('fOO'+unichr(0x0e1)*5)
+
+
+Generating passwords with others not yet supported
+
+  #>>> pwd.generate()
+  #'?d{*~2q|P'
+  #
+  #>>> pwd.generate()
+  #'(8a5\\(^}vB'
+
+We want to have at least 5 different characters in the password:
+
+  >>> pwd = password.HighSecurityPasswordUtility(seed=8)
+  >>> pwd.minUniqueCharacters = 5
+
+  >>> pwd.verify('foofoo1212')
+  Traceback (most recent call last):
+  ...
+  TooFewUniqueCharacters
+
+  >>> pwd.verify('FOOfoo2323')
+  Traceback (most recent call last):
+  ...
+  TooFewUniqueCharacters
+
+  >>> pwd.verify('fOOBAR123')
+
+  >>> pwd.generate()
+  '{l%ix~t8R'
+
+  >>> pwd.generate()
+  'Us;iwbzM[J'
+
+
+We want to have at least 5 different letters in the password:
+
+  >>> pwd = password.HighSecurityPasswordUtility(seed=8)
+  >>> pwd.minUniqueLetters = 5
+
+  >>> pwd.verify('foofoo1212')
+  Traceback (most recent call last):
+  ...
+  TooFewUniqueLetters
+
+  >>> pwd.verify('FOOBfoob2323')
+  Traceback (most recent call last):
+  ...
+  TooFewUniqueLetters
+
+  >>> pwd.verify('fOOBAR123')
+
+  >>> pwd.generate()
+  '{l%ix~t8R'
+
+  >>> pwd.generate()
+  'Us;iwbzM[J'
+
+
 The Password Field
 ------------------
 
 The password field can be used to specify an advanced password. It extends the
 standard ``zope.schema`` password field with the ``checker`` attribute. The
-checker is either a password utility (as specified above) or the name of sucha
+checker is either a password utility (as specified above) or the name of such a
 a utility. The checker is used to verify whether a password is acceptable or
 not.
 
@@ -169,6 +343,8 @@
   >>> from zope.app.authentication.password import PlainTextPasswordManager
   >>> from z3c.password import field
 
+  >>> pwd = password.HighSecurityPasswordUtility(seed=8)
+
   >>> pwdField = field.Password(
   ...     __name__='password',
   ...     title=u'Password',
@@ -182,3 +358,102 @@
   ...
   TooShortPassword
 
+Validation must work on bound fields too:
+
+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')
+
+Bind the field:
+
+  >>> bound = pwdField.bind(user)
+
+  >>> bound.validate(u'fooBar12')
+  >>> bound.validate(u'fooBar')
+  Traceback (most recent call last):
+  ...
+  TooShortPassword
+
+Let's create a principal without the PrincipalMixIn:
+
+  >>> user = principalfolder.InternalPrincipal('srichter', '123123',
+  ...     u'Stephan Richter')
+
+Bind the field:
+
+  >>> bound = pwdField.bind(user)
+
+  >>> bound.validate(u'fooBar12')
+  >>> bound.validate(u'fooBar')
+  Traceback (most recent call last):
+  ...
+  TooShortPassword
+
+
+Other common usecase is to do a utility and specify it's name as checker.
+
+  >>> import zope.component
+  >>> zope.component.provideUtility(pwd, name='my password checker')
+
+Recreate the field:
+
+  >>> pwdField = field.Password(
+  ...     __name__='password',
+  ...     title=u'Password',
+  ...     checker='my password checker')
+
+Let's validate a value:
+
+  >>> pwdField.validate(u'fooBar12')
+  >>> pwdField.validate(u'fooBar')
+  Traceback (most recent call last):
+  ...
+  TooShortPassword
+
+
+Edge cases.
+
+No checker specified.
+
+  >>> pwdField = field.Password(
+  ...     __name__='password',
+  ...     title=u'Password')
+
+Validation silently succeeds with a checker:
+
+  >>> pwdField.validate(u'fooBar12')
+  >>> pwdField.validate(u'fooBar')
+
+Bad utility name.
+
+  >>> pwdField = field.Password(
+  ...     __name__='password',
+  ...     title=u'Password',
+  ...     checker='foobar password checker')
+
+Burps on the utility lookup as expected:
+
+  >>> pwdField.validate(u'fooBar12')
+  Traceback (most recent call last):
+  ...
+  ComponentLookupError:...
+
+Bound object does not have the property:
+
+  >>> pwdField = field.Password(
+  ...     __name__='foobar',
+  ...     title=u'Password',
+  ...     checker=pwd)
+
+  >>> bound = pwdField.bind(user)
+
+Validation silently succeeds:
+
+  >>> bound.validate(u'fooBar12')
\ No newline at end of file

Modified: z3c.password/trunk/src/z3c/password/field.py
===================================================================
--- z3c.password/trunk/src/z3c/password/field.py	2009-06-22 07:56:49 UTC (rev 101209)
+++ z3c.password/trunk/src/z3c/password/field.py	2009-06-22 08:25:20 UTC (rev 101210)
@@ -30,7 +30,7 @@
     def checker(self):
         if self._checker is None:
             return None
-        if not isinstance(self._checker, (str, unicode)):
+        if not isinstance(self._checker, basestring):
             return self._checker
         return zope.component.getUtility(
             interfaces.IPasswordUtility, self._checker)
@@ -43,4 +43,15 @@
                 old = self.get(self.context)
             except AttributeError:
                 pass
-        self.checker.verify(value, old)
+        checker = self.checker
+        if checker is not None:
+            self.checker.verify(value, old)
+
+        #try to check for disallowPasswordReuse here too, to raise
+        #problems ASAP
+        if self.context is not None:
+            try:
+                self.context._checkDisallowedPreviousPassword(value)
+            except AttributeError:
+                #if _checkDisallowedPreviousPassword is missing
+                pass

Modified: z3c.password/trunk/src/z3c/password/interfaces.py
===================================================================
--- z3c.password/trunk/src/z3c/password/interfaces.py	2009-06-22 07:56:49 UTC (rev 101209)
+++ z3c.password/trunk/src/z3c/password/interfaces.py	2009-06-22 08:25:20 UTC (rev 101210)
@@ -40,6 +40,15 @@
 class TooManyGroupCharacters(InvalidPassword):
     __doc__ = _('''Password contains too many characters of one group.''')
 
+class TooFewGroupCharacters(InvalidPassword):
+    __doc__ = _('''Password does not contain enough characters of one group.''')
+
+class TooFewUniqueCharacters(InvalidPassword):
+    __doc__ = _('''Password does not contain enough unique characters.''')
+
+class TooFewUniqueLetters(InvalidPassword):
+    __doc__ = _('''Password does not contain enough unique letters.''')
+
 class PasswordExpired(Exception):
     __doc__ = _('''The password has expired.''')
 
@@ -47,6 +56,13 @@
         self.principal = principal
         Exception.__init__(self, self.__doc__)
 
+class PreviousPasswordNotAllowed(InvalidPassword):
+    __doc__ = _('''The password set was already used before.''')
+
+    def __init__(self, principal):
+        self.principal = principal
+        Exception.__init__(self, self.__doc__)
+
 class TooManyLoginFailures(Exception):
     __doc__ = _('''The password was entered incorrectly too often.''')
 
@@ -117,9 +133,9 @@
     @zope.interface.invariant
     def minMaxLength(task):
         if task.minLength is not None and task.maxLength is not None:
-            if task.minLength > task.minLength:
+            if task.minLength > task.maxLength:
                 raise zope.interface.Invalid(
-                    u"Minimum length must be greater than the maximum length.")
+                    u"Minimum length must not be greater than the maximum length.")
 
     groupMax = zope.schema.Int(
         title=_(u'Maximum Characters of Group'),
@@ -135,7 +151,123 @@
         required=False,
         default=None)
 
+    minLowerLetter = zope.schema.Int(
+        title=_(u'Minimum Number of Lowercase letters'),
+        description=_(u'The minimum amount of lowercase letters that a '
+                      u'password must have.'),
+        required=False,
+        default=None)
 
+    minUpperLetter = zope.schema.Int(
+        title=_(u'Minimum Number of Uppercase letters'),
+        description=_(u'The minimum amount of uppercase letters that a '
+                      u'password must have.'),
+        required=False,
+        default=None)
+
+    minDigits = zope.schema.Int(
+        title=_(u'Minimum Number of Numeric digits'),
+        description=_(u'The minimum amount of numeric digits that a '
+                      u'password must have.'),
+        required=False,
+        default=None)
+
+    minSpecials = zope.schema.Int(
+        title=_(u'Minimum Number of Special characters'),
+        description=_(u'The minimum amount of special characters that a '
+                      u'password must have.'),
+        required=False,
+        default=None)
+
+    #WARNING! generating a password with Others is not yet supported
+    minOthers = zope.schema.Int(
+        title=_(u'Minimum Number of Other characters'),
+        description=_(u'The minimum amount of other characters that a '
+                      u'password must have.'),
+        required=False,
+        default=None)
+
+    @zope.interface.invariant
+    def saneMinimums(task):
+        minl = 0
+        if task.minLowerLetter:
+            if task.minLowerLetter > task.groupMax:
+                raise zope.interface.Invalid(
+                    u"Any group minimum length must NOT be greater than "
+                    u"the maximum group length.")
+
+            minl += task.minLowerLetter
+        if task.minUpperLetter:
+            if task.minUpperLetter > task.groupMax:
+                raise zope.interface.Invalid(
+                    u"Any group minimum length must NOT be greater than "
+                    u"the maximum group length.")
+
+            minl += task.minUpperLetter
+        if task.minDigits:
+            if task.minDigits > task.groupMax:
+                raise zope.interface.Invalid(
+                    u"Any group minimum length must NOT be greater than "
+                    u"the maximum group length.")
+
+            minl += task.minDigits
+        if task.minSpecials:
+            if task.minSpecials > task.groupMax:
+                raise zope.interface.Invalid(
+                    u"Any group minimum length must NOT be greater than "
+                    u"the maximum group length.")
+
+            minl += task.minSpecials
+        if task.minOthers:
+            if task.minOthers > task.groupMax:
+                raise zope.interface.Invalid(
+                    u"Any group minimum length must NOT be greater than "
+                    u"the maximum group length.")
+
+            minl += task.minOthers
+
+        if task.maxLength is not None:
+            if minl > task.maxLength:
+                raise zope.interface.Invalid(
+                    u"Sum of group minimum lengths must NOT be greater than "
+                    u"the maximum password length.")
+
+    minUniqueLetters = zope.schema.Int(
+        title=_(u'Minimum Number of Unique letters'),
+        description=_(u'The minimum amount of unique letters that a '
+                      u'password must have. This is against passwords '
+                      u'like `aAaA0000`. All characters taken lowercase.'),
+        required=False,
+        default=None)
+
+    @zope.interface.invariant
+    def minUniqueLettersLength(task):
+        if (task.minUniqueLetters is not None
+            and task.minUniqueLetters is not None):
+            if task.minUniqueLetters > task.maxLength:
+                raise zope.interface.Invalid(
+                    u"Minimum unique letters number must not be greater than "
+                    u"the maximum length.")
+
+    minUniqueCharacters = zope.schema.Int(
+        title=_(u'Minimum Number of Unique characters'),
+        description=_(u'The minimum amount of unique characters that a '
+                      u'password must have. This is against passwords '
+                      u'like `aAaA0000`. All characters taken lowercase.'),
+        required=False,
+        default=None)
+
+    @zope.interface.invariant
+    def minUniqueCharactersLength(task):
+        if (task.minUniqueCharacters is not None
+            and task.minUniqueCharacters is not None):
+            if task.minUniqueCharacters > task.maxLength:
+                raise zope.interface.Invalid(
+                    u"Minimum unique characters length must not be greater than "
+                    u"the maximum length.")
+
+
+
 class IPasswordOptionsUtility(zope.interface.Interface):
     """Different general security options.
 
@@ -169,3 +301,9 @@
                       'password can be provided.'),
         required=False,
         default=None)
+
+    disallowPasswordReuse = zope.schema.Bool(
+        title=_(u'Disallow Password Reuse'),
+        description=_(u'Do not allow to set a previously set password again.'),
+        required=False,
+        default=False)

Modified: z3c.password/trunk/src/z3c/password/password.py
===================================================================
--- z3c.password/trunk/src/z3c/password/password.py	2009-06-22 07:56:49 UTC (rev 101209)
+++ z3c.password/trunk/src/z3c/password/password.py	2009-06-22 08:25:20 UTC (rev 101210)
@@ -54,6 +54,20 @@
         interfaces.IHighSecurityPasswordUtility['groupMax'])
     maxSimilarity = FieldProperty(
         interfaces.IHighSecurityPasswordUtility['maxSimilarity'])
+    minLowerLetter = FieldProperty(
+        interfaces.IHighSecurityPasswordUtility['minLowerLetter'])
+    minUpperLetter = FieldProperty(
+        interfaces.IHighSecurityPasswordUtility['minUpperLetter'])
+    minDigits = FieldProperty(
+        interfaces.IHighSecurityPasswordUtility['minDigits'])
+    minSpecials = FieldProperty(
+        interfaces.IHighSecurityPasswordUtility['minSpecials'])
+    minOthers = FieldProperty(
+        interfaces.IHighSecurityPasswordUtility['minOthers'])
+    minUniqueCharacters = FieldProperty(
+        interfaces.IHighSecurityPasswordUtility['minUniqueCharacters'])
+    minUniqueLetters = FieldProperty(
+        interfaces.IHighSecurityPasswordUtility['minUniqueLetters'])
 
     LOWERLETTERS = string.letters[:26]
     UPPERLETTERS = string.letters[26:]
@@ -65,13 +79,28 @@
                    u'for more details.')
 
     def __init__(self, minLength=8, maxLength=12, groupMax=6,
-                 maxSimilarity=0.6, seed=None):
+                 maxSimilarity=0.6, seed=None,
+                 minLowerLetter=None, minUpperLetter=None, minDigits=None,
+                 minSpecials=None, minOthers=None,
+                 minUniqueCharacters=None, minUniqueLetters=None):
         self.minLength = minLength
         self.maxLength = maxLength
         self.groupMax = groupMax
         self.maxSimilarity = maxSimilarity
         self.random = random.Random(seed or time.time())
+        self.minLowerLetter = minLowerLetter
+        self.minUpperLetter = minUpperLetter
+        self.minDigits = minDigits
+        self.minSpecials = minSpecials
+        self.minOthers = minOthers
+        self.minUniqueCharacters = minUniqueCharacters
+        self.minUniqueLetters = minUniqueLetters
 
+    def _checkSimilarity(self, new, ref):
+        sm = difflib.SequenceMatcher(None, new, ref)
+        if sm.ratio() > self.maxSimilarity:
+            raise interfaces.TooSimilarPassword()
+
     def verify(self, new, ref=None):
         '''See interfaces.IHighSecurityPasswordUtility'''
         # 0. Make sure we got a password.
@@ -85,20 +114,23 @@
         # 2. Ensure that the password is sufficiently different to the old
         #    one.
         if ref is not None:
-            sm = difflib.SequenceMatcher(None, new, ref)
-            if sm.ratio() > self.maxSimilarity:
-                raise interfaces.TooSimilarPassword()
+            self._checkSimilarity(new, ref)
         # 3. Ensure that the password's character set is complex enough.
         num_lower_letters = 0
         num_upper_letters = 0
         num_digits = 0
         num_specials = 0
         num_others = 0
+        uniqueChars = set()
+        uniqueLetters = set()
         for char in new:
+            uniqueChars.add(char.lower())
             if char in self.LOWERLETTERS:
                 num_lower_letters += 1
+                uniqueLetters.add(char.lower())
             elif char in self.UPPERLETTERS:
                 num_upper_letters += 1
+                uniqueLetters.add(char.lower())
             elif char in self.DIGITS:
                 num_digits += 1
             elif char in self.SPECIALS:
@@ -111,6 +143,35 @@
             num_specials > self.groupMax or
             num_others > self.groupMax):
             raise interfaces.TooManyGroupCharacters()
+
+        if (self.minLowerLetter is not None
+            and num_lower_letters < self.minLowerLetter):
+            raise interfaces.TooFewGroupCharacters()
+
+        if (self.minUpperLetter is not None
+            and num_upper_letters < self.minUpperLetter):
+            raise interfaces.TooFewGroupCharacters()
+
+        if (self.minDigits is not None
+            and num_digits < self.minDigits):
+            raise interfaces.TooFewGroupCharacters()
+
+        if (self.minSpecials is not None
+            and num_specials < self.minSpecials):
+            raise interfaces.TooFewGroupCharacters()
+
+        if (self.minOthers is not None
+            and num_others < self.minOthers):
+            raise interfaces.TooFewGroupCharacters()
+
+        if (self.minUniqueCharacters is not None
+            and len(uniqueChars) < self.minUniqueCharacters):
+            raise interfaces.TooFewUniqueCharacters()
+
+        if (self.minUniqueLetters is not None
+            and len(uniqueLetters) < self.minUniqueLetters):
+            raise interfaces.TooFewUniqueLetters()
+
         return
 
     def generate(self, ref=None):
@@ -120,11 +181,14 @@
             new = ''
             # Determine the length of the password
             length = self.random.randint(self.minLength, self.maxLength)
+
             # Generate the password
             chars = self.LOWERLETTERS + self.UPPERLETTERS + \
                     self.DIGITS + self.SPECIALS
+
             for count in xrange(length):
                 new += self.random.choice(chars)
+
             # Verify the new password
             try:
                 self.verify(new, ref)
@@ -133,3 +197,28 @@
             else:
                 verified = True
         return new
+
+class PasswordOptionsUtility(object):
+    """An implementation of the security options."""
+    zope.interface.implements(interfaces.IPasswordOptionsUtility)
+
+    changePasswordOnNextLogin = FieldProperty(
+        interfaces.IPasswordOptionsUtility['changePasswordOnNextLogin'])
+    passwordExpiresAfter = FieldProperty(
+        interfaces.IPasswordOptionsUtility['passwordExpiresAfter'])
+    lockOutPeriod = FieldProperty(
+        interfaces.IPasswordOptionsUtility['lockOutPeriod'])
+    maxFailedAttempts = FieldProperty(
+        interfaces.IPasswordOptionsUtility['maxFailedAttempts'])
+    disallowPasswordReuse = FieldProperty(
+        interfaces.IPasswordOptionsUtility['disallowPasswordReuse'])
+
+    def __init__(self, changePasswordOnNextLogin=None,
+                 passwordExpiresAfter=None,
+                 lockOutPeriod=None, maxFailedAttempts=None,
+                 disallowPasswordReuse=None):
+        self.changePasswordOnNextLogin = changePasswordOnNextLogin
+        self.passwordExpiresAfter = passwordExpiresAfter
+        self.lockOutPeriod = lockOutPeriod
+        self.maxFailedAttempts = maxFailedAttempts
+        self.disallowPasswordReuse = disallowPasswordReuse
\ No newline at end of file

Modified: z3c.password/trunk/src/z3c/password/principal.py
===================================================================
--- z3c.password/trunk/src/z3c/password/principal.py	2009-06-22 07:56:49 UTC (rev 101209)
+++ z3c.password/trunk/src/z3c/password/principal.py	2009-06-22 08:25:20 UTC (rev 101210)
@@ -17,6 +17,7 @@
 """
 __docformat__ = "reStructuredText"
 import datetime
+import persistent.list
 import zope.component
 from z3c.password import interfaces
 
@@ -34,16 +35,40 @@
     lastFailedAttempt = None
     lockOutPeriod = None
 
+    disallowPasswordReuse = None
+    previousPasswords = None
+
+    def _checkDisallowedPreviousPassword(self, password):
+        if self._disallowPasswordReuse():
+            if self.previousPasswords is not None:
+                #hack, but this should work with zope.app.authentication and
+                #z3c.authenticator
+                passwordManager = self._getPasswordManager()
+
+                for pwd in self.previousPasswords:
+                    if passwordManager.checkPassword(pwd, password):
+                        raise interfaces.PreviousPasswordNotAllowed(self)
+
     def getPassword(self):
         return super(PrincipalMixIn, self).getPassword()
 
     def setPassword(self, password, passwordManagerName=None):
+        self._checkDisallowedPreviousPassword(password)
+
         super(PrincipalMixIn, self).setPassword(password, passwordManagerName)
+
+        if self._disallowPasswordReuse():
+            if self.previousPasswords is None:
+                self.previousPasswords = persistent.list.PersistentList()
+
+            self.previousPasswords.append(self.password)
+
         self.passwordSetOn = self.now()
         self.failedAttempts = 0
         self.lastFailedAttempt = None
         self.passwordExpired = False
 
+
     password = property(getPassword, setPassword)
 
     def now(self):
@@ -175,4 +200,17 @@
             if options.maxFailedAttempts is not None:
                 return options.maxFailedAttempts
             else:
-                return self.maxFailedAttempts
\ No newline at end of file
+                return self.maxFailedAttempts
+
+    def _disallowPasswordReuse(self):
+        if self.disallowPasswordReuse is not None:
+            return self.disallowPasswordReuse
+
+        options = self._optionsUtility()
+        if options is None:
+            return self.disallowPasswordReuse
+        else:
+            if options.disallowPasswordReuse is not None:
+                return options.disallowPasswordReuse
+            else:
+                return self.disallowPasswordReuse
\ No newline at end of file

Modified: z3c.password/trunk/src/z3c/password/principal.txt
===================================================================
--- z3c.password/trunk/src/z3c/password/principal.txt	2009-06-22 07:56:49 UTC (rev 101209)
+++ z3c.password/trunk/src/z3c/password/principal.txt	2009-06-22 08:25:20 UTC (rev 101210)
@@ -47,8 +47,17 @@
 
   A time delta object after the user can try again after too many login
   failures.
-  If ``None`` login will enabled by a correct password.
 
+- ``disallowPasswordReuse``
+
+  Do not allow setting a password again that was used anytime before.
+  Set to True to enable.
+
+- ``previousPasswords``
+
+  Previous (encoded) password stored when required for
+  ``disallowPasswordReuse``
+
 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
@@ -71,7 +80,6 @@
 
   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``
 
@@ -79,7 +87,12 @@
   password before the password is locked and no new password can be provided.
   (or lockOutPeriod kicks in)
 
+- ``disallowPasswordReuse``
 
+  Do not allow setting a password again that was used anytime before.
+  Set to True to enable.
+
+
 Let's now create a principal:
 
   >>> from zope.app.authentication import principalfolder
@@ -200,14 +213,7 @@
   >>> 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
-
+  >>> from z3c.password.password import PasswordOptionsUtility
   >>> poptions = PasswordOptionsUtility()
   >>> zope.component.provideUtility(poptions)
 
@@ -590,7 +596,67 @@
   1
 
 
-Edge case.
+``disallowPasswordReuse``
+-------------------------
+
+Set this option to True to disallow setting a password that was used anytime
+before.
+
+  >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+
+Set ``disallowPasswordReuse``:
+(passwords will be stored from this point on, so the first '123123' NOT)
+
+  >>> poptions.disallowPasswordReuse = True
+
+  >>> user.setPassword('234234')
+  >>> user.setPassword('345345')
+  >>> user.setPassword('456456')
+  >>> user.setPassword('123123')
+
+Setting a used password again holds an exception:
+
+  >>> user.setPassword('234234')
+  Traceback (most recent call last):
+  ...
+  PreviousPasswordNotAllowed: The password set was already used before.
+
+Something else works:
+
+  >>> user.setPassword('789789')
+
+  >>> poptions.disallowPasswordReuse = False
+
+Same works when option is set on the user:
+
+  >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+
+Set ``disallowPasswordReuse``:
+(passwords will be stored from this point on, so the first '123123' NOT)
+
+  >>> user.disallowPasswordReuse = True
+
+  >>> user.setPassword('234234')
+  >>> user.setPassword('345345')
+  >>> user.setPassword('456456')
+  >>> user.setPassword('123123')
+
+Setting a used password again holds an exception:
+
+  >>> user.setPassword('234234')
+  Traceback (most recent call last):
+  ...
+  PreviousPasswordNotAllowed: The password set was already used before.
+
+Something else works:
+
+  >>> user.setPassword('789789')
+
+
+
+Edge cases
+----------
+
 When there is no ``maxFailedAttempts`` set, we can bang on with a bad password
 forever.
 
@@ -606,6 +672,32 @@
   ...         print 'bug'
 
 
+
+``failedAttempts`` needs to be reset on a successful check:
+
+  >>> user = MyPrincipal('srichter', '123123', u'Stephan Richter')
+  >>> user.checkPassword('456456')
+  False
+
+``failedAttempts`` gets set on a bad check:
+
+  >>> user.failedAttempts
+  1
+  >>> user.lastFailedAttempt
+  datetime.datetime(2009, 6, 14, 17, 15)
+
+  >>> user.checkPassword('123123')
+  True
+
+Gets reset on a successful check:
+
+  >>> user.failedAttempts
+  0
+  >>> user.lastFailedAttempt is None
+  True
+
+
+
 Coverage happiness
 ------------------
 



More information about the Checkins mailing list