From d2404766bb8d9cd44391834c1e337295c4e67d09 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Sun, 8 Sep 2019 00:45:30 +0200
Subject: [PATCH] fields: use django hashers to support more hashed passwords
 formats (#35533)

---
 tests/test_form_pages.py | 53 ++++++++++++++++++++++++++++++++++------
 wcs/fields.py            | 19 ++++++++++++++
 wcs/qommon/form.py       | 31 +++++++++++++++++++++++
 3 files changed, 96 insertions(+), 7 deletions(-)

diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py
index 0b13ade6..f53e011e 100644
--- a/tests/test_form_pages.py
+++ b/tests/test_form_pages.py
@@ -19,6 +19,14 @@ try:
 except ImportError:
     Image = None
 
+import django
+from django.contrib.auth.hashers import (
+    BCryptPasswordHasher, BCryptSHA256PasswordHasher, CryptPasswordHasher,
+    MD5PasswordHasher, PBKDF2PasswordHasher, PBKDF2SHA1PasswordHasher,
+    SHA1PasswordHasher, UnsaltedMD5PasswordHasher, UnsaltedSHA1PasswordHasher)
+if django.VERSION >= (1, 10, 0):
+    from django.contrib.auth.hashers import Argon2PasswordHasher
+
 from quixote.http_request import Upload as QuixoteUpload
 from wcs.qommon.emails import docutils
 from wcs.qommon.form import UploadedFile
@@ -1936,8 +1944,17 @@ def test_form_direct_draft_access(pub):
 def form_password_field_submit(app, password):
     formdef = create_formdef()
     formdef.enable_tracking_codes = True
-    formdef.fields = [fields.PasswordField(id='0', label='password',
-        formats=['sha1', 'md5', 'cleartext'])]
+    formats=['sha1', 'md5', 'cleartext',
+             'bcrypt', 'bcrypt_sha256', 'crypt', 'pbkdf2_sha1', 'pbkdf2_sha256',
+             'salted_md5', 'salted_sha1', 'unsalted_md5', 'unsalted_sha1']
+    if django.VERSION >= (1, 10, 0):
+        try:
+            Argon2PasswordHasher().encode('x', Argon2PasswordHasher().salt())
+        except ValueError, AttributeError:
+            pass
+        else:
+            formats.append('argon2')
+    formdef.fields = [fields.PasswordField(id='0', label='password', formats=formats)]
     formdef.store()
     page = app.get('/test/')
     formdef.data_class().wipe()
@@ -1954,11 +1971,33 @@ def form_password_field_submit(app, password):
     assert formdef.data_class().count() == 1
     data_id = formdef.data_class().select()[0].id
     data = formdef.data_class().get(data_id)
-    assert data.data == {'0': {
-        'sha1': hashlib.sha1(password).hexdigest(),
-        'md5': hashlib.md5(password).hexdigest(),
-        'cleartext': unicode(password, 'utf-8'),
-        }}
+    assert data.data['0']['sha1'] == hashlib.sha1(password).hexdigest()
+    assert data.data['0']['md5'] == hashlib.md5(password).hexdigest()
+    assert data.data['0']['cleartext'] == unicode(password, 'utf-8')
+    assert set(formats) == set(data.data['0'].keys())
+    for (key, val) in data.data['0'].items():
+        if key == 'argon2':
+            assert Argon2PasswordHasher().verify(password, val)
+        elif key == 'bcrypt':
+            assert BCryptPasswordHasher().verify(password, val)
+        elif key == 'bcrypt_sha256':
+            assert BCryptSHA256PasswordHasher().verify(password, val)
+        elif key == 'crypt':
+            assert CryptPasswordHasher().verify(password, val)
+        elif key == 'pbkdf2_sha1':
+            assert PBKDF2SHA1PasswordHasher().verify(password, val)
+        elif key == 'pbkdf2_sha256':
+            assert PBKDF2PasswordHasher().verify(password, val)
+        elif key == 'salted_md5':
+            algorithm, salt, hash = val.split('$', 2)
+            assert val == MD5PasswordHasher().encode(password, salt.encode())
+        elif key == 'salted_sha1':
+            algorithm, salt, hash = val.split('$', 2)
+            assert val == SHA1PasswordHasher().encode(password, salt.encode())
+        elif key == 'unsalted_md5':
+            assert UnsaltedMD5PasswordHasher().verify(password, val)
+        elif key == 'unsalted_sha1':
+            assert UnsaltedSHA1PasswordHasher().verify(password, val)
 
 def test_form_password_field_submit(pub):
     user = create_user(pub)
diff --git a/wcs/fields.py b/wcs/fields.py
index 717cc8c6..20a6e94e 100644
--- a/wcs/fields.py
+++ b/wcs/fields.py
@@ -28,6 +28,9 @@ import collections
 from quixote import get_request, get_publisher
 from quixote.html import htmltext, TemplateIO
 
+import django
+if django.VERSION >= (1, 10, 0):
+    from django.contrib.auth.hashers import Argon2PasswordHasher
 from django.utils.encoding import smart_text
 from django.utils.formats import date_format as django_date_format
 from django.utils.html import urlize
@@ -2301,7 +2304,23 @@ class PasswordField(WidgetField):
         formats = [('cleartext', _('Clear text')),
             ('md5', _('MD5')),
             ('sha1', _('SHA1')),
+            ('bcrypt', _('BCRYPT')),
+            #('bcrypt_sha256', _('BCRYPT SHA256')),
+            ('crypt', _('CRYPT')),
+            #('pbkdf2_sha1', _('PBKDF2 SHA1')),
+            ('pbkdf2_sha256', _('PBKDF2')),
+            #('salted_md5', _('salted MD5')),
+            ('salted_sha1', _('salted SHA1')),
+            #('unsalted_md5', _('unsalted MD5')),
+            #('unsalted_sha1', _('unsalted SHA1')),
             ]
+        if django.VERSION >= (1, 10, 0):
+            try:
+                Argon2PasswordHasher().encode('x', Argon2PasswordHasher().salt())
+            except ValueError, AttributeError:
+                pass
+            else:
+                formats.append(('argon2', _('ARGON2')))
         form.add(CheckboxesWidget, 'formats', title=_('Storage formats'),
                 value=self.formats, options=formats, inline=True)
         form.add(IntWidget, 'min_length', title=_('Minimum length'),
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index c882fbf9..db55a931 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -65,7 +65,14 @@ from quixote.util import randbytes
 from django.utils.six.moves.html_parser import HTMLParser
 from django.utils.six import StringIO
 
+import django
 from django.conf import settings
+from django.contrib.auth.hashers import (
+    BCryptPasswordHasher, BCryptSHA256PasswordHasher, CryptPasswordHasher,
+    MD5PasswordHasher, PBKDF2PasswordHasher, PBKDF2SHA1PasswordHasher,
+    SHA1PasswordHasher, UnsaltedMD5PasswordHasher, UnsaltedSHA1PasswordHasher)
+if django.VERSION >= (1, 10, 0):
+    from django.contrib.auth.hashers import Argon2PasswordHasher
 from django.utils.safestring import mark_safe
 
 from .template import render as render_template, Template, TemplateError
@@ -2180,11 +2187,35 @@ $(function() {
             self.get_widget('pwd1').set_error(' '.join(set_errors))
             pwd1 = None
 
+        def make_encoder(cls):
+            hasher = cls()
+            def encoder(password):
+                salt = hasher.salt().encode()  # encode needed for salted md5 and sha1 
+                return hasher.encode(password, salt)
+            return encoder
+
         PASSWORD_FORMATS = {
             'cleartext': lambda x: x,
             'md5': lambda x: hashlib.md5(x).hexdigest(),
             'sha1': lambda x: hashlib.sha1(x).hexdigest(),
+            'bcrypt': make_encoder(BCryptPasswordHasher),
+            'bcrypt_sha256': make_encoder(BCryptSHA256PasswordHasher),
+            'crypt': make_encoder(CryptPasswordHasher),
+            'pbkdf2_sha1': make_encoder(PBKDF2SHA1PasswordHasher),
+            'pbkdf2_sha256': make_encoder(PBKDF2PasswordHasher),
+            'salted_md5': make_encoder(MD5PasswordHasher),
+            'salted_sha1': make_encoder(SHA1PasswordHasher),
+            'unsalted_md5': make_encoder(UnsaltedMD5PasswordHasher),
+            'unsalted_sha1': make_encoder(UnsaltedSHA1PasswordHasher),
         }
+        if django.VERSION >= (1, 10, 0):
+            encoder = make_encoder(Argon2PasswordHasher)
+            try:
+                encoder('x')
+            except ValueError, AttributeError:
+                pass
+            else:
+                PASSWORD_FORMATS.update({'argon2': encoder})
 
         if pwd1:
             self.value = {}
-- 
2.23.0.rc1

