Repository: hedyorg/hedy

Issue:
bcrypt work factor is killing our poor Heroku box
We have `bcrypt` at the default work factor of 12, which is a bit steep for our single-core Heroku box. 

Right now, any bcrypt hash operation takes about ~`300`ms:

```
$ time python -c 'import bcrypt; bcrypt.hashpw(bytes("asdf1234", "utf-8"), bcrypt.gensalt())'

real	0m0.354s
user	0m0.340s
sys	0m0.012s
```

Keeping in mind that the box has only a single processor and so can only do one thing at a time, 3 logins/signups/password resets/any bcrypt operation will block anyone else from submitting a program to the web server or doing anything else for a full second [0]. This is already leading to noticeable load and latency spikes on the metrics dashboard.

Yes, we could spring for more cores, but we're on a bit of a budget and would like to stretch our single-core machine as far as it'll go.

We should reduce the work factor of the `bcrypt` operations to something more reasonable, like 9:

```
~ $ time python -c 'import bcrypt; bcrypt.hashpw(bytes("asdf1234", "utf-8"), bcrypt.gensalt(rounds=9))'

real	0m0.084s
user	0m0.080s
sys	0m0.000s
```

This is going to require some code to check the load factor of a user's existing salt and regenerate, re-hash and re-store their password if their current salt does not match our current desired work factor.


> [0] Not entirely of course, because we have a couple of threads running and not all work is purely CPU-bound, some of the work is I/O bound like reading files from disk or reading things from DynamoDB... but the core work of the Hedy server is compiling code which is *also* CPU bound so we're definitely contending with the main purpose of the web app.


Discussion:
Hi @rix0rrr !

This can definitely give some oxygen to the Heroku box. While I'd rather use the default load factor and I think we'll have to get multiple cores sooner than later, it could be worth to do this in the meantime.

If I understand correctly, the implementation would require:
- Determining the desired load factor in the config (I'd put it in config.py).
- The signup & change password logic would use the desired load factor when computing a hash.
- On user login, we'd check if the current load factor of the password matches the desired one. If it doesn't, we recompute the hash and update it on the database. The reason for doing this on login is that for recomputing hashes we need the original password, which we by design don't store.
@fpereiro Yes those are exactly the steps that are needed Happy if you can take this up!
@Felienne will do!

Resolving patch:
diff --git a/auth.py b/auth.py
--- a/auth.py
+++ b/auth.py
@@ -4,7 +4,7 @@
 import urllib
 from flask import request, make_response, jsonify, redirect
 from flask_helpers import render_template
-from utils import type_check, object_check, timems, times, db_get, db_set, db_del, db_del_many, db_scan, db_describe, db_get_many
+from utils import type_check, object_check, timems, times, db_get, db_set, db_del, db_del_many, db_scan, db_describe, db_get_many, extract_bcrypt_rounds
 import datetime
 from functools import wraps
 from config import config
@@ -24,7 +24,7 @@ def check_password (password, hash):
     return bcrypt.checkpw (bytes (password, 'utf-8'), bytes (hash, 'utf-8'))
 
 def make_salt ():
-    return bcrypt.gensalt ().decode ('utf-8')
+    return bcrypt.gensalt (rounds=config ['bcrypt_rounds']).decode ('utf-8')
 
 @querylog.timed
 def hash (password, salt):
@@ -101,9 +101,17 @@ def login ():
         if not check_password (body ['password'], user ['password']):
             return 'invalid username/password', 403
 
+        # If the number of bcrypt rounds has changed, create a new hash.
+        new_hash = None
+        if config ['bcrypt_rounds'] != extract_bcrypt_rounds (user ['password']):
+            new_hash = hash (body ['password'], make_salt ())
+
         cookie = make_salt ()
         db_set ('tokens', {'id': cookie, 'username': user ['username'], 'ttl': times () + session_length})
-        db_set ('users', {'username': user ['username'], 'last_login': timems ()})
+        if new_hash:
+            db_set ('users', {'username': user ['username'], 'password': new_hash, 'last_login': timems ()})
+        else:
+            db_set ('users', {'username': user ['username'], 'last_login': timems ()})
         resp = make_response ({})
         # We set the cookie to expire in a year, just so that the browser won't invalidate it if the same cookie gets renewed by constant use.
         # The server will decide whether the cookie expires.
diff --git a/config.py b/config.py
--- a/config.py
+++ b/config.py
@@ -15,6 +15,8 @@
         'sender': 'Hedy <hedy@felienne.com>',
         'region': 'eu-central-1',
     },
+    # The bcrypt library's default is 12
+    'bcrypt_rounds': 9,
     'dynamodb': {
         'region': 'eu-west-1'
     },
diff --git a/utils.py b/utils.py
--- a/utils.py
+++ b/utils.py
@@ -5,6 +5,7 @@
 from boto3.dynamodb.conditions import Key, Attr
 import functools
 import os
+import re
 from ruamel import yaml
 import querylog
 
@@ -273,8 +274,10 @@ def slash_join(*args):
         ret.append(arg.lstrip('/') if ret else arg)
     return ''.join(ret)
 
+def extract_bcrypt_rounds (hash):
+    return int (re.match ('\$2b\$\d+', hash) [0].replace ('$2b$', ''))
 
 def isoformat(timestamp):
     """Turn a timestamp into an ISO formatted string."""
     dt = datetime.datetime.utcfromtimestamp(timestamp)
-    return dt.isoformat() + 'Z'
\ No newline at end of file
+    return dt.isoformat() + 'Z'


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,9 +1,17 @@
 import unittest
 import utils
-
+import bcrypt
 
 class TestUtils(unittest.TestCase):
   def test_slashjoin(self):
     self.assertEqual('http://hedycode.com/banaan/xyz', utils.slash_join('http://hedycode.com/', '/banaan', '', '/xyz'))
     self.assertEqual('/one/two', utils.slash_join('/one/two'))
     self.assertEqual('/one/two', utils.slash_join(None, '/one/two'))
+
+  def test_extract_rounds(self):
+    salt = bcrypt.gensalt (rounds=7).decode ('utf-8')
+    self.assertEqual(7, utils.extract_bcrypt_rounds(salt))
+
+  def test_extract_default_rounds(self):
+    salt = bcrypt.gensalt ().decode ('utf-8')
+    self.assertEqual(12, utils.extract_bcrypt_rounds(salt))