Richard Jones avatar Richard Jones committed 0c67c95 Merge

Merged in dstufft/pypi/passlib (pull request #3)

Migrate PyPI to use passlib to store passwords

Comments (0)

Files changed (7)

 - distutils2
 - python-openid
 - raven
+- passlib
+- py-bcrypt (If using the recommended bcrypt hasher)
 
 Quick development setup
 -----------------------
     $ bin/easy_install zope.interface zope.pagetemplate
     $ bin/easy_install zope.tal zope.tales zope.i18nmessageid psycopg2
     $ bin/easy_install docutils M2Crypto python-openid raven
+    $ bin/easy_install passlib py-bcrypt
 
 Then you can launch the server using the pypi.wsgi script::
 
 
 For testing purposes, run tools/mksqlite to create packages.db. Set
 [database]driver to sqlite3, and [database]name to packages.db, then
-run tools/demodata to populate the database.
+run tools/demodata to populate the database.

config.ini.template

 key_dir = .
 simple_sign_script = /serversig
 
+[passlib]
+; The first listed schemed will automatically be the default, see passlib
+;   documentation for a full list of options.
+schemes = bcrypt, bcrypt_sha1, hex_sha1
+
 [logging]
 file =
 mailhost =
 import ConfigParser
 from urlparse import urlsplit, urlunsplit
 
+from passlib.context import CryptContext
+from passlib.registry import register_crypt_handler_path
+
+
+# Register our legacy password handler
+register_crypt_handler_path("bcrypt_sha1", "legacy_passwords")
+
+
 class Config:
     ''' Read in the config and set up the vars with the correct type.
     '''
 
         self.sentry_dsn = c.get('sentry', 'dsn')
 
+        self.passlib = CryptContext(
+                # Unless we've manually specific a list of deprecated
+                #   algorithms assume we will deprecate all but the default.
+                deprecated=["auto"],
+            )
+
+        # Configure a passlib context from the config file
+        self.passlib.load_path(configfile, update=True)
+
     def make_https(self):
         if self.url.startswith("http:"):
             self.url = "https"+self.url[4:]

legacy_passwords.py

+import base64
+import hashlib
+
+import passlib.exc as exc
+import passlib.utils.handlers as uh
+
+from passlib.registry import get_crypt_handler
+from passlib.utils import to_unicode
+from passlib.utils.compat import uascii_to_str
+
+
+passlib_bcrypt = get_crypt_handler("bcrypt")
+
+
+class bcrypt_sha1(uh.StaticHandler):
+
+    name = "bcrypt_sha1"
+    _hash_prefix = u"$bcrypt_sha1$"
+
+    def _calc_checksum(self, secret):
+        # Hash the secret with sha1 first
+        secret = hashlib.sha1(secret).hexdigest()
+
+        # Hash it with bcrypt
+        return passlib_bcrypt.encrypt(secret)
+
+    def to_string(self):
+        assert self.checksum is not None
+        return uascii_to_str(self._hash_prefix + base64.b64encode(self.checksum))
+
+    @classmethod
+    def from_string(cls, hash, **context):
+        # default from_string() which strips optional prefix,
+        # and passes rest unchanged as checksum value.
+        hash = to_unicode(hash, "ascii", "hash")
+        hash = cls._norm_hash(hash)
+        # could enable this for extra strictness
+        ##pat = cls._hash_regex
+        ##if pat and pat.match(hash) is None:
+        ##    raise ValueError("not a valid %s hash" % (cls.name,))
+        prefix = cls._hash_prefix
+        if prefix:
+            if hash.startswith(prefix):
+                hash = hash[len(prefix):]
+            else:
+                raise exc.InvalidHashError(cls)
+
+        # Decode the base64 stored actual hash
+        hash = unicode(base64.b64decode(hash))
+
+        return cls(checksum=hash, **context)
+
+    @classmethod
+    def verify(cls, secret, hash, **context):
+        # NOTE: classes with multiple checksum encodings should either
+        # override this method, or ensure that from_string() / _norm_checksum()
+        # ensures .checksum always uses a single canonical representation.
+        uh.validate_secret(secret)
+        self = cls.from_string(hash, **context)
+        chk = self.checksum
+        if chk is None:
+            raise exc.MissingDigestError(cls)
+
+        # Actually use the verify from passlib_bcrypt after hashing the secret
+        #   with sha1
+        secret = hashlib.sha1(secret).hexdigest()
+        return passlib_bcrypt.verify(secret, chk)
         if self.has_user(name):
             if password:
                 # update existing user, including password
-                password = hashlib.sha1(password).hexdigest()
+                password = self.config.passlib.encrypt(password)
                 safe_execute(cursor,
                    'update users set password=%s, email=%s where name=%s',
                     (password, email, name))
         if cursor.fetchone()[0] > 0:
             raise ValueError, "Email address already belongs to a different user"
 
-        password = hashlib.sha1(password).hexdigest()
+        password = self.config.passlib.encrypt(password)
 
         # new user
         safe_execute(cursor,
                     update users set last_login=current_timestamp where name=%s''', (username,))
         self.userip = userip
 
-    def setpasswd(self, username, password):
-        password = hashlib.sha1(password).hexdigest()
+    def setpasswd(self, username, password, hashed=False):
+        if not hashed:
+            self.config.passlib.encrypt(password)
+
         self.get_cursor().execute('''
             update users set password=%s where name=%s
             ''', (password, username))

tools/upgradepw.py

+#!/usr/bin/python
+import base64
+import os
+import sys
+
+# Workaround current bug in docutils:
+# http://permalink.gmane.org/gmane.text.docutils.devel/6324
+import docutils.utils
+
+
+root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.append(root)
+
+import config
+import store
+import passlib.registry
+
+bcrypt = passlib.registry.get_crypt_handler("bcrypt")
+bcrypt_sha1 = passlib.registry.get_crypt_handler("bcrypt_sha1")
+
+cfg = config.Config(os.path.join(root, "config.ini"))
+st = store.Store(cfg)
+
+print "Migrating passwords to bcrypt_sha1 from unsalted sha1....",
+
+st.open()
+for i, u in enumerate(st.get_users()):
+    user = st.get_user(u['name'])
+    # basic sanity check to allow it to run concurrent with users accessing
+    if len(user['password']) == 40 and "$" not in user["password"]:
+        # Hash the existing sha1 password with bcrypt
+        bcrypted = bcrypt.encrypt(user["password"])
+
+        # Base64 encode the bcrypted password so that it's just a blob of data
+        encoded = base64.b64encode(bcrypted)
+
+        st.setpasswd(user['name'], bcrypt_sha1._hash_prefix + encoded,
+                hashed=True,
+            )
+
+    # Commit every 20 users
+    if not i % 20:
+        st.commit()
+        st.open()
+
+st.commit()
+st.close()
+
+print "[ok]"
         if script_name == '/id':
             return self.run_id()
 
-        # see if the user has provided a username/password
-        auth = self.env.get('HTTP_CGI_AUTHORIZATION', '').strip()
-        if auth:
-            authtype, auth = auth.split(None, 1) # OAuth has many more parameters
-            if authtype.lower() == 'basic':
-                try:
-                    un, pw = base64.decodestring(auth).split(':')
-                except (binascii.Error, ValueError):
-                    # Invalid base64, or not exactly one colon
-                    un = pw = ''
-                if self.store.has_user(un):
-                    pw = hashlib.sha1(pw).hexdigest()
-                    user = self.store.get_user(un)
-                    if pw != user['password']:
-                        raise Unauthorised, 'Incorrect password'
-                    self.username = un
-                    self.authenticated = True
-                    last_login = user['last_login']
-                    # Only update last_login every minute
-                    update_last_login = not last_login or (time.time()-time.mktime(last_login.timetuple()) > 60)
-                    self.store.set_user(un, self.remote_addr, update_last_login)
-        else:
-            un = self.env.get('SSH_USER', '')
-            if un and self.store.has_user(un):
-                user = self.store.get_user(un)
-                self.username = un
-                self.authenticated = self.loggedin = True
-                last_login = user['last_login']
-                # Only update last_login every minute
-                update_last_login = not last_login or (time.time()-time.mktime(last_login.timetuple()) > 60)
-                self.store.set_user(un, self.remote_addr, update_last_login)
-
         # on logout, we set the cookie to "logged_out"
         self.cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
         try:
             # no login time update, since looking for the
             # cookie did that already
             self.store.set_user(name, self.remote_addr, False)
+        else:
+            # see if the user has provided a username/password
+            auth = self.env.get('HTTP_CGI_AUTHORIZATION', '').strip()
+            if auth:
+                authtype, auth = auth.split(None, 1) # OAuth has many more parameters
+                if authtype.lower() == 'basic':
+                    try:
+                        un, pw = base64.decodestring(auth).split(':')
+                    except (binascii.Error, ValueError):
+                        # Invalid base64, or not exactly one colon
+                        un = pw = ''
+                    if self.store.has_user(un):
+                        # Fetch the user from the database
+                        user = self.store.get_user(un)
+
+                        # Verify the hash, and see if it needs migrated
+                        ok, new_hash = self.config.passlib.verify_and_update(pw, user["password"])
+
+                        # If our password didn't verify as ok then raise an
+                        #   error.
+                        if not ok:
+                            raise Unauthorised, 'Incorrect password'
+
+                        if new_hash:
+                            # The new hash needs to be stored for this user.
+                            self.store.setpasswd(un, new_hash, hashed=True)
+
+                        # Login the user
+                        self.username = un
+                        self.authenticated = True
+
+                        # Determine if we need to store the users last login,
+                        #   as we only want to do this once a minute.
+                        last_login = user['last_login']
+                        update_last_login = not last_login or (time.time()-time.mktime(last_login.timetuple()) > 60)
+                        self.store.set_user(un, self.remote_addr, update_last_login)
+            else:
+                un = self.env.get('SSH_USER', '')
+                if un and self.store.has_user(un):
+                    user = self.store.get_user(un)
+                    self.username = un
+                    self.authenticated = self.loggedin = True
+                    last_login = user['last_login']
+                    # Only update last_login every minute
+                    update_last_login = not last_login or (time.time()-time.mktime(last_login.timetuple()) > 60)
+                    self.store.set_user(un, self.remote_addr, update_last_login)
 
         # Commit all user-related changes made up to here
         if self.username:
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.