From e512669d22543536236f7b33594f1dbf73c3723f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Corentin=20S=C3=A9chet?= <csechet@entrouvert.com>
Date: Thu, 1 Sep 2022 00:55:49 +0200
Subject: [PATCH 1/2] misc: add password strength meter in NewPasswordInput
 (#63831)

---
 src/authentic2/api_views.py                   | 18 ++++-
 src/authentic2/forms/widgets.py               |  5 +-
 src/authentic2/passwords.py                   | 53 ++++++++++---
 .../static/authentic2/css/password.scss       | 79 +++++++++++++++++++
 .../static/authentic2/js/password.js          | 60 +++++++++++---
 .../authentic2/widgets/new_password.html      | 42 ++++++++--
 tests/api/test_all.py                         | 17 ++--
 7 files changed, 228 insertions(+), 46 deletions(-)

diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py
index 7b545fe2..4e6af0c8 100644
--- a/src/authentic2/api_views.py
+++ b/src/authentic2/api_views.py
@@ -1462,10 +1462,22 @@ class ValidatePasswordAPI(BaseRpcView):
 
     def rpc(self, request, serializer):
         password_checker = get_password_checker()
+        report = password_checker(serializer.validated_data['password'])
+
+        if hasattr(report, 'strength'):
+            result = {
+                'result': 1,
+                'ok': report.ok,
+                'strength': report.strength,
+                'strength_label': report.strength_label,
+                'hint': report.hint,
+            }
+            return result, status.HTTP_200_OK
+
         checks = []
-        result = {'result': 1, 'checks': checks}
+        result = {'result': 1, 'ok': report.ok, 'checks': checks}
         ok = True
-        for check in password_checker(serializer.validated_data['password']):
+        for check in report.checks:
             ok = ok and check.result
             checks.append(
                 {
@@ -1473,7 +1485,7 @@ class ValidatePasswordAPI(BaseRpcView):
                     'label': check.label,
                 }
             )
-        result['ok'] = ok
+
         return result, status.HTTP_200_OK
 
 
diff --git a/src/authentic2/forms/widgets.py b/src/authentic2/forms/widgets.py
index 90150967..c19abdd6 100644
--- a/src/authentic2/forms/widgets.py
+++ b/src/authentic2/forms/widgets.py
@@ -276,8 +276,9 @@ class NewPasswordInput(PasswordInput):
     def get_context(self, *args, **kwargs):
         context = super().get_context(*args, **kwargs)
         password_checker = get_password_checker()
-        checks = list(password_checker(''))
-        context['checks'] = checks
+        report = password_checker('')
+        context['report'] = report
+        context['use_password_strength'] = hasattr(report, 'strength')
         return context
 
     def render(self, name, value, attrs=None, renderer=None):
diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py
index 29cb3440..3c37b1d7 100644
--- a/src/authentic2/passwords.py
+++ b/src/authentic2/passwords.py
@@ -55,13 +55,27 @@ class PasswordChecker(metaclass=abc.ABCMeta):
             self.label = label
             self.result = result
 
-    def __init__(self, *args, **kwargs):
-        pass
+    class ChecksReport:
+        def __init__(self, checks):
+            self.checks = list(checks)
+
+        @property
+        def ok(self):
+            return all([check.result for check in self.checks])
+
+    class StrengthReport:
+        def __init__(self, ok, strength, hint):
+            self.ok = ok
+            self.strength = strength
+            if strength is not None:
+                self.strength_label = [_('Very Weak'), _('Weak'), _('Fair'), _('Good'), _('Strong')][strength]
+            self.hint = hint
 
     @abc.abstractmethod
     def __call__(self, password, **kwargs):
         """Return an iterable of Check objects giving the list of checks and
-        their result."""
+        their result or a StrengthReport giving password strength and hints
+        to improve it."""
         return []
 
 
@@ -95,7 +109,28 @@ class DefaultPasswordChecker(PasswordChecker):
         return app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
 
     def __call__(self, password, **kwargs):
-
+        if self.min_strength:
+            return self.password_strength(password, **kwargs)
+        else:
+            return self.ChecksReport(self.checks(password, **kwargs))
+
+    def password_strength(self, password, **kwargs):
+        hint = ''
+        strength = 0
+        if self.min_length and len(password) < self.min_length:
+            hint = _('use at least 8 characters')
+        elif password:
+            report = zxcvbn(password)
+            strength = report['score']
+            suggestions = report['feedback']['suggestions']
+            if len(suggestions):
+                hint = report['feedback']['suggestions'][0]
+            else:
+                hint = _('add more words or characters')
+
+        return self.StrengthReport(strength >= self.min_strength, strength, hint)
+
+    def checks(self, password, **kwargs):
         if self.min_length:
             yield self.Check(
                 result=len(password) >= self.min_length, label=_('%s characters') % self.min_length
@@ -113,13 +148,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)
@@ -127,6 +155,5 @@ def get_password_checker(*args, **kwargs):
 
 def validate_password(password):
     password_checker = get_password_checker()
-    errors = [not check.result for check in password_checker(password)]
-    if any(errors):
+    if not password_checker(password).ok:
         raise ValidationError(_('This password is not accepted.'))
diff --git a/src/authentic2/static/authentic2/css/password.scss b/src/authentic2/static/authentic2/css/password.scss
index 3f729d66..9bb669d7 100644
--- a/src/authentic2/static/authentic2/css/password.scss
+++ b/src/authentic2/static/authentic2/css/password.scss
@@ -14,6 +14,7 @@
   padding-left: 1.25rem;
 }
 
+/* Password classes checks */
 .a2-password-policy-container {
   display: block;
   margin-top: 0.6em;
@@ -44,6 +45,84 @@
   visibility: visible;
 }
 
+/* Password strength meter & hints */
+.a2-password-feedback {
+  font-size: 90%;
+  margin-bottom: 1.8em;
+}
+
+.a2-password-strength {
+  margin: 0.4rem 0;
+  display: none;
+
+  &--label {
+    display: inline;
+    font-weight: bold;
+  }
+
+  &--name {
+    display: inline;
+  }
+
+  &--gauge {
+    display: flex;
+    height: 0.7rem;
+    margin: 0.2rem 0;
+  }
+
+  &--bar {
+    flex-grow: 1;
+    &:not(:last-child) {
+      margin-right: 0.4rem;
+    }
+  }
+
+  &.strength-0 &--bar { background: darkred; }
+  &.strength-1 &--bar { background: orange; }
+  &.strength-2 &--bar { background: yellow; }
+  &.strength-3 &--bar { background: yellowgreen; }
+  &.strength-4 &--bar { background: darkgreen; }
+
+  @for $i from 0 through 4 {
+    &.strength-#{$i} { display: block; }
+    &.strength-#{$i} &--bar:nth-child(n+#{$i + 2}) {
+      background: transparent;
+    }
+  }
+}
+
+.a2-password-hint {
+  font-size: 90%;
+
+  &.a2-password-hidden {
+    display: none;
+  }
+
+  &--text {
+    margin: 0.4rem 0;
+    display: inline-block;
+  }
+
+  &--to-weak {
+    display: inline;
+    .a2-password-ok & {
+      display: none;
+    }
+  }
+
+  &--ok {
+    display: none;
+    .a2-password-ok & {
+      display: inline;
+    }
+  }
+
+  &--content {
+    font-weight: bold;
+    display: inline;
+  }
+}
+
 /* Equality check */
 
 .a2-password-nok .a2-password-check-equality-default,
diff --git a/src/authentic2/static/authentic2/js/password.js b/src/authentic2/static/authentic2/js/password.js
index f83367e8..3b05b7bf 100644
--- a/src/authentic2/static/authentic2/js/password.js
+++ b/src/authentic2/static/authentic2/js/password.js
@@ -38,6 +38,49 @@ a2_password_validate = (function () {
         var password = $input.val();
         var $help_text = $input.parent().find('.a2-password-policy-hint');
         var $policyContainer = $help_text.find('.a2-password-policy-container');
+
+        function update_checks(data) {
+            if (! data.result) {
+                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];
+
+                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);
+            }
+        }
+
+        var $feedback = $input.parent().find('.a2-password-feedback')
+        var $hint = $feedback.find('.a2-password-hint');
+        var $hint_content = $feedback.find('.a2-password-hint--content');
+        var $strength = $feedback.find('.a2-password-strength');
+        var $strength_name = $feedback.find('.a2-password-strength--name');
+
+
+        function update_strength(data) {
+            $hint.toggleClass('a2-password-ok', data.ok);
+            $hint.toggleClass('a2-password-hidden', password == '' || data.strength == 4);
+            $hint.toggleClass('errornotice', !data.ok);
+            $hint.toggleClass('infonotice', data.ok);
+            $hint_content.text(data.hint)
+
+            if(data.strength !== undefined) {
+                for (var i = 0; i < 5; ++i) {
+                    $strength.removeClass('strength-' + i);
+                }
+
+                $strength.addClass('strength-' + data.strength);
+                $strength_name.text(data.strength_label);
+            }
+        }
+
         $.ajax({
             method: 'POST',
             url: '/api/validate-password/',
@@ -45,20 +88,11 @@ a2_password_validate = (function () {
             dataType: 'json',
             contentType: 'application/json; charset=utf-8',
             success: function(data) {
-                if (! data.result) {
-                    return;
+                if(data.strength !== undefined) {
+                    update_strength(data)
                 }
-
-                $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);
+                else {
+                    update_checks(data)
                 }
             }
         });
diff --git a/src/authentic2/templates/authentic2/widgets/new_password.html b/src/authentic2/templates/authentic2/widgets/new_password.html
index acbca015..39bccc53 100644
--- a/src/authentic2/templates/authentic2/widgets/new_password.html
+++ b/src/authentic2/templates/authentic2/widgets/new_password.html
@@ -1,10 +1,38 @@
 {% load i18n %}
 {% include "django/forms/widgets/input.html" %}
-<div class="a2-password-policy-hint">
-  {% trans "In order to create a secure password, please use at least :" %}
-  <div class="a2-password-policy-container">
-    {% for check in checks %}
-      <span class="a2-password-policy-rule">{{ check.label }}</span>
-    {% endfor %}
+{% if use_password_strength %}
+  <div class="a2-password-feedback">
+    <div class="a2-password-strength strength-0">
+      <p class="a2-password-strength--label">{% trans "Password strength :" %}
+        <div class="a2-password-strength--name">{{ report.strength_label }}</div>
+      </p>
+      <div class="a2-password-strength--gauge">
+        <div class="a2-password-strength--bar"></div>
+        <div class="a2-password-strength--bar"></div>
+        <div class="a2-password-strength--bar"></div>
+        <div class="a2-password-strength--bar"></div>
+        <div class="a2-password-strength--bar"></div>
+      </div>
+    </div>
+    <div class="a2-password-hint errornotice a2-password-hidden">
+      <div class="a2-password-hint--text">
+        <div class="a2-password-hint--to-weak">
+          {% trans "Your password is too weak. To create a secure password, please " %}
+        </div>
+        <div class="a2-password-hint--ok">
+          {% trans "Your password is strong enough. To create an even more secure password, you could " %}
+        </div>
+        <div class="a2-password-hint--content">{{ report.hint }}</div>
+      </div>
+    </div>
   </div>
-</div>
+{% else %}
+  <div class="a2-password-policy-hint">
+    {% trans "In order to create a secure password, please use at least :" %}
+    <div class="a2-password-policy-container">
+      {% for check in report.checks %}
+        <span class="a2-password-policy-rule">{{ check.label }}</span>
+      {% endfor %}
+    </div>
+  </div>
+{% endif %}
diff --git a/tests/api/test_all.py b/tests/api/test_all.py
index eee61ba7..f129dfa7 100644
--- a/tests/api/test_all.py
+++ b/tests/api/test_all.py
@@ -1757,20 +1757,21 @@ 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': 'short'})
     assert response.json['result'] == 1
+    assert response.json['strength'] == 0
     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['hint'] == 'use at least 8 characters'
 
+    response = app.post_json('/api/validate-password/', params={'password': 'w34k P455w0rd'})
+    assert response.json['result'] == 1
+    assert response.json['ok'] is False
+    assert response.json['hint'] == 'Add another word or two. Uncommon words are better.'
     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['hint'] == 'add more words or characters'
 
 
 def test_api_users_get_or_create(settings, app, admin):
-- 
2.35.1

