From 4bb8877f3b03255113a9a488cab26037ef45f895 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 | 51 +++++++++++++++++++++++++++++++++----
 tests/api/test_all.py       | 50 ++++++++++++++++++++++--------------
 2 files changed, 77 insertions(+), 24 deletions(-)

diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py
index 3570a018..b337d628 100644
--- a/src/authentic2/passwords.py
+++ b/src/authentic2/passwords.py
@@ -136,15 +136,56 @@ class StrengthReport:
 def get_password_strength(password):
     min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
 
-    hint = _('add more words or characters')
+    hint = _('add more words or characters.')
     strength = 0
     if min_length and len(password) < min_length:
-        hint = _('use at least %s characters' % min_length)
+        hint = _('use at least %s characters.' % min_length)
     elif password:
         report = zxcvbn(password)
         strength = report['score']
-        suggestions = report['feedback']['suggestions']
-        if len(suggestions):
-            hint = report['feedback']['suggestions'][0]
+        hint = get_hint(report['sequence'])
 
     return StrengthReport(strength, hint)
+
+
+def get_hint(matches):
+    matches = sorted(matches, key=lambda m: len(m['token']), reverse=True)
+    for match in matches:
+        hint = get_hint_for_match(match)
+        if hint:
+            return hint
+    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
diff --git a/tests/api/test_all.py b/tests/api/test_all.py
index c21d7f3e..ae27a093 100644
--- a/tests/api/test_all.py
+++ b/tests/api/test_all.py
@@ -1757,35 +1757,47 @@ def test_validate_password_regex(app, settings):
 
 
 @pytest.mark.parametrize(
-    'password,strength,label',
+    'min_length, password,strength,label',
     [
-        ('?', 0, 'Very Weak'),
-        ('?JR!', 1, 'Weak'),
-        ('?JR!p4A', 2, 'Fair'),
-        ('?JR!p4A2i', 3, 'Good'),
-        ('?JR!p4A2i:#', 4, 'Strong'),
+        (0, '?', 0, 'Very Weak'),
+        (0, '?', 0, 'Very Weak'),
+        (0, '?JR!', 1, 'Weak'),
+        (0, '?JR!p4A', 2, 'Fair'),
+        (0, '?JR!p4A2i', 3, 'Good'),
+        (0, '?JR!p4A2i:#', 4, 'Strong'),
+        (12, '?JR!p4A2i:#', 0, 'Very Weak'),
     ],
 )
-def test_password_strength(app, settings, password, strength, label):
-    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
+def test_password_strength(app, settings, min_length, password, strength, label):
+    settings.A2_PASSWORD_POLICY_MIN_LENGTH = min_length
     response = app.post_json('/api/password-strength/', params={'password': password})
     assert response.json['result'] == 1
     assert response.json['strength'] == strength
     assert response.json['strength_label'] == label
 
 
-def test_password_strength_min_length(app, settings):
-    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 10
-
-    response = app.post_json('/api/password-strength/', params={'password': 'too_short'})
-    assert response.json['result'] == 1
-    assert response.json['strength'] == 0
-    assert response.json['strength_label'] == 'Very Weak'
-
-    response = app.post_json('/api/password-strength/', params={'password': 'long_enough'})
+@pytest.mark.parametrize(
+    'min_length, password, hint',
+    [
+        (0, '', 'add more words or characters.'),
+        (0, 'sdfgh', 'avoid straight rows of keys like "sdfgh".'),
+        (0, 'ertgfd', 'avoid short keyboard patterns like "ertgfd".'),
+        (0, 'abab', 'avoid repeated words and characters like "abab".'),
+        (0, 'abcd', 'avoid sequences like "abcd".'),
+        (0, '2019', 'avoid recent years.'),
+        (0, '02/08/14', 'avoid dates and years that are associated with you.'),
+        (0, '02/08/14', 'avoid dates and years that are associated with you.'),
+        (0, 'p@ssword', 'avoid "p@ssword" : it\'s similar to a commonly used password'),
+        (0, 'password', 'avoid "password" : it\'s a commonly used password.'),
+        (42, 'password', 'use at least 42 characters.'),
+    ],
+)
+def test_password_strength_hints(app, settings, min_length, password, hint):
+    settings.A2_PASSWORD_POLICY_MIN_LENGTH = min_length
+    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
+    response = app.post_json('/api/password-strength/', params={'password': password})
     assert response.json['result'] == 1
-    assert response.json['strength'] != 0
-    assert response.json['strength_label'] != 'Very Weak'
+    assert response.json['hint'] == hint
 
 
 def test_api_users_get_or_create(settings, app, admin):
-- 
2.35.1

