changeset 174:e8f61cbd661b

modularize crypto/random stuff, move it to MoinMoin.util.crypto, pw change bugfix on password change, the new password was not saved to the profile User / crypto code: minor optimizations / refactorings crypto module contents: password hashing/encryption, validation, pw hash upgrades: * pw_hash = crypt_password(password) * is_valid = valid_password(password, pw_hash) * upgraded_pw_hash = upgrade_password(password, pw_hash) password recovery: * key, token = generate_token() * is_valid = valid_token(key, token) random strings: * rs = random_string(length, chars) compute ascii cache keys: * key = cache_key(**kw)
author Thomas Waldmann <tw AT waldmann-edv DOT de>
date Thu, 07 Apr 2011 20:56:35 +0200
parents 89f50aed143f
children d3b415b65562
files MoinMoin/_tests/__init__.py MoinMoin/_tests/test_user.py MoinMoin/apps/feed/views.py MoinMoin/apps/frontend/_tests/test_frontend.py MoinMoin/apps/frontend/views.py MoinMoin/items/__init__.py MoinMoin/script/account/resetpw.py MoinMoin/themes/__init__.py MoinMoin/user.py MoinMoin/util/__init__.py MoinMoin/util/_tests/test_crypto.py MoinMoin/util/_tests/test_pysupport.py MoinMoin/util/_tests/test_util.py MoinMoin/util/crypto.py MoinMoin/wikiutil.py
diffstat 15 files changed, 311 insertions(+), 192 deletions(-) [+]
line wrap: on
line diff
--- a/MoinMoin/_tests/__init__.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/_tests/__init__.py	Thu Apr 07 20:56:35 2011 +0200
@@ -15,7 +15,7 @@
 
 from MoinMoin import config, security, user
 from MoinMoin.items import Item
-from MoinMoin.util import random_string
+from MoinMoin.util.crypto import random_string
 from MoinMoin.storage.error import ItemAlreadyExistsError
 
 # Promoting the test user -------------------------------------------
--- a/MoinMoin/_tests/test_user.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/_tests/test_user.py	Thu Apr 07 20:56:35 2011 +0200
@@ -14,26 +14,7 @@
 from flask import g as flaskg
 
 from MoinMoin import user
-
-
-class TestEncodePassword(object):
-    """user: encode passwords tests"""
-
-    def testAscii(self):
-        """user: encode ascii password"""
-        # u'MoinMoin' and 'MoinMoin' should be encoded to same result
-        expected = "{SSHA256}n0JB8FCTQCpQeg0bmdgvTGwPKvxm8fVNjSRD+JGNs50xMjM0NQ=="
-
-        result = user.encodePassword("MoinMoin", salt='12345')
-        assert result == expected
-        result = user.encodePassword(u"MoinMoin", salt='12345')
-        assert result == expected
-
-    def testUnicode(self):
-        """ user: encode unicode password """
-        result = user.encodePassword(u'סיסמה סודית בהחלט', salt='12345') # Hebrew
-        expected = "{SSHA256}pdYvYv+4Vph259sv/HAm7zpZTv4sBKX9ITOX/m00HMsxMjM0NQ=="
-        assert result == expected
+from MoinMoin.util import crypto
 
 
 class TestLoginWithPassword(object):
@@ -328,7 +309,7 @@
         self.user.name = name
         self.user.email = email
         if not pwencoded:
-            password = user.encodePassword(password)
+            password = crypto.crypt_password(password)
         self.user.enc_password = password
 
         # Validate that we are not modifying existing user data file!
--- a/MoinMoin/apps/feed/views.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/apps/feed/views.py	Thu Apr 07 20:56:35 2011 +0200
@@ -26,6 +26,7 @@
 from MoinMoin.config import NAME, ACL, MIMETYPE, ACTION, ADDRESS, HOSTNAME, USERID, COMMENT
 from MoinMoin.themes import get_editor_info
 from MoinMoin.items import Item
+from MoinMoin.util.crypto import cache_key
 
 @feed.route('/atom/<itemname:item_name>')
 @feed.route('/atom', defaults=dict(item_name=''))
@@ -35,7 +36,7 @@
     # - full item in html is nice
     # - diffs in textmode are OK, but look very simple
     # - full-item content in textmode is OK, but looks very simple
