passlib support - strong crypto hashes
authorThomas Waldmann <tw AT waldmann-edv DOT de>
Sun, 20 Jan 2013 16:12:13 +0100
changeset 1913dbcadc76561a
parent 1912 aff38f7e35b7
child 1914 591c6df0f900
passlib support - strong crypto hashes

updated code, tests and docs

use passlib for:
- wiki user password hashes
- constant time comparison of pw recovery token
- random_string
- recovery token generation

require passlib >= 1.6.0 in setup.py (consteq is new in 1.6)

reduce validity of pw recovery token to 2h, use sha256 (instead of sha1), reduce key length to 32chars

cosmetic other changes
MoinMoin/_tests/test_user.py
MoinMoin/_tests/wikiconfig.py
MoinMoin/apps/frontend/views.py
MoinMoin/config/default.py
MoinMoin/script/account/resetpw.py
MoinMoin/script/migration/moin19/import19.py
MoinMoin/user.py
MoinMoin/util/_tests/test_crypto.py
MoinMoin/util/crypto.py
docs/admin/configure.rst
docs/admin/upgrade.rst
setup.py
     1.1 --- a/MoinMoin/_tests/test_user.py	Sat Jan 19 04:23:42 2013 +0100
     1.2 +++ b/MoinMoin/_tests/test_user.py	Sun Jan 20 16:12:13 2013 +0100
     1.3 @@ -1,6 +1,7 @@
     1.4  # -*- coding: utf-8 -*-
     1.5  # Copyright: 2003-2004 by Juergen Hermann <jh@web.de>
     1.6  # Copyright: 2009 by ReimarBauer
     1.7 +# Copyright: 2013 by ThomasWaldmann
     1.8  # License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
     1.9  
    1.10  """
    1.11 @@ -38,9 +39,7 @@
    1.12          assert u.exists()
    1.13  
    1.14  
    1.15 -class TestLoginWithPassword(object):
    1.16 -    """user: login tests"""
    1.17 -
    1.18 +class TestUser(object):
    1.19      def setup_method(self, method):
    1.20          # Save original user
    1.21          self.saved_user = flaskg.user
    1.22 @@ -56,6 +55,8 @@
    1.23          # Restore original user
    1.24          flaskg.user = self.saved_user
    1.25  
    1.26 +    # Passwords / Login -----------------------------------------------
    1.27 +
    1.28      def testAsciiPassword(self):
    1.29          """ user: login with ascii password """
    1.30          # Create test user
    1.31 @@ -78,21 +79,27 @@
    1.32          theUser = user.User(name=name, password=password)
    1.33          assert theUser.valid
    1.34  
    1.35 -    def test_login(self):
    1.36 +    def testPasswordHash(self):
    1.37          """
    1.38 -        Create user with some password and check that user can login.
    1.39 +        Create user, set a specific pw hash and check that user can login
    1.40 +        with the correct password and can not log in with a wrong password.
    1.41          """
    1.42          # Create test user
    1.43          name = u'Test User'
    1.44 -        password = '12345'
    1.45 -        salt = 'salt'
    1.46 -        pw_hash = ''
    1.47 +        # sha512_crypt passlib hash for '12345':
    1.48 +        pw_hash = '$6$rounds=1001$y9ObPHKb8cvRCs5G$39IW1i5w6LqXPRi4xqAu3OKv1UOpVKNkwk7zPnidsKZWqi1CrQBpl2wuq36J/s6yTxjCnmaGzv/2.dAmM8fDY/'
    1.49          self.createUser(name, pw_hash, True)
    1.50  
    1.51 -        # Try to "login"
    1.52 -        theuser = user.User(name=name, password=password)
    1.53 +        # Try to "login" with correct password
    1.54 +        theuser = user.User(name=name, password='12345')
    1.55          assert theuser.valid
    1.56  
    1.57 +        # Try to "login" with a wrong password
    1.58 +        theuser = user.User(name=name, password='wrong')
    1.59 +        assert not theuser.valid
    1.60 +
    1.61 +    # Subscriptions ---------------------------------------------------
    1.62 +
    1.63      def testSubscriptionSubscribedPage(self):
    1.64          """ user: tests is_subscribed_to  """
    1.65          pagename = u'HelpMiscellaneous'
     2.1 --- a/MoinMoin/_tests/wikiconfig.py	Sat Jan 19 04:23:42 2013 +0100
     2.2 +++ b/MoinMoin/_tests/wikiconfig.py	Sun Jan 20 16:12:13 2013 +0100
     2.3 @@ -25,3 +25,12 @@
     2.4      interwikiname = u'MoinTest'
     2.5      interwiki_map = dict(Self='http://localhost:8080/', MoinMoin='http://moinmo.in/')
     2.6      interwiki_map[interwikiname] = 'http://localhost:8080/'
     2.7 +
     2.8 +    passlib_crypt_context = dict(
     2.9 +        schemes=["sha512_crypt", ],
    2.10 +        # for the tests, we don't want to have varying rounds
    2.11 +        sha512_crypt__vary_rounds=0,
    2.12 +        # for the tests, we want to have a rather low rounds count,
    2.13 +        # so the tests run quickly (do NOT use low counts in production!)
    2.14 +        sha512_crypt__default_rounds=1001,
    2.15 +    )
     3.1 --- a/MoinMoin/apps/frontend/views.py	Sat Jan 19 04:23:42 2013 +0100
     3.2 +++ b/MoinMoin/apps/frontend/views.py	Sun Jan 20 16:12:13 2013 +0100
     3.3 @@ -1,5 +1,5 @@
     3.4  # Copyright: 2012 MoinMoin:CheerXiao
     3.5 -# Copyright: 2003-2010 MoinMoin:ThomasWaldmann
     3.6 +# Copyright: 2003-2013 MoinMoin:ThomasWaldmann
     3.7  # Copyright: 2011 MoinMoin:AkashSinha
     3.8  # Copyright: 2011 MoinMoin:ReimarBauer
     3.9  # Copyright: 2008 MoinMoin:FlorianKrupicka
    3.10 @@ -1188,16 +1188,17 @@
    3.11      """Validator for a valid password recovery form
    3.12      """
    3.13      passwords_mismatch_msg = L_('The passwords do not match.')
    3.14 -    password_encoding_problem_msg = L_('New password is unacceptable, encoding trouble.')
    3.15 +    password_problem_msg = L_('New password is unacceptable, could not get processed.')
    3.16  
    3.17      def validate(self, element, state):
    3.18          if element['password1'].value != element['password2'].value:
    3.19              return self.note_error(element, state, 'passwords_mismatch_msg')
    3.20  
    3.21 +        password = element['password1'].value
    3.22          try:
    3.23 -            crypto.crypt_password(element['password1'].value)
    3.24 -        except UnicodeError:
    3.25 -            return self.note_error(element, state, 'password_encoding_problem_msg')
    3.26 +            app.cfg.cache.pwd_context.encrypt(password)
    3.27 +        except (ValueError, TypeError) as err:
    3.28 +            return self.note_error(element, state, 'password_problem_msg')
    3.29  
    3.30          return True
    3.31  
    3.32 @@ -1322,7 +1323,7 @@
    3.33      """
    3.34      passwords_mismatch_msg = L_('The passwords do not match.')
    3.35      current_password_wrong_msg = L_('The current password was wrong.')
    3.36 -    password_encoding_problem_msg = L_('New password is unacceptable, encoding trouble.')
    3.37 +    password_problem_msg = L_('New password is unacceptable, could not get processed.')
    3.38  
    3.39      def validate(self, element, state):
    3.40          if not (element['password_current'].valid and element['password1'].valid and element['password2'].valid):
    3.41 @@ -1334,10 +1335,11 @@
    3.42          if element['password1'].value != element['password2'].value:
    3.43              return self.note_error(element, state, 'passwords_mismatch_msg')
    3.44  
    3.45 +        password = element['password1'].value
    3.46          try:
    3.47 -            crypto.crypt_password(element['password1'].value)
    3.48 -        except UnicodeError:
    3.49 -            return self.note_error(element, state, 'password_encoding_problem_msg')
    3.50 +            app.cfg.cache.pwd_context.encrypt(password)
    3.51 +        except (ValueError, TypeError) as err:
    3.52 +            return self.note_error(element, state, 'password_problem_msg')
    3.53          return True
    3.54  
    3.55  
     4.1 --- a/MoinMoin/config/default.py	Sat Jan 19 04:23:42 2013 +0100
     4.2 +++ b/MoinMoin/config/default.py	Sun Jan 20 16:12:13 2013 +0100
     4.3 @@ -1,6 +1,6 @@
     4.4  # -*- coding: utf-8 -*-
     4.5  # Copyright: 2000-2004 Juergen Hermann <jh@web.de>
     4.6 -# Copyright: 2005-2011 MoinMoin:ThomasWaldmann
     4.7 +# Copyright: 2005-2013 MoinMoin:ThomasWaldmann
     4.8  # Copyright: 2008      MoinMoin:JohannesBerg
     4.9  # Copyright: 2010      MoinMoin:DiogenesAugusto
    4.10  # Copyright: 2011      MoinMoin:AkashSinha
    4.11 @@ -151,6 +151,12 @@
    4.12                  raise error.ConfigurationError("You must set a (at least {0} chars long) secret string for secrets['{1}']!".format(
    4.13                      secret_min_length, secret_key_name))
    4.14  
    4.15 +        from passlib.context import CryptContext
    4.16 +        try:
    4.17 +            self.cache.pwd_context = CryptContext(**self.passlib_crypt_context)
    4.18 +        except ValueError as err:
    4.19 +            raise error.ConfigurationError("passlib_crypt_context configuration is invalid [{0}].".format(err))
    4.20 +
    4.21      def _config_check(self):
    4.22          """ Check namespace and warn about unknown names
    4.23  
    4.24 @@ -311,6 +317,22 @@
    4.25  
    4.26      ('password_checker', DefaultExpression('_default_password_checker'),
    4.27       'checks whether a password is acceptable (default check is length >= 6, at least 4 different chars, no keyboard sequence, not username used somehow (you can switch this off by using `None`)'),
    4.28 +
    4.29 +    ('passlib_crypt_context', dict(
    4.30 +        # schemes we want to support (or deprecated schemes for which we still have
    4.31 +        # hashes in our storage).
    4.32 +        # note about bcrypt: it needs additional code (that is not pure python and
    4.33 +        # thus either needs compiling or installing platform-specific binaries)
    4.34 +        schemes=["sha512_crypt", ],
    4.35 +        # default scheme for creating new pw hashes (if not given, passlib uses first from schemes)
    4.36 +        #default="sha512_crypt",
    4.37 +        # deprecated schemes get auto-upgraded to the default scheme at login
    4.38 +        # time or when setting a password (including doing a moin account pwreset).
    4.39 +        #deprecated=["auto"],
    4.40 +        # vary rounds parameter randomly when creating new hashes...
    4.41 +        #all__vary_rounds=0.1,
    4.42 +     ),
    4.43 +     "passlib CryptContext arguments, see passlib docs"),
    4.44    )),
    4.45    # ==========================================================================
    4.46    'spam_leech_dos': ('Anti-Spam / Leech / DOS',
    4.47 @@ -448,6 +470,7 @@
    4.48          scroll_page_after_edit=True,
    4.49          show_comments=False,
    4.50          want_trivial=False,
    4.51 +        enc_password=u'',  # empty value == invalid hash
    4.52          disabled=False,
    4.53          bookmarks={},
    4.54          quicklinks=[],
     5.1 --- a/MoinMoin/script/account/resetpw.py	Sat Jan 19 04:23:42 2013 +0100
     5.2 +++ b/MoinMoin/script/account/resetpw.py	Sun Jan 20 16:12:13 2013 +0100
     5.3 @@ -1,4 +1,4 @@
     5.4 -# Copyright: 2006 MoinMoin:ThomasWaldmann
     5.5 +# Copyright: 2006-2013 MoinMoin:ThomasWaldmann
     5.6  # Copyright: 2008 MoinMoin:JohannesBerg
     5.7  # Copyright: 2011 MoinMoin:ReimarBauer
     5.8  # License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
     5.9 @@ -14,7 +14,6 @@
    5.10  
    5.11  from MoinMoin import user
    5.12  from MoinMoin.app import before_wiki
    5.13 -from MoinMoin.util import crypto
    5.14  
    5.15  
    5.16  class Set_Password(Command):
    5.17 @@ -45,6 +44,10 @@
    5.18              print 'This user "{0!r}" does not exists!'.format(u.name)
    5.19              return
    5.20  
    5.21 -        u.enc_password = crypto.crypt_password(password)
    5.22 -        u.save()
    5.23 -        print 'Password set.'
    5.24 +        try:
    5.25 +            u.enc_password = app.cfg.cache.pwd_context.encrypt(password)
    5.26 +        except (TypeError, ValueError) as err:
    5.27 +            print "Error: Password could not get processed, aborting."
    5.28 +        else:
    5.29 +            u.save()
    5.30 +            print 'Password set.'
     6.1 --- a/MoinMoin/script/migration/moin19/import19.py	Sat Jan 19 04:23:42 2013 +0100
     6.2 +++ b/MoinMoin/script/migration/moin19/import19.py	Sun Jan 20 16:12:13 2013 +0100
     6.3 @@ -536,7 +536,7 @@
     6.4                  'editor_default', # not used any more
     6.5                  'editor_ui', # not used any more
     6.6                  'external_target', # ancient, not used any more
     6.7 -                'passwd', # ancient, not used any more (use enc_passwd)
     6.8 +                'passwd', # ancient, not used any more (use enc_password)
     6.9                  'show_emoticons', # ancient, not used any more
    6.10                  'show_fancy_diff', # kind of diff display now depends on mimetype
    6.11                  'show_fancy_links', # not used any more (now link rendering depends on theme)
    6.12 @@ -568,6 +568,17 @@
    6.13              if key in metadata and metadata[key] in [u'', tuple(), {}, [], ]:
    6.14                  del metadata[key]
    6.15  
    6.16 +        # moin2 only supports passlib generated hashes, drop everything else
    6.17 +        # (users need to do pw recovery in case they are affected)
    6.18 +        pw = metadata.get('enc_password')
    6.19 +        if pw is not None:
    6.20 +            if pw.startswith('{PASSLIB}'):
    6.21 +                # take it, but strip the prefix as moin2 does not use that any more
    6.22 +                metadata['enc_password'] = pw[len('{PASSLIB}'):]
    6.23 +            else:
    6.24 +                # drop old, unsupported (and also more or less unsafe) hashing scheme
    6.25 +                del metadata['enc_password']
    6.26 +
    6.27          # TODO quicklinks and subscribed_items - check for non-interwiki elements and convert them to interwiki
    6.28  
    6.29          return metadata
     7.1 --- a/MoinMoin/user.py	Sat Jan 19 04:23:42 2013 +0100
     7.2 +++ b/MoinMoin/user.py	Sun Jan 20 16:12:13 2013 +0100
     7.3 @@ -1,5 +1,5 @@
     7.4  # Copyright: 2000-2004 Juergen Hermann <jh@web.de>
     7.5 -# Copyright: 2003-2012 MoinMoin:ThomasWaldmann
     7.6 +# Copyright: 2003-2013 MoinMoin:ThomasWaldmann
     7.7  # Copyright: 2007 MoinMoin:JohannesBerg
     7.8  # Copyright: 2007 MoinMoin:HeinrichWendel
     7.9  # Copyright: 2008 MoinMoin:ChristopherDenter
    7.10 @@ -33,14 +33,16 @@
    7.11  
    7.12  from whoosh.query import Term, And, Or
    7.13  
    7.14 +from MoinMoin import log
    7.15 +logging = log.getLogger(__name__)
    7.16 +
    7.17  from MoinMoin import wikiutil
    7.18  from MoinMoin.config import CONTENTTYPE_USER
    7.19  from MoinMoin.constants.keys import *
    7.20  from MoinMoin.i18n import _, L_, N_
    7.21  from MoinMoin.mail import sendmail
    7.22  from MoinMoin.util.interwiki import getInterwikiHome, getInterwikiName, is_local_wiki
    7.23 -from MoinMoin.util.crypto import crypt_password, upgrade_password, valid_password, \
    7.24 -                                 generate_token, valid_token, make_uuid
    7.25 +from MoinMoin.util.crypto import generate_token, valid_token, make_uuid
    7.26  from MoinMoin.storage.error import NoSuchItemError, ItemAlreadyExistsError, NoSuchRevisionError
    7.27  
    7.28  
    7.29 @@ -67,11 +69,7 @@
    7.30          if pw_error:
    7.31              return _("Password not acceptable: %(msg)s", msg=pw_error)
    7.32  
    7.33 -    try:
    7.34 -        theuser.set_password(password, is_encrypted)
    7.35 -    except UnicodeError as err:
    7.36 -        # Should never happen
    7.37 -        return "Can't encode password: %(msg)s" % dict(msg=str(err))
    7.38 +    theuser.set_password(password, is_encrypted)
    7.39  
    7.40      # try to get the email, for new users it is required
    7.41      if validate and not email:
    7.42 @@ -422,20 +420,21 @@
    7.43          if not pw_hash or not password:
    7.44              return False, False
    7.45  
    7.46 -        # check the password against the password hash
    7.47 -        if not valid_password(password, pw_hash):
    7.48 -            return False, False
    7.49 +        pwd_context = self._cfg.cache.pwd_context
    7.50 +        password_correct = False
    7.51 +        recomputed_hash = None
    7.52 +        try:
    7.53 +            password_correct, recomputed_hash = pwd_context.verify_and_update(password, pw_hash)
    7.54 +        except (ValueError, TypeError) as err:
    7.55 +            logging.error('in user profile %r, verifying the passlib pw hash raised an Exception [%s]' % (self.id, str(err)))
    7.56 +        else:
    7.57 +            if recomputed_hash is not None:
    7.58 +                data[ENC_PASSWORD] = recomputed_hash
    7.59 +        return password_correct, bool(recomputed_hash)
    7.60  
    7.61 -        new_pw_hash = upgrade_password(password, pw_hash)
    7.62 -        if not new_pw_hash:
    7.63 -            return True, False
    7.64 -
    7.65 -        data[ENC_PASSWORD] = new_pw_hash
    7.66 -        return True, True
    7.67 -
    7.68 -    def set_password(self, password, is_encrypted=False):
    7.69 +    def set_password(self, password, is_encrypted=False, salt=None):
    7.70          if not is_encrypted:
    7.71 -            password = crypt_password(password)
    7.72 +            password = self._cfg.cache.pwd_context.encrypt(password, salt=salt)
    7.73          self.profile[ENC_PASSWORD] = password
    7.74          # Invalidate all other browser sessions except this one.
    7.75          session['user.session_token'] = self.generate_session_token(False)
     8.1 --- a/MoinMoin/util/_tests/test_crypto.py	Sat Jan 19 04:23:42 2013 +0100
     8.2 +++ b/MoinMoin/util/_tests/test_crypto.py	Sun Jan 20 16:12:13 2013 +0100
     8.3 @@ -1,5 +1,5 @@
     8.4  # -*- coding: utf-8 -*-
     8.5 -# Copyright: 2011 by MoinMoin:ThomasWaldmann
     8.6 +# Copyright: 2011-2013 by MoinMoin:ThomasWaldmann
     8.7  # License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
     8.8  
     8.9  """
    8.10 @@ -53,6 +53,10 @@
    8.11          result = crypto.valid_token(test_key, test_token)
    8.12          assert not result
    8.13  
    8.14 +
    8.15 +class TestCacheKey(object):
    8.16 +    """ tests for cache key generation """
    8.17 +
    8.18      def test_cache_key(self):
    8.19          """ The key must be different for different <kw> """
    8.20          test_kw1 = {'MoinMoin': 'value1'}
    8.21 @@ -61,4 +65,5 @@
    8.22          result2 = crypto.cache_key(**test_kw2)
    8.23          assert result1 != result2, ("Expected different keys for different <kw> but got the same")
    8.24  
    8.25 +
    8.26  coverage_modules = ['MoinMoin.util.crypto']
     9.1 --- a/MoinMoin/util/crypto.py	Sat Jan 19 04:23:42 2013 +0100
     9.2 +++ b/MoinMoin/util/crypto.py	Sun Jan 20 16:12:13 2013 +0100
     9.3 @@ -1,9 +1,4 @@
     9.4 -# Copyright: 2000-2004 Juergen Hermann <jh@web.de>
     9.5 -# Copyright: 2003-2011 MoinMoin:ThomasWaldmann
     9.6 -# Copyright: 2007 MoinMoin:JohannesBerg
     9.7 -# Copyright: 2007 MoinMoin:HeinrichWendel
     9.8 -# Copyright: 2008 MoinMoin:ChristopherDenter
     9.9 -# Copyright: 2010 MoinMoin:DiogenesAugusto
    9.10 +# Copyright: 2012-2013 MoinMoin:ThomasWaldmann
    9.11  # License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
    9.12  
    9.13  """
    9.14 @@ -11,10 +6,6 @@
    9.15  
    9.16  Features:
    9.17  
    9.18 -- generate strong, salted cryptographic password hashes for safe pw storage
    9.19 -- verify cleartext password against any supported crypto (see METHODS)
    9.20 -- supports password hash upgrades to stronger methods if the cleartext
    9.21 -  password is available (usually at login time)
    9.22  - generate password recovery tokens
    9.23  - verify password recovery tokens
    9.24  - generate random strings of given length (for salting)
    9.25 @@ -26,7 +17,6 @@
    9.26  
    9.27  import hashlib
    9.28  import hmac
    9.29 -import random
    9.30  import time
    9.31  
    9.32  from uuid import uuid4
    9.33 @@ -34,61 +24,27 @@
    9.34  make_uuid = lambda: unicode(uuid4().hex)
    9.35  UUID_LEN = len(make_uuid())
    9.36  
    9.37 -# random stuff
    9.38 +from passlib.utils import rng, getrandstr, getrandbytes, consteq, generate_password
    9.39 +
    9.40  
    9.41  def random_string(length, allowed_chars=None):
    9.42      """
    9.43      Generate a random string with given length consisting of the given characters.
    9.44  
    9.45 +    Note: this is now just a little wrapper around passlib's randomness code.
    9.46 +
    9.47      :param length: length of the string
    9.48      :param allowed_chars: string with allowed characters or None
    9.49                            to indicate all 256 byte values should be used
    9.50      :returns: random string
    9.51      """
    9.52      if allowed_chars is None:
    9.53 -        s = ''.join([chr(random.randint(0, 255)) for dummy in xrange(length)])
    9.54 +        s = getrandbytes(rng, length)
    9.55      else:
    9.56 -        s = ''.join([random.choice(allowed_chars) for dummy in xrange(length)])
    9.57 +        s = getrandstr(rng, allowed_chars, length)
    9.58      return s
    9.59  
    9.60  
    9.61 -# password stuff
    9.62 -
    9.63 -def crypt_password(password, salt=None):
    9.64 -    """
    9.65 -    Crypt/Hash a cleartext password
    9.66 -
    9.67 -    :param password: cleartext password [unicode]
    9.68 -    :param salt: salt for the password [str] or None to generate a random salt
    9.69 -    :rtype: str
    9.70 -    :returns: the password hash
    9.71 -    """
    9.72 -    return 'foobar' # TODO
    9.73 -
    9.74 -
    9.75 -def upgrade_password(password, pw_hash):
    9.76 -    """
    9.77 -    Upgrade a password to a better hash, if needed
    9.78 -
    9.79 -    :param password: cleartext password [unicode]
    9.80 -    :param pw_hash: password hash (with hash type prefix)
    9.81 -    :rtype: str
    9.82 -    :returns: new password hash (or None, if unchanged)
    9.83 -    """
    9.84 -    # TODO
    9.85 -
    9.86 -def valid_password(password, pw_hash):
    9.87 -    """
    9.88 -    Validate a user password.
    9.89 -
    9.90 -    :param password: cleartext password to verify [unicode]
    9.91 -    :param pw_hash: password hash (with hash type prefix)
    9.92 -    :rtype: bool
    9.93 -    :returns: password is valid
    9.94 -    """
    9.95 -    return True # TODO
    9.96 -
    9.97 -
    9.98  # password recovery token
    9.99  
   9.100  def generate_token(key=None, stamp=None):
   9.101 @@ -109,18 +65,18 @@
   9.102      :param key: give it to recompute some specific token for verification
   9.103      :param stamp: give it to recompute some specific token for verification
   9.104      :rtype: 2-tuple
   9.105 -    :returns: key, token
   9.106 +    :returns: key, token (both unicode)
   9.107      """
   9.108      if key is None:
   9.109 -        key = random_string(64, "abcdefghijklmnopqrstuvwxyz0123456789")
   9.110 +        key = generate_password(size=32)
   9.111      if stamp is None:
   9.112          stamp = int(time.time())
   9.113 -    h = hmac.new(str(key), str(stamp), digestmod=hashlib.sha1).hexdigest()
   9.114 -    token = str(stamp) + '-' + h
   9.115 -    return key, token
   9.116 +    h = hmac.new(str(key), str(stamp), digestmod=hashlib.sha256).hexdigest()
   9.117 +    token = u"{0}-{1}".format(stamp, h)
   9.118 +    return unicode(key), token
   9.119  
   9.120  
   9.121 -def valid_token(key, token, timeout=12*60*60):
   9.122 +def valid_token(key, token, timeout=2*60*60):
   9.123      """
   9.124      check if token is valid with respect to the secret key,
   9.125      the token must not be older than timeout seconds.
   9.126 @@ -141,7 +97,7 @@
   9.127      if timeout and stamp + timeout < time.time():
   9.128          return False
   9.129      expected_token = generate_token(key, stamp)[1]
   9.130 -    return token == expected_token
   9.131 +    return consteq(token, expected_token)
   9.132  
   9.133  
   9.134  # miscellaneous
    10.1 --- a/docs/admin/configure.rst	Sat Jan 19 04:23:42 2013 +0100
    10.2 +++ b/docs/admin/configure.rst	Sun Jan 20 16:12:13 2013 +0100
    10.3 @@ -630,7 +630,28 @@
    10.4  
    10.5  Password storage
    10.6  ----------------
    10.7 -Moin never stores passwords in clear text.
    10.8 +Moin never stores wiki user passwords in clear text, but uses strong
    10.9 +cryptographic hashes provided by the "passlib" library, see there for details:
   10.10 +
   10.11 +    http://packages.python.org/passlib/.
   10.12 +
   10.13 +The passlib docs recommend 3 hashing schemes that have good security:
   10.14 +sha512_crypt, pbkdf2_sha512 and bcrypt (bcrypt has additional binary/compiled
   10.15 +package requirements, please refer to the passlib docs in case you want to use
   10.16 +it).
   10.17 +
   10.18 +By default, we use sha512_crypt hashes with default parameters as provided
   10.19 +by passlib (this is same algorithm as moin >= 1.9.7 used by default).
   10.20 +
   10.21 +In case you experience slow logins or feel that you might need to tweak the
   10.22 +hash generation for other reasons, please read the passlib docs. moin allows
   10.23 +you to configure passlib's CryptContext params within the wiki config, the
   10.24 +default is this:
   10.25 +
   10.26 +::
   10.27 +    passlib_crypt_context = dict(
   10.28 +        schemes=["sha512_crypt", ],
   10.29 +    )
   10.30  
   10.31  
   10.32  Authorization
    11.1 --- a/docs/admin/upgrade.rst	Sat Jan 19 04:23:42 2013 +0100
    11.2 +++ b/docs/admin/upgrade.rst	Sun Jan 20 16:12:13 2013 +0100
    11.3 @@ -19,9 +19,9 @@
    11.4  
    11.5  From moin < 1.9
    11.6  ===============
    11.7 -If you run an older moin version than 1.9, please first upgrade to moin 1.9.x
    11.8 -before upgrading to moin2. 
    11.9 -You may want to run 1.9.x for a while to be sure everything is working as expected.
   11.10 +If you run an older moin version than 1.9, please first upgrade to a recent
   11.11 +moin 1.9.x version (preferably >= 1.9.7) before upgrading to moin2.
   11.12 +You may want to run that for a while to be sure everything is working as expected.
   11.13  
   11.14  Note: Both moin 1.9.x and moin2 are WSGI applications.
   11.15  Upgrading to 1.9 first also makes sense concerning the WSGI / server side.
   11.16 @@ -29,6 +29,14 @@
   11.17  
   11.18  From moin 1.9.x
   11.19  ===============
   11.20 +
   11.21 +If you want to keep your user's password hashes and migrate them to moin2,
   11.22 +make sure you use moin >= 1.9.7 WITH enabled passlib support and that all
   11.23 +password hashes stored in user profiles are {PASSLIB} hashes. Other hashes
   11.24 +will get removed in the migration process and users will need to do password
   11.25 +recovery via email (or with admin help, if that does not work).
   11.26 +
   11.27 +
   11.28  Backup
   11.29  ------
   11.30  Have a backup of everything, so you can go back in case it doesn't do what
   11.31 @@ -56,6 +64,8 @@
   11.32      sitename = u'...' # same as in 1.9
   11.33      item_root = u'...' # see page_front_page in 1.9
   11.34  
   11.35 +    # if you had a custom passlib_crypt_context in 1.9, put it here
   11.36 +
   11.37      # configure backend and ACLs to use in future
   11.38      # TODO
   11.39  
    12.1 --- a/setup.py	Sat Jan 19 04:23:42 2013 +0100
    12.2 +++ b/setup.py	Sun Jan 20 16:12:13 2013 +0100
    12.3 @@ -101,6 +101,7 @@
    12.4          'whoosh>=2.4.0', # needed for indexed search
    12.5          'sphinx>=1.1', # needed to build the docs
    12.6          'pdfminer', # pdf -> text/plain conversion
    12.7 +        'passlib>=1.6.0', # strong password hashing (1.6 needed for consteq)
    12.8          'XStatic>=0.0.2', # support for static file pypi packages
    12.9          'XStatic-CKEditor>=3.6.1.2',
   12.10          'XStatic-jQuery>=1.8.2',