Repository: Mailu/Mailu

Issue:
Implement a credential cache (alternative to #1758)
## What type of PR?

Feature: it implements a credential cache to speedup authentication requests.

## What does this PR do?

Credentials are stored in cold-storage using a slow, salted/iterated hash function to prevent offline bruteforce attacks. This creates a performance bottleneck for no valid reason (see the
rationale/long version on https://github.com/Mailu/Mailu/issues/1194#issuecomment-762115549).

The new credential cache makes things fast again.

Keep in mind that the cache is local to each gunicorn.

There is an argument to be said about making it less configurable (remove the new knobs, no new dependency) ... that is at #1758

### Related issue(s)
- close #1411
- close #1194 
- close #1758

## Prerequistes
Before we can consider review and merge, please make sure the following list is done and checked.
If an entry in not applicable, you can check it or remove it from the list.

- [x] In case of feature or enhancement: documentation updated accordingly
- [x] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/guide.html#changelog) entry file.



Discussion:
Thanks for submitting this pull request.
Bors-ng will now build test images. When it succeeds, we will continue to review and test your PR.

bors try

Note: if this build fails, [read this](http://mailu.io/master/contributors/environment.html#when-bors-try-fails).
## try

Build succeeded:
  * [continuous-integration/travis-ci/push](https://travis-ci.org/github/Mailu/Mailu/builds/757953464?utm_source=github_status&utm_medium=notification)
@micw Can you review this PR please? Are you okay with flask_caching as a new dependency?

Resolving patch:
diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py
--- a/core/admin/mailu/models.py
+++ b/core/admin/mailu/models.py
@@ -305,6 +305,7 @@ class User(Base, Email):
     """
     __tablename__ = "user"
     _ctx = None
+    _credential_cache = {}
 
     domain = db.relationship(Domain,
         backref=db.backref('users', cascade='all, delete-orphan'))
@@ -382,6 +383,17 @@ def get_password_context():
         return User._ctx
 
     def check_password(self, password):
+        cache_result = self._credential_cache.get(self.get_id())
+        current_salt = self.password.split('$')[3] if len(self.password.split('$')) == 5 else None
+        if cache_result and current_salt:
+            cache_salt, cache_hash = cache_result
+            if cache_salt == current_salt:
+                return hash.pbkdf2_sha256.verify(password, cache_hash)
+            else:
+                # the cache is local per gunicorn; the password has changed
+                # so the local cache can be invalidated
+                del self._credential_cache[self.get_id()]
+
         reference = self.password
         # strip {scheme} if that's something mailu has added
         # passlib will identify *crypt based hashes just fine
@@ -396,6 +408,17 @@ def check_password(self, password):
             self.password = new_hash
             db.session.add(self)
             db.session.commit()
+
+        if result:
+            """The credential cache uses a low number of rounds to be fast.
+While it's not meant to be persisted to cold-storage, no additional measures
+are taken to ensure it isn't (mlock(), encrypted swap, ...) on the basis that
+we have little control over GC and string interning anyways.
+
+ An attacker that can dump the process' memory is likely to find credentials
+in clear-text regardless of the presence of the cache.
+            """
+            self._credential_cache[self.get_id()] = (self.password.split('$')[3], hash.pbkdf2_sha256.using(rounds=1).hash(password))
         return result
 
     def set_password(self, password, hash_scheme=None, raw=False):


Test patch: