From 18760dc58143f6ce5a200480702d1fc94410aaa8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Corentin=20S=C3=A9chet?= <csechet@entrouvert.com>
Date: Thu, 1 Sep 2022 23:17:48 +0200
Subject: [PATCH 3/3] misc: generate hints from zxcvbn report (#63831)

---
 src/authentic2/passwords.py | 45 +++++++++++++++++++++++++++++++++-
 tests/api/test_all.py       | 49 ++++++++++++++++++++++++++++---------
 2 files changed, 82 insertions(+), 12 deletions(-)

diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py
index ac74d13b..50d35e8e 100644
--- a/src/authentic2/passwords.py
+++ b/src/authentic2/passwords.py
@@ -122,7 +122,7 @@ class DefaultPasswordChecker(PasswordChecker):
         elif password:
             report = zxcvbn(password)
             strength = report['score']
-            hints = report['feedback']['suggestions']
+            hints = get_hints(report['sequence'])
 
         return self.StrengthReport(strength >= self.min_strength, strength, hints)
 
@@ -145,6 +145,49 @@ class DefaultPasswordChecker(PasswordChecker):
             yield self.Check(result=bool(re.match(self.regexp, password)), label=self.regexp_label)
 
 
+def get_hints(matches):
+    matches = sorted(matches, key=lambda m: len(m['token']), reverse=True)
+    hints = [get_hint_for_match(m) for m in matches]
+    hints = list(h for h in hints if h)
+    if hints:
+        return hints
+    return [_('Use a longer password.')]
+
+
+def get_hint_for_match(match):
+    pattern = match['pattern']
+    hint = None
+    if pattern == 'spatial':
+        if match['turns'] == 1:
+            hint = _('Avoid straight rows of keys like "{token}".')
+        else:
+            hint = _('Avoid short keyboard patterns like "{token}".')
+
+    if pattern == 'repeat':
+        hint = _('Avoid repeated words and characters like "{token}".')
+
+    if pattern == 'sequence':
+        hint = _('Avoid sequences like "{token}".')
+
+    if pattern == 'regex':
+        if match['regex_name'] == 'recent_year':
+            hint = _('Avoid recent years.')
+
+    if pattern == 'date':
+        hint = _('Avoid dates and years that are associated with you.')
+
+    if pattern == 'dictionary':
+        if match['l33t'] or match['reversed']:
+            hint = _('Avoid "{token}" : it\'s similar to a commonly used password')
+        else:
+            hint = _('Avoid "{token}" : it\'s a commonly used password.')
+
+    if hint is not None:
+        return hint.format(token=match['token'])
+
+    return None
+
+
 def get_password_checker(*args, **kwargs):
     return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs)
 
diff --git a/tests/api/test_all.py b/tests/api/test_all.py
index dc30c24e..6c5f2a7c 100644
--- a/tests/api/test_all.py
+++ b/tests/api/test_all.py
@@ -1756,7 +1756,17 @@ def test_validate_password_regex(app, settings):
     assert response.json['checks'][4]['result'] is True
 
 
-def test_validate_password_strength(app, settings):
+@pytest.mark.parametrize(
+    'password, strength',
+    [
+        ('?', 0),
+        ('?JR!', 1),
+        ('?JR!p4"', 2),
+        ('?JR!p4A2"', 3),
+        ('?JR!p4A2i:"', 4),
+    ],
+)
+def test_validate_password_strength(app, settings, password, strength):
     settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
     response = app.post_json('/api/validate-password/', params={'password': 'short'})
     assert response.json['result'] == 1
@@ -1764,19 +1774,36 @@ def test_validate_password_strength(app, settings):
     assert response.json['ok'] is False
     assert response.json['hints'] == ['Use at least 8 characters']
 
-    response = app.post_json('/api/validate-password/', params={'password': 'w34k P455w0rd'})
+    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
+    settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0
+    response = app.post_json('/api/validate-password/', params={'password': password})
     assert response.json['result'] == 1
-    assert response.json['ok'] is False
-    assert response.json['hints'] == [
-        'Add another word or two. Uncommon words are better.',
-        'Capitalization doesn\'t help very much.',
-        'Predictable substitutions like \'@\' instead of \'a\' don\'t help very much.',
-    ]
+    assert response.json['ok'] == (strength >= 3)
+    assert response.json['strength_label'] == ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'][strength]
+    assert response.json['strength'] == strength
+
 
-    response = app.post_json('/api/validate-password/', params={'password': 'xbA2E4]#o'})
+@pytest.mark.parametrize(
+    'password, hint',
+    [
+        ('sdfgh', 'Avoid straight rows of keys like "sdfgh".'),
+        ('ertgfd', 'Avoid short keyboard patterns like "ertgfd".'),
+        ('abab', 'Avoid repeated words and characters like "abab".'),
+        ('abcd', 'Avoid sequences like "abcd".'),
+        ('2019', 'Avoid recent years.'),
+        ('02/08/14', 'Avoid dates and years that are associated with you.'),
+        ('02/08/14', 'Avoid dates and years that are associated with you.'),
+        ('p@ssword', 'Avoid "p@ssword" : it\'s similar to a commonly used password'),
+        ('password', 'Avoid "password" : it\'s a commonly used password.'),
+    ],
+)
+def test_validate_password_hints(app, settings, password, hint):
+    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
+    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
+    settings.A2_PASSWORD_POLICY_MIN_CLASSES = 0
+    response = app.post_json('/api/validate-password/', params={'password': password})
     assert response.json['result'] == 1
-    assert response.json['ok'] is True
-    assert response.json['hints'] == []
+    assert response.json['hints'] == [hint]
 
 
 def test_api_users_get_or_create(settings, app, admin):
-- 
2.35.1