-    cid = wikiutil.cache_key(usage="atom", item_name=item_name)
+    cid = cache_key(usage="atom", item_name=item_name)
     content = app.cache.get(cid)
     if content is None:
         title = app.cfg.sitename
--- a/MoinMoin/apps/frontend/_tests/test_frontend.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/apps/frontend/_tests/test_frontend.py	Thu Apr 07 20:56:35 2011 +0200
@@ -11,6 +11,7 @@
 
 from MoinMoin.apps.frontend import views
 from MoinMoin import user
+from MoinMoin.util import crypto
 
 class TestFrontend(object):
     def test_root(self):
@@ -139,7 +140,7 @@
         self.user.name = name
         self.user.email = email
         if not pwencoded:
-            password = user.encodePassword(password)
+            password = crypto.crypt_password(password)
         self.user.enc_password = password
 
         # Validate that we are not modifying existing user data file!
--- a/MoinMoin/apps/frontend/views.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/apps/frontend/views.py	Thu Apr 07 20:56:35 2011 +0200
@@ -41,6 +41,7 @@
 from MoinMoin import config, user, wikiutil
 from MoinMoin.config import MIMETYPE, ITEMLINKS, ITEMTRANSCLUSIONS
 from MoinMoin.util.forms import make_generator
+from MoinMoin.util import crypto
 from MoinMoin.security.textcha import TextCha, TextChaizedForm, TextChaValid
 from MoinMoin.storage.error import NoSuchItemError, NoSuchRevisionError, AccessDeniedError
 from MoinMoin.signalling import item_displayed, item_modified
@@ -915,7 +916,7 @@
             return self.note_error(element, state, 'passwords_mismatch_msg')
 
         try:
-            user.encodePassword(element['password1'].value)
+            crypto.crypt_password(element['password1'].value)
         except UnicodeError:
             return self.note_error(element, state, 'password_encoding_problem_msg')
 
@@ -1082,7 +1083,7 @@
             return self.note_error(element, state, 'passwords_mismatch_msg')
 
         try:
-            user.encodePassword(element['password1'].value)
+            crypto.crypt_password(element['password1'].value)
         except UnicodeError:
             return self.note_error(element, state, 'password_encoding_problem_msg')
         return True
@@ -1187,7 +1188,8 @@
             # successfully modified everything
             success = True
             if part == 'password':
-                flaskg.user.enc_password = user.encodePassword(form['password1'].value)
+                flaskg.user.enc_password = crypto.crypt_password(form['password1'].value)
+                flaskg.user.save()
                 flash(_("Your password has been changed."), "info")
             else:
                 if part == 'personal':
--- a/MoinMoin/items/__init__.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/items/__init__.py	Thu Apr 07 20:56:35 2011 +0200
@@ -24,6 +24,7 @@
 from MoinMoin.security.textcha import TextCha, TextChaizedForm, TextChaValid
 from MoinMoin.util.forms import make_generator
 from MoinMoin.util.mimetype import MimeType
+from MoinMoin.util.crypto import cache_key
 
 try:
     import PIL
@@ -167,9 +168,9 @@
         hash_name = HASH_ALGORITHM
         hash_hexdigest = self.rev.get(hash_name)
         if hash_hexdigest:
-            cid = wikiutil.cache_key(usage="internal_representation",
-                                     hash_name=hash_name,
-                                     hash_hexdigest=hash_hexdigest)
+            cid = cache_key(usage="internal_representation",
+                            hash_name=hash_name,
+                            hash_hexdigest=hash_hexdigest)
             doc = app.cache.get(cid)
         else:
             # likely a non-existing item
@@ -731,7 +732,7 @@
         if isinstance(name, unicode):
             name = name.encode('utf-8')
         temp_fname = os.path.join(tempfile.gettempdir(), 'TarContainer_' +
-                                  wikiutil.cache_key(usage='TarContainer', name=self.name))
+                                  cache_key(usage='TarContainer', name=self.name))
         tf = tarfile.TarFile(temp_fname, mode='a')
         ti = tarfile.TarInfo(name)
         if isinstance(content, str):
@@ -908,10 +909,10 @@
             # resize requested, XXX check ACL behaviour! XXX
             hash_name = HASH_ALGORITHM
             hash_hexdigest = self.rev[hash_name]
