From 8079b586cdd242146bcd7f5e3c235687996dff24 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Corentin=20S=C3=A9chet?= <csechet@entrouvert.com>
Date: Wed, 29 Jun 2022 11:49:11 +0200
Subject: [PATCH] misc: add password strength meter (#63831)

---
 src/authentic2/api_views.py                   | 19 ++++--
 src/authentic2/passwords.py                   | 50 ++++++++++++---
 .../static/authentic2/css/password.scss       | 62 +++++++++++++++++++
 .../static/authentic2/js/password.js          | 44 ++++++++++---
 tests/api/test_all.py                         | 22 ++++---
 5 files changed, 165 insertions(+), 32 deletions(-)

diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py
index e126614d..1e73b9f6 100644
--- a/src/authentic2/api_views.py
+++ b/src/authentic2/api_views.py
@@ -1401,18 +1401,27 @@ class ValidatePasswordAPI(BaseRpcView):
 
     def rpc(self, request, serializer):
         password_checker = get_password_checker()
+        check_report = password_checker(serializer.validated_data['password'])
+        if check_report.strength is not None:
+            return {
+                'result': 1,
+                'ok': check_report.ok,
+                'strength': check_report.strength,
+                'hints': check_report.hints,
+                'strength_label': check_report.strength_label,
+            }, status.HTTP_200_OK
+
         checks = []
-        result = {'result': 1, 'checks': checks}
-        ok = True
-        for check in password_checker(serializer.validated_data['password']):
-            ok = ok and check.result
+        result = {'result': 1, 'checks': checks, 'ok': check_report.ok}
+
+        for check in check_report.checks:
             checks.append(
                 {
                     'result': check.result,
                     'label': check.label,
                 }
             )
-        result['ok'] = ok
+
         return result, status.HTTP_200_OK
 
 
diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py
index 41623d38..8d4f8696 100644
--- a/src/authentic2/passwords.py
+++ b/src/authentic2/passwords.py
@@ -56,6 +56,15 @@ class PasswordChecker(metaclass=abc.ABCMeta):
             self.label = label
             self.result = result
 
+    class CheckReport:
+        def __init__(self, ok, checks=None, strength=None, hints=None):
+            self.ok = ok
+            self.checks = checks
+            self.strength = strength
+            self.hints = hints if hints is not None else []
+            if strength is not None:
+                self.strength_label = [_('Weak'), _('Fair'), _('Good'), _('Strong')][strength]
+
     def __init__(self, *args, **kwargs):
         pass
 
@@ -96,7 +105,23 @@ class DefaultPasswordChecker(PasswordChecker):
         return app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
 
     def __call__(self, password, **kwargs):
-
+        if self.min_strength:
+            if password:
+                result = zxcvbn(password)
+                strength = result['score']
+                return self.CheckReport(
+                    ok=strength >= self.min_strength,
+                    strength=strength,
+                    hints=result['feedback']['suggestions'],
+                )
+
+            return self.CheckReport(ok=False, strength=0)
+        else:
+            checks = list(self.checks(password, **kwargs))
+            ok = all([it.result for it in checks])
+            return self.CheckReport(ok=ok, checks=checks)
+
+    def checks(self, password, **kwargs):
         if self.min_length:
             yield self.Check(
                 result=len(password) >= self.min_length, label=_('%s characters') % self.min_length
@@ -114,13 +139,6 @@ class DefaultPasswordChecker(PasswordChecker):
         if self.regexp and self.regexp_label:
             yield self.Check(result=bool(re.match(self.regexp, password)), label=self.regexp_label)
 
-        if self.min_strength:
-            score = 0
-            if password:
-                score = zxcvbn(password)['score']
-
-            yield self.Check(result=score > self.min_strength, label=_('Secure password'))
-
 
 def get_password_checker(*args, **kwargs):
     return import_string(app_settings.A2_PASSWORD_POLICY_CLASS)(*args, **kwargs)
@@ -134,7 +152,21 @@ def validate_password(password):
 
 def password_help_text(password='', only_errors=False):
     password_checker = get_password_checker()
-    criteria = [check.label for check in password_checker(password) if not (only_errors and check.result)]
+    check_report = password_checker(password)
+    if check_report.strength is not None:
+        hints = ['<div class="a2-password-hint">%s</div>' % hint for hint in check_report.hints]
+        return (
+            '<div class="a2-password-strength-label">'
+            '    <div>%s</div>'
+            '    <div class="a2-password-strength-label-value">%s</div>'
+            '</div>'
+            '<div class="a2-password-strength-meter-container">'
+            '    <div class="a2-password-strength-meter a2-password-strength-0"></div>'
+            '</div>'
+            '<div class="a2-password-hint-container">%s</div>'
+        ) % (_('Password strength:'), check_report.strength_label, ''.join(hints))
+
+    criteria = [check.label for check in check_report.checks if not (only_errors and check.result)]
     if criteria:
         html_criteria = ['<span class="a2-password-policy-rule">%s</span>' % criter for criter in criteria]
         return _(
diff --git a/src/authentic2/static/authentic2/css/password.scss b/src/authentic2/static/authentic2/css/password.scss
index 1bd43853..0141c1a5 100644
--- a/src/authentic2/static/authentic2/css/password.scss
+++ b/src/authentic2/static/authentic2/css/password.scss
@@ -9,6 +9,68 @@
   padding-left: 1.25rem;
 }
 
+.a2-password-hint-container {
+  display: block;
+  margin-top: 0.6em;
+  margin-left: 1.8em;
+}
+
+.a2-password-strength-label {
+  &::after {
+    font-family: FontAwesome;
+    content: "\f00c"; /* ok icon */
+    color: green;
+    visibility: hidden;
+    margin: 1rem;
+  }
+  margin: 0.4rem;
+
+  div {
+    display: inline-block;
+  }
+}
+
+.a2-password-ok.a2-password-strength-label {
+    color: green;
+    &::after {
+      visibility: visible;
+    }
+  }
+
+
+.a2-password-strength-meter-container {
+  margin: 0.2rem 1rem;
+  border: 1px solid;
+}
+
+.a2-password-strength-meter {
+  height: 0.5rem;
+
+  &.a2-password-strength-0 {
+    background: darkred;
+    width: 25%;
+  }
+
+  &.a2-password-strength-1 {
+    background: yellow;
+    width: 50%;
+  }
+
+  &.a2-password-strength-2 {
+    background: green;
+    width: 75%;
+  }
+
+  &.a2-password-strength-3 {
+    background: blue;
+    width: 100%;
+  }
+}
+
+.a2-password-hint-container {
+  margin: 0.2rem 1rem;
+}
+
 .a2-password-policy-container {
   display: block;
   margin-top: 0.6em;
diff --git a/src/authentic2/static/authentic2/js/password.js b/src/authentic2/static/authentic2/js/password.js
index f782b13a..da5c670a 100644
--- a/src/authentic2/static/authentic2/js/password.js
+++ b/src/authentic2/static/authentic2/js/password.js
@@ -37,7 +37,6 @@ a2_password_validate = (function () {
     function get_validation($input) {
         var password = $input.val();
         var $help_text = $input.parent().find('.hint');
-        var $policyContainer = $help_text.find('.a2-password-policy-container');
         $.ajax({
             method: 'POST',
             url: '/api/validate-password/',
@@ -49,16 +48,41 @@ a2_password_validate = (function () {
                     return;
                 }
 
-                $policyContainer.empty();
-                $policyContainer.removeClass('a2-password-ok a2-password-nok');
-                for (var i = 0; i < data.checks.length; i++) {
-                    var error = data.checks[i];
+                if ( data.strength !== undefined) {
+                    var $hintContainer = $help_text.find('.a2-password-hint-container');
+                    var $strengthMeter = $help_text.find('.a2-password-strength-meter');
+                    var $strengthLabel = $help_text.find('.a2-password-strength-label');
+                    var $strengthLabelValue = $help_text.find('.a2-password-strength-label-value');
+                    $strengthLabel.toggleClass('a2-password-ok', data.ok);
+                    $strengthLabel.toggleClass('a2-password-nok', ! data.ok);
+                    $strengthLabelValue.text(data.strength_label)
 
-                    var $rule = $('<span class="a2-password-policy-rule"/>');
-                    $rule.text(error.label)
-                    $rule.appendTo($policyContainer);
-                    $rule.toggleClass('a2-password-ok', error.result);
-                    $rule.toggleClass('a2-password-nok', ! error.result);
+                    $hintContainer.empty()
+                    for (var i = 0; i < 4; ++i) {
+                        $strengthMeter.removeClass('a2-password-strength-' + i);
+                    }
+
+                    $strengthMeter.addClass('a2-password-strength-' + data.strength);
+
+                    for (var i = 0; i < data.hints.length; i++) {
+                        var $hint = $('<div class="a2-password-policy-hint"/>');
+                        $hint.text(data.hints[i])
+                        $hint.appendTo($hintContainer);
+                    }
+                }
+                else {
+                    var $policyContainer = $help_text.find('.a2-password-policy-container');
+                    $policyContainer.empty();
+                    $policyContainer.removeClass('a2-password-ok a2-password-nok');
+                    for (var i = 0; i < data.checks.length; i++) {
+                        var error = data.checks[i];
+
+                        var $rule = $('<span class="a2-password-policy-rule"/>');
+                        $rule.text(error.label)
+                        $rule.appendTo($policyContainer);
+                        $rule.toggleClass('a2-password-ok', error.result);
+                        $rule.toggleClass('a2-password-nok', ! error.result);
+                    }
                 }
             }
         });
diff --git a/tests/api/test_all.py b/tests/api/test_all.py
index 7c251bf1..b47f9f86 100644
--- a/tests/api/test_all.py
+++ b/tests/api/test_all.py
@@ -1672,20 +1672,26 @@ def test_validate_password_regex(app, settings):
 
 
 def test_validate_password_strength(app, settings):
-    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 2
-    response = app.post_json('/api/validate-password/', params={'password': 'w34k P455w0rd'})
+    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
+    response = app.post_json('/api/validate-password/', params={'password': 'P@ssword'})
     assert response.json['result'] == 1
     assert response.json['ok'] is False
-    assert len(response.json['checks']) == 5
-    assert response.json['checks'][4]['label'] == 'Secure password'
-    assert response.json['checks'][4]['result'] is False
+    assert response.json['strength'] == 0
+    assert response.json['strength_label'] == 'Weak'
+    assert len(response.json['hints']) == 3
+    assert response.json['hints'][0] == 'Add another word or two. Uncommon words are better.'
+    assert response.json['hints'][1] == 'Capitalization doesn\'t help very much.'
+    assert (
+        response.json['hints'][2]
+        == 'Predictable substitutions like \'@\' instead of \'a\' don\'t help very much.'
+    )
 
     response = app.post_json('/api/validate-password/', params={'password': 'xbA2E4]#o'})
     assert response.json['result'] == 1
     assert response.json['ok'] is True
-    assert len(response.json['checks']) == 5
-    assert response.json['checks'][4]['label'] == 'Secure password'
-    assert response.json['checks'][4]['result'] is True
+    assert response.json['strength'] == 3
+    assert response.json['strength_label'] == 'Strong'
+    assert len(response.json['hints']) == 0
 
 
 def test_api_users_get_or_create(settings, app, admin):
-- 
2.35.1