-            cid = wikiutil.cache_key(usage="ImageTransform",
-                                     hash_name=hash_name,
-                                     hash_hexdigest=hash_hexdigest,
-                                     width=width, height=height, transpose=transpose)
+            cid = cache_key(usage="ImageTransform",
+                            hash_name=hash_name,
+                            hash_hexdigest=hash_hexdigest,
+                            width=width, height=height, transpose=transpose)
             c = app.cache.get(cid)
             if c is None:
                 content_type = self.rev[MIMETYPE]
@@ -935,10 +936,10 @@
 
     def _render_data_diff_raw(self, oldrev, newrev):
         hash_name = HASH_ALGORITHM
-        cid = wikiutil.cache_key(usage="ImageDiff",
-                                 hash_name=hash_name,
-                                 hash_old=oldrev[hash_name],
-                                 hash_new=newrev[hash_name])
+        cid = cache_key(usage="ImageDiff",
+                        hash_name=hash_name,
+                        hash_old=oldrev[hash_name],
+                        hash_new=newrev[hash_name])
         c = app.cache.get(cid)
         if c is None:
             if PIL is None:
--- a/MoinMoin/script/account/resetpw.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/script/account/resetpw.py	Thu Apr 07 20:56:35 2011 +0200
@@ -13,6 +13,7 @@
 from flaskext.script import Command, Option
 
 from MoinMoin import user
+from MoinMoin.util import crypto
 
 
 class Set_Password(Command):
@@ -44,7 +45,7 @@
             print 'This user "%s" does not exists!' % u.name
             return
 
-        u.enc_password = user.encodePassword(password)
+        u.enc_password = crypto.crypt_password(password)
         u.save()
         print 'Password set.'
 
--- a/MoinMoin/themes/__init__.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/themes/__init__.py	Thu Apr 07 20:56:35 2011 +0200
@@ -22,6 +22,7 @@
 from MoinMoin import wikiutil, user
 from MoinMoin.config import USERID, ADDRESS, HOSTNAME
 from MoinMoin.util.interwiki import split_interwiki, resolve_interwiki, join_wiki, getInterwikiHome
+from MoinMoin.util.crypto import cache_key
 
 
 def get_current_theme():
@@ -220,7 +221,7 @@
             if sistername == self.cfg.interwikiname:  # it is THIS wiki
                 items.append(('sisterwiki current', sisterurl, sistername))
             else:
-                cid = wikiutil.cache_key(usage="SisterSites", sistername=sistername)
+                cid = cache_key(usage="SisterSites", sistername=sistername)
                 sisteritems = app.cache.get(cid)
                 if sisteritems is None:
                     uo = urllib.URLopener()
--- a/MoinMoin/user.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/user.py	Thu Apr 07 20:56:35 2011 +0200
@@ -19,17 +19,8 @@
 
 from __future__ import absolute_import, division
 
-import time, base64
+import time
 import copy
-import hashlib
-import hmac
-
-from MoinMoin.util import md5crypt
-
-try:
-    import crypt
-except ImportError:
-    crypt = None
 
 from babel import parse_locale
 
@@ -39,8 +30,10 @@
 
 from MoinMoin import config, wikiutil
 from MoinMoin.i18n import _, L_, N_
-from MoinMoin.util import random_string
 from MoinMoin.util.interwiki import getInterwikiHome
+from MoinMoin.util.crypto import crypt_password, upgrade_password, valid_password, \
+                                 generate_token, valid_token
+
 
 
 def create_user(username, password, email, openid=None):
@@ -68,7 +61,7 @@
 
     # Encode password
     try:
-        theuser.enc_password = encodePassword(password)
+        theuser.enc_password = crypt_password(password)
     except UnicodeError as err:
         # Should never happen
         return "Can't encode password: %(msg)s" % dict(msg=str(err))
@@ -177,25 +170,6 @@
     return result
 
 
-def encodePassword(pwd, salt=None):
-    """ Encode a cleartext password
-
-    :param pwd: the cleartext password, (unicode)
-    :param salt: the salt for the password (string)
-    :rtype: string
-    :returns: the password in SHA256-encoding
-    """
-    pwd = pwd.encode('utf-8')
-
-    if salt is None:
-        salt = random_string(32)
-    assert isinstance(salt, str)
-    hash = hashlib.new('sha256', pwd)
-    hash.update(salt)
-
-    return '{SSHA256}' + base64.encodestring(hash.digest() + salt).rstrip()
-
-
 def normalizeName(name):
     """ Make normalized user name
 
@@ -273,7 +247,7 @@
         self.recoverpass_key = None
 
         if password:
-            self.enc_password = encodePassword(password)
+            self.enc_password = crypt_password(password)
 
         self._stored = False
         self.last_saved = 0
@@ -297,7 +271,7 @@
         if not self.id:
             self.id = self.make_id()
             if password is not None:
-                self.enc_password = encodePassword(password)
+                self.enc_password = crypt_password(password)
 
         # "may" so we can say "if user.may.read(pagename):"
         if self._cfg.SecurityPolicy:
@@ -404,68 +378,23 @@
         :rtype: 2 tuple (bool, bool)
         :returns: password is valid, enc_password changed
         """
-        epwd = data['enc_password']
+        pw_hash = data['enc_password']
 
-        # If we have no password set, we don't accept login with username
-        if not epwd:
+        # If we have no password set, we don't accept login with username.
+        # Require non-empty password.
+        if not pw_hash or not password:
             return False, False
 
-        # require non empty password
-        if not password:
+        # check the password against the password hash
+        if not valid_password(password, pw_hash):
             return False, False
-        # encode password
-        pw_utf8 = password.encode('utf-8')
-
-        # Check and/or upgrade passwords from earlier MoinMoin versions and
-        # passwords imported from other wiki systems.
-        for method in ['{SSHA256}', '{SSHA}', '{SHA}', '{APR1}', '{MD5}', '{DES}']:
-            if epwd.startswith(method):
-                d = epwd[len(method):]
 
-                if method == '{SSHA256}':
-                    pw_hash = base64.decodestring(d)
-                    # pw_hash is of the form "<hash><salt>"
-                    salt = pw_hash[32:]
-                    hash = hashlib.new('sha256', pw_utf8)
-                    hash.update(salt)
-                    enc = base64.encodestring(hash.digest() + salt).rstrip()
-                elif method == '{SSHA}':
-                    pw_hash = base64.decodestring(d)
-                    # pw_hash is of the form "<hash><salt>"
-                    salt = pw_hash[20:]
-                    hash = hashlib.new('sha1', pw_utf8)
-                    hash.update(salt)
-                    enc = base64.encodestring(hash.digest() + salt).rstrip()
-                elif method == '{SHA}':
-                    hash = hashlib.new('sha1', pw_utf8)
-                    enc = base64.encodestring(hash.digest()).rstrip()
-                elif method == '{APR1}':
-                    # d is of the form "$apr1$<salt>$<hash>"
-                    salt = d.split('$')[2]
-                    enc = md5crypt.apache_md5_crypt(pw_utf8, salt.encode('ascii'))
-                elif method == '{MD5}':
-                    # d is of the form "$1$<salt>$<hash>"
-                    salt = d.split('$')[2]
-                    enc = md5crypt.unix_md5_crypt(pw_utf8, salt.encode('ascii'))
-                elif method == '{DES}':
-                    if crypt is None:
-                        return False, False
-                    # d is 2 characters salt + 11 characters hash
-                    salt = d[:2]
-                    enc = crypt.crypt(pw_utf8, salt.encode('ascii'))
+        new_pw_hash = upgrade_password(password, pw_hash)
+        if not new_pw_hash:
+            return True, False
 
-                if epwd == method + enc:
-                    # SSHA256 current password hash
-                    if method == '{SSHA256}':
-                        return True, False
-                    else:
-                        # stored password hashed with old hash method
-                        data['enc_password'] = encodePassword(password) # upgrade to SSHA256
-                        return True, True
-                return False, False
-
-        # No encoded password match, this must be wrong password
-        return False, False
+        data['enc_password'] = new_pw_hash
+        return True, True
 
     def persistent_items(self):
         """ items we want to store into the user profile """
@@ -827,31 +756,16 @@
             return self.host()
 
     def generate_recovery_token(self):
-        key = random_string(64, "abcdefghijklmnopqrstuvwxyz0123456789")
-        msg = str(int(time.time()))
-        h = hmac.new(key, msg, digestmod=hashlib.sha1).hexdigest()
+        key, token = generate_token()
         self.recoverpass_key = key
         self.save()
-        return msg + '-' + h
+        return token
 
-    def apply_recovery_token(self, tok, newpass):
-        parts = tok.split('-')
-        if len(parts) != 2:
-            return False
-        try:
-            stamp = int(parts[0])
-        except ValueError:
-            return False
-        # only allow it to be valid for twelve hours
-        if stamp + 12*60*60 < time.time():
-            return False
-        # check hmac
-        # key must be of type string
-        h = hmac.new(str(self.recoverpass_key), str(stamp), digestmod=hashlib.sha1).hexdigest()
-        if h != parts[1]:
+    def apply_recovery_token(self, token, newpass):
+        if not valid_token(self.recoverpass_key, token):
             return False
         self.recoverpass_key = None
-        self.enc_password = encodePassword(newpass)
+        self.enc_password = crypt_password(newpass)
         self.save()
         return True
 
--- a/MoinMoin/util/__init__.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/util/__init__.py	Thu Apr 07 20:56:35 2011 +0200
@@ -7,7 +7,7 @@
 """
 
 
-import re, random
+import re
 
 # do the pickle magic once here, so we can just import from here:
 # cPickle can encode normal and Unicode strings
@@ -82,17 +82,3 @@
         return pattern[1:-1]
     return pattern[1:]
 
-
-def random_string(length, allowed_chars=None):
-    """ Generate a random string with given length consisting
-        of the given characters.
-
-        :param length: length of the string
-        :param allowed_chars: string with allowed characters or None
-                              to indicate all 256 byte values should be used
-        :returns: random string
-    """
-    if allowed_chars is None:
-        return ''.join([chr(random.randint(0, 255)) for dummy in xrange(length)])
-
-    return ''.join([random.choice(allowed_chars) for dummy in xrange(length)])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/_tests/test_crypto.py	Thu Apr 07 20:56:35 2011 +0200
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+# Copyright: 2011 by MoinMoin:ThomasWaldmann
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+MoinMoin - MoinMoin.util.crypto Tests
+"""
+
+
+import py
+
+from MoinMoin.util import crypto
+
+
+class TestRandom(object):
+    """ crypto: random tests """
+
+    def testRandomString(self):
+        """ util.random_string: test randomness and length """
+        length = 8
+        result1 = crypto.random_string(length)
+        result2 = crypto.random_string(length)
+        assert result1 != result2, ('Expected different random strings, but got "%(result1)s" and "%(result2)s"') % locals()
+
+        result = len(crypto.random_string(length))
+        expected = length
+        assert result == expected, ('Expected length "%(expected)s" but got "%(result)s"') % locals()
+
+
+class TestEncodePassword(object):
+    """crypto: encode passwords tests"""
+
+    def testAscii(self):
+        """user: encode ascii password"""
+        # u'MoinMoin' and 'MoinMoin' should be encoded to same result
+        expected = "{SSHA256}n0JB8FCTQCpQeg0bmdgvTGwPKvxm8fVNjSRD+JGNs50xMjM0NQ=="
+
+        result = crypto.crypt_password("MoinMoin", salt='12345')
+        assert result == expected
+        result = crypto.crypt_password(u"MoinMoin", salt='12345')
+        assert result == expected
+
+    def testUnicode(self):
+        """ user: encode unicode password """
+        result = crypto.crypt_password(u'סיסמה סודית בהחלט', salt='12345') # Hebrew
+        expected = "{SSHA256}pdYvYv+4Vph259sv/HAm7zpZTv4sBKX9ITOX/m00HMsxMjM0NQ=="
+        assert result == expected
+
+
+coverage_modules = ['MoinMoin.util.crypto']
+
--- a/MoinMoin/util/_tests/test_pysupport.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/util/_tests/test_pysupport.py	Thu Apr 07 20:56:35 2011 +0200
@@ -13,7 +13,7 @@
 
 from flask import current_app as app
 
-from MoinMoin.util import pysupport, random_string
+from MoinMoin.util import pysupport, crypto
 from MoinMoin.util import plugins
 
 
@@ -108,7 +108,7 @@
         if self.pluginExists():
             self.shouldDeleteTestPlugin = False
             py.test.skip("Won't overwrite existing plugin: %s" % self.plugin)
-        self.key = random_string(32, 'abcdefg')
+        self.key = crypto.random_string(32, 'abcdefg')
         data = '''
 # If you find this file in your wiki plugin directory, you can safely
 # delete it.
--- a/MoinMoin/util/_tests/test_util.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/util/_tests/test_util.py	Thu Apr 07 20:56:35 2011 +0200
@@ -40,15 +40,5 @@
         expected = '2-3,5-6,23,100-102,104'
         assert result == expected, ('Expected "%(expected)s" but got "%(result)s"') % locals()
 
-    def testRandomString(self):
-        """ util.random_string: test randomness and length """
-        length = 8
-        result1 = util.random_string(length)
-        result2 = util.random_string(length)
-        assert result1 != result2, ('Expected different random strings, but got "%(result1)s" and "%(result2)s"') % locals()
-
-        result = len(util.random_string(length))
-        expected = length
-        assert result == expected, ('Expected length "%(expected)s" but got "%(result)s"') % locals()
 
 coverage_modules = ['MoinMoin.util']
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MoinMoin/util/crypto.py	Thu Apr 07 20:56:35 2011 +0200
@@ -0,0 +1,205 @@
+# Copyright: 2000-2004 Juergen Hermann <jh@web.de>
+# Copyright: 2003-2011 MoinMoin:ThomasWaldmann
+# Copyright: 2007 MoinMoin:JohannesBerg
+# Copyright: 2007 MoinMoin:HeinrichWendel
+# Copyright: 2008 MoinMoin:ChristopherDenter
+# Copyright: 2010 MoinMoin:DiogenesAugusto
+# License: GNU GPL v2 (or any later version), see LICENSE.txt for details.
+
+"""
+MoinMoin - Cryptographic and random functions
+"""
+
+from __future__ import absolute_import, division
+
+import base64
+import hashlib
+import hmac
+import random
+
+try:
+    import crypt
+except ImportError:
+    crypt = None
+
+from . import md5crypt
+
+# random stuff
+
+def random_string(length, allowed_chars=None):
+    """
+    Generate a random string with given length consisting of the given characters.
+
+    :param length: length of the string
+    :param allowed_chars: string with allowed characters or None
+                          to indicate all 256 byte values should be used
+    :returns: random string
+    """
+    if allowed_chars is None:
+        s = ''.join([chr(random.randint(0, 255)) for dummy in xrange(length)])
+    else:
+        s = ''.join([random.choice(allowed_chars) for dummy in xrange(length)])
+    return s
+
+
+# password stuff
+
+def crypt_password(password, salt=None):
+    """
+    Crypt/Hash a cleartext password
+
+    :param password: cleartext password [unicode]
+    :param salt: salt for the password [str] or None to generate a random salt
+    :rtype: str
+    :returns: the SSHA256 password hash
+    """
+    password = password.encode('utf-8')
+    if salt is None:
+        salt = random_string(32)
+    assert isinstance(salt, str)
+    h = hashlib.new('sha256', password)
+    h.update(salt)
+    return '{SSHA256}' + base64.encodestring(h.digest() + salt).rstrip()
+
+
+def upgrade_password(password, pw_hash):
+    """
+    Upgrade a password to a better hash, if needed
+
+    :param password: cleartext password [unicode]
+    :param pw_hash: password hash (with hash type prefix)
+    :rtype: str
+    :returns: new password hash (or None, if unchanged)
+    """
+    if not pw_hash.startswith('{SSHA256}'):
+        # pw_hash using some old hash method, upgrade to better method
+        return crypt_password(password)
+
+
+def valid_password(password, pw_hash):
+    """
+    Validate a user password.
+
+    :param password: cleartext password to verify [unicode]
+    :param pw_hash: password hash (with hash type prefix)
+    :rtype: bool
+    :returns: password is valid
+    """
+    # encode password
+    pw_utf8 = password.encode('utf-8')
+
+    methods = ['{SSHA256}', '{SSHA}', '{SHA}', '{APR1}', '{MD5}', ]
+    if crypt:
+        # we have the crypt module
+        methods.append('{DES}')
+
+    for method in methods:
+        if pw_hash.startswith(method):
+            d = pw_hash[len(method):]
+            if method == '{SSHA256}':
+                ph = base64.decodestring(d)
+                # ph is of the form "<hash><salt>"
+                salt = ph[32:]
+                h = hashlib.new('sha256', pw_utf8)
+                h.update(salt)
+                enc = base64.encodestring(h.digest() + salt).rstrip()
+            elif method == '{SSHA}':
+                ph = base64.decodestring(d)
+                # ph is of the form "<hash><salt>"
+                salt = ph[20:]
+                h = hashlib.new('sha1', pw_utf8)
+                h.update(salt)
+                enc = base64.encodestring(h.digest() + salt).rstrip()
+            elif method == '{SHA}':
+                h = hashlib.new('sha1', pw_utf8)
+                enc = base64.encodestring(h.digest()).rstrip()
+            elif method == '{APR1}':
+                # d is of the form "$apr1$<salt>$<hash>"
+                salt = d.split('$')[2]
+                enc = md5crypt.apache_md5_crypt(pw_utf8, salt.encode('ascii'))
+            elif method == '{MD5}':
+                # d is of the form "$1$<salt>$<hash>"
+                salt = d.split('$')[2]
+                enc = md5crypt.unix_md5_crypt(pw_utf8, salt.encode('ascii'))
+            elif method == '{DES}':
+                # d is 2 characters salt + 11 characters hash
+                salt = d[:2]
+                enc = crypt.crypt(pw_utf8, salt.encode('ascii'))
+            else:
+                raise ValueError("missing password hash method %s handler" % method)
+
+            return pw_hash == method + enc
+    else:
+        idx = pw_hash.index('}') + 1
+        raise ValueError("unsupported password hash method %r" % pw_hash[:idx])
+
+
+# password recovery token
+
+def generate_token(key=None, stamp=None):
+    """
+    generate a pair of a secret key and a crypto token.
+
+    you can use this to implement a password recovery functionality by
+    calling generate_token() and transmitting the returned token to the
+    (correct) user (e.g. by email) and storing the returned (secret) key
+    into the user's profile on the server side.
+
+    after the user received the token, he returns to the wiki, gives his
+    user name or email address and the token he received. read the (secret)
+    key from the user profile and call valid_token(key, token) to verify
+    if the token is valid. if it is, consider the user authenticated, remove
+    the secret key from his profile and let him reset his password.
+
+    :param key: give it to recompute some specific token for verification
+    :param stamp: give it to recompute some specific token for verification
+    :rtype: 2-tuple
+    :returns: key, token
+    """
+    if key is None:
+        key = random_string(64, "abcdefghijklmnopqrstuvwxyz0123456789")
+    if stamp is None:
+        stamp = int(time.time())
+    h = hmac.new(str(key), str(stamp), digestmod=hashlib.sha1).hexdigest()
+    token = stamp + '-' + h
+    return key, token
+
+
+def valid_token(key, token, timeout=12*60*60):
+    """
+    check if token is valid with respect to the secret key,
+    the token must not be older than timeout seconds.
+
+    :param key: give the secret key to verify the token
+    :param token: the token to verify
+    :rtype: bool
+    :returns: token is valid and not timed out
+    """
+    parts = token.split('-')
+    if len(parts) != 2:
+        return False
+    try:
+        stamp = int(parts[0])
+    except ValueError:
+        return False
+    if stamp + timeout < time.time():
+        return False
+    expected_token = generate_token(key, stamp)[1]
+    return token == expected_token
+
+
+# miscellaneous
+
+def cache_key(**kw):
+    """
+    Calculate a cache key (ascii only)
+
+    Important key properties:
+
+    * The key must be different for different <kw>.
+    * Key is pure ascii
+
+    :param kw: keys/values to compute cache key from
+    """
+    return hashlib.md5(repr(kw)).hexdigest()
+
--- a/MoinMoin/wikiutil.py	Wed Apr 06 16:46:54 2011 +0200
+++ b/MoinMoin/wikiutil.py	Thu Apr 07 20:56:35 2011 +0200
@@ -16,7 +16,6 @@
 import os
 import re
 import time
-import hashlib
 
 from MoinMoin import log
 logging = log.getLogger(__name__)
@@ -356,17 +355,3 @@
             headers.append(('Content-Disposition', content_disposition))
         return headers
 
-
-def cache_key(**kw):
-    """
-    Calculate a cache key (ascii only)
-
-    Important key properties:
-
-    * The key must be different for different <kw>.
-    * Key is pure ascii
-
-    :param kw: keys/values to compute cache key from
-    """
-    return hashlib.md5(repr(kw)).hexdigest()
-