From 5de83a83d21b6c5c78afe309ad44720bc0bfc568 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 2/3] misc: add password strength meter in NewPasswordInput
 (#63831)

---
 src/authentic2/api_urls.py                    |  1 +
 src/authentic2/api_views.py                   | 25 ++++-
 src/authentic2/forms/widgets.py               |  6 ++
 src/authentic2/passwords.py                   | 56 +++++++----
 .../static/authentic2/css/password.scss       | 92 +++++++++++++++++++
 .../static/authentic2/js/password.js          | 38 ++++++++
 .../authentic2/widgets/new_password.html      | 25 +++++
 tests/api/test_all.py                         | 39 +++++---
 tests/test_validators.py                      | 29 +++++-
 tests/test_widgets.py                         | 19 +++-
 10 files changed, 296 insertions(+), 34 deletions(-)

diff --git a/src/authentic2/api_urls.py b/src/authentic2/api_urls.py
index 075f329c..3ddf5f24 100644
--- a/src/authentic2/api_urls.py
+++ b/src/authentic2/api_urls.py
@@ -55,6 +55,7 @@ urlpatterns = [
     url(r'^check-password/$', api_views.check_password, name='a2-api-check-password'),
     url(r'^check-api-client/$', api_views.check_api_client, name='a2-api-check-api-client'),
     url(r'^validate-password/$', api_views.validate_password, name='a2-api-validate-password'),
+    url(r'^password-strength/$', api_views.password_strength, name='a2-api-password-strength'),
     url(r'^address-autocomplete/$', api_views.address_autocomplete, name='a2-api-address-autocomplete'),
 ]
 
diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py
index 7b545fe2..69388d57 100644
--- a/src/authentic2/api_views.py
+++ b/src/authentic2/api_views.py
@@ -61,7 +61,7 @@ from .apps.journal.models import Event
 from .custom_user.models import Profile, ProfileType, User
 from .journal_event_types import UserLogin, UserRegistration
 from .models import APIClient, Attribute, PasswordReset, Service
-from .passwords import get_password_checker
+from .passwords import get_password_checker, get_password_strength
 from .utils import misc as utils_misc
 from .utils.api import DjangoRBACPermission, NaturalKeyRelatedField
 from .utils.lookups import Unaccent
@@ -1480,6 +1480,29 @@ class ValidatePasswordAPI(BaseRpcView):
 validate_password = ValidatePasswordAPI.as_view()
 
 
+class PasswordStrengthSerializer(serializers.Serializer):
+    password = serializers.CharField(required=True, allow_blank=True)
+
+
+class PasswordStrengthAPI(BaseRpcView):
+    permission_classes = ()
+    authentication_classes = (CsrfExemptSessionAuthentication,)
+    serializer_class = PasswordStrengthSerializer
+
+    def rpc(self, request, serializer):
+        report = get_password_strength(serializer.validated_data['password'])
+        result = {
+            'result': 1,
+            'strength': report.strength,
+            'strength_label': report.strength_label,
+            'hint': report.hint,
+        }
+        return result, status.HTTP_200_OK
+
+
+password_strength = PasswordStrengthAPI.as_view()
+
+
 class AddressAutocompleteAPI(APIView):
     permission_classes = (permissions.AllowAny,)
 
diff --git a/src/authentic2/forms/widgets.py b/src/authentic2/forms/widgets.py
index 90150967..cbfdb4af 100644
--- a/src/authentic2/forms/widgets.py
+++ b/src/authentic2/forms/widgets.py
@@ -273,6 +273,12 @@ class PasswordInput(BasePasswordInput):
 class NewPasswordInput(PasswordInput):
     template_name = 'authentic2/widgets/new_password.html'
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
+        if min_strength:
+            self.attrs['data-min-strength'] = min_strength
+
     def get_context(self, *args, **kwargs):
         context = super().get_context(*args, **kwargs)
         password_checker = get_password_checker()
diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py
index 29cb3440..3570a018 100644
--- a/src/authentic2/passwords.py
+++ b/src/authentic2/passwords.py
@@ -55,9 +55,6 @@ class PasswordChecker(metaclass=abc.ABCMeta):
             self.label = label
             self.result = result
 
-    def __init__(self, *args, **kwargs):
-        pass
-
     @abc.abstractmethod
     def __call__(self, password, **kwargs):
         """Return an iterable of Check objects giving the list of checks and
@@ -90,12 +87,7 @@ class DefaultPasswordChecker(PasswordChecker):
     def regexp_label(self):
         return app_settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG
 
-    @property
-    def min_strength(self):
-        return app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
-
     def __call__(self, password, **kwargs):
-
         if self.min_length:
             yield self.Check(
                 result=len(password) >= self.min_length, label=_('%s characters') % self.min_length
@@ -113,20 +105,46 @@ 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)
 
 
 def validate_password(password):
-    password_checker = get_password_checker()
-    errors = [not check.result for check in password_checker(password)]
-    if any(errors):
-        raise ValidationError(_('This password is not accepted.'))
+    min_strength = app_settings.A2_PASSWORD_POLICY_MIN_STRENGTH
+    if min_strength is not None:
+        if get_password_strength(password).strength < min_strength:
+            raise ValidationError(_('This password is not strong enough.'))
+
+        min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
+        if min_length > len(password):
+            raise ValidationError(_('Password must be at least %s characters.') % min_length)
+    else:
+        password_checker = get_password_checker()
+        errors = [not check.result for check in password_checker(password)]
+        if any(errors):
+            raise ValidationError(_('This password is not accepted.'))
+
+
+class StrengthReport:
+    def __init__(self, strength, hint):
+        self.strength = strength
+        self.strength_label = [_('Very Weak'), _('Weak'), _('Fair'), _('Good'), _('Strong')][strength]
+        self.hint = hint
+
+
+def get_password_strength(password):
+    min_length = app_settings.A2_PASSWORD_POLICY_MIN_LENGTH
+
+    hint = _('add more words or characters')
+    strength = 0
+    if min_length and len(password) < 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]
+
+    return StrengthReport(strength, hint)
diff --git a/src/authentic2/static/authentic2/css/password.scss b/src/authentic2/static/authentic2/css/password.scss
index 3f729d66..ae7c55f1 100644
--- a/src/authentic2/static/authentic2/css/password.scss
+++ b/src/authentic2/static/authentic2/css/password.scss
@@ -44,6 +44,98 @@
   visibility: visible;
 }
 
+/* Password strength meter & hints */
+.a2-password-feedback {
+  font-size: 90%;
+  margin-bottom: 1.8em;
+  display: none;
+}
+
+.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;
+  }
+
+  &--ok {
+    display: none;
+    .a2-password-ok & {
+      display: inline;
+    }
+  }
+
+  &--nok{
+    display: inline;
+    .a2-password-ok & {
+      display: none;
+    }
+  }
+
+  &--content {
+    font-weight: bold;
+    display: inline;
+  }
+}
+
+// Switch password feedback to strength meter if data-min-strength is defined
+// on the password input
+input[type=password][data-min-strength] {
+  & ~ .a2-password-feedback {
+    display: block;
+  }
+
+  & ~ .a2-password-policy-hint {
+    display: none;
+  }
+}
+
+
 /* 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..3a41c466 100644
--- a/src/authentic2/static/authentic2/js/password.js
+++ b/src/authentic2/static/authentic2/js/password.js
@@ -25,6 +25,37 @@ a2_password_check_equality = (function () {
     }
 })();
 
+function update_password_strength($input, password, min_strength) {
+    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');
+    $.ajax({
+        method: 'POST',
+        url: '/api/password-strength/',
+        data: JSON.stringify({'password': password}),
+        dataType: 'json',
+        contentType: 'application/json; charset=utf-8',
+        success: function(data) {
+            strength = data.strength
+            ok = strength >= min_strength
+            $hint.toggleClass('a2-password-ok', ok);
+            $hint.toggleClass('a2-password-hidden', password == '' || strength == 4);
+            $hint.toggleClass('errornotice', !ok);
+            $hint.toggleClass('infonotice', ok);
+            $hint_content.text(data.hint)
+
+            for (var i = 0; i < 5; ++i) {
+                $strength.removeClass('strength-' + i);
+            }
+
+            $strength.addClass('strength-' + strength);
+            $strength_name.text(data.strength_label);
+        }
+    });
+}
+
 a2_password_validate = (function () {
     function toggle_error($elt) {
         $elt.removeClass('a2-password-check-equality-ok');
@@ -36,6 +67,13 @@ a2_password_validate = (function () {
     }
     function get_validation($input) {
         var password = $input.val();
+        var min_strength = $input.attr('data-min-strength')
+
+        if( min_strength !== undefined ) {
+            update_password_strength($input, password, min_strength);
+            return
+        }
+
         var $help_text = $input.parent().find('.a2-password-policy-hint');
         var $policyContainer = $help_text.find('.a2-password-policy-container');
         $.ajax({
diff --git a/src/authentic2/templates/authentic2/widgets/new_password.html b/src/authentic2/templates/authentic2/widgets/new_password.html
index acbca015..50322437 100644
--- a/src/authentic2/templates/authentic2/widgets/new_password.html
+++ b/src/authentic2/templates/authentic2/widgets/new_password.html
@@ -1,5 +1,30 @@
 {% load i18n %}
 {% include "django/forms/widgets/input.html" %}
+<div class="a2-password-feedback" data-min-strength="{{ min_strength }}">
+  <div class="a2-password-strength strength-0">
+    <p class="a2-password-strength--label">{% trans "Password strength :" %}
+    <div class="a2-password-strength--name">-</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--nok">
+        {% 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"></div>
+    </div>
+  </div>
+</div>
 <div class="a2-password-policy-hint">
   {% trans "In order to create a secure password, please use at least :" %}
   <div class="a2-password-policy-container">
diff --git a/tests/api/test_all.py b/tests/api/test_all.py
index eee61ba7..c21d7f3e 100644
--- a/tests/api/test_all.py
+++ b/tests/api/test_all.py
@@ -1756,21 +1756,36 @@ def test_validate_password_regex(app, settings):
     assert response.json['checks'][4]['result'] is True
 
 
-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'})
+@pytest.mark.parametrize(
+    'password,strength,label',
+    [
+        ('?', 0, 'Very Weak'),
+        ('?JR!', 1, 'Weak'),
+        ('?JR!p4A', 2, 'Fair'),
+        ('?JR!p4A2i', 3, 'Good'),
+        ('?JR!p4A2i:#', 4, 'Strong'),
+    ],
+)
+def test_password_strength(app, settings, password, strength, label):
+    settings.A2_PASSWORD_POLICY_MIN_LENGTH = 0
+    response = app.post_json('/api/password-strength/', params={'password': password})
     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'] == strength
+    assert response.json['strength_label'] == label
+
 
-    response = app.post_json('/api/validate-password/', params={'password': 'xbA2E4]#o'})
+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['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'] == 0
+    assert response.json['strength_label'] == 'Very Weak'
+
+    response = app.post_json('/api/password-strength/', params={'password': 'long_enough'})
+    assert response.json['result'] == 1
+    assert response.json['strength'] != 0
+    assert response.json['strength_label'] != 'Very Weak'
 
 
 def test_api_users_get_or_create(settings, app, admin):
diff --git a/tests/test_validators.py b/tests/test_validators.py
index c99ee383..9939ae50 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -24,7 +24,7 @@ from django.core.exceptions import ValidationError
 from authentic2.validators import EmailValidator, HexaColourValidator, validate_password
 
 
-def test_validate_password():
+def test_validate_password(settings):
     with pytest.raises(ValidationError):
         validate_password('aaaaaZZZZZZ')
     with pytest.raises(ValidationError):
@@ -34,6 +34,33 @@ def test_validate_password():
     validate_password('000aaaaZZZZ')
 
 
+@pytest.mark.parametrize(
+    'password,min_strength',
+    [
+        ('', 0),
+        ('?', 0),
+        ('?JR!', 1),
+        ('?JR!p4A', 2),
+        ('?JR!p4A2i', 3),
+        ('?JR!p4A2i:#', 4),
+    ],
+)
+def test_validate_password_strength(settings, password, min_strength):
+    settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
+    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength
+    validate_password(password)
+
+    settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password) + 1
+    with pytest.raises(ValidationError):
+        validate_password(password)
+
+    if min_strength < 4:
+        settings.A2_PASSWORD_POLICY_MIN_LENGTH = len(password)
+        settings.A2_PASSWORD_POLICY_MIN_STRENGTH = min_strength + 1
+        with pytest.raises(ValidationError):
+            validate_password(password)
+
+
 def test_validate_colour():
     validator = HexaColourValidator()
     with pytest.raises(ValidationError):
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index c495a982..70ffb57b 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -17,7 +17,7 @@
 
 from pyquery import PyQuery
 
-from authentic2.widgets import DatalistTextInput, DateTimeWidget, DateWidget, TimeWidget
+from authentic2.widgets import DatalistTextInput, DateTimeWidget, DateWidget, NewPasswordInput, TimeWidget
 
 
 def test_datetimepicker_init_and_render_no_locale():
@@ -50,3 +50,20 @@ def test_datalisttextinput_init_and_render():
         assert option.values()[0] in data
         data.remove(option.values()[0])
     assert not data
+
+
+def test_new_password_input(settings):
+    widget = NewPasswordInput()
+    html = widget.render('foo', 'bar')
+    query = PyQuery(html)
+
+    textinput = query.find('input')
+    assert textinput.attr('data-min-strength') is None
+
+    settings.A2_PASSWORD_POLICY_MIN_STRENGTH = 3
+    widget = NewPasswordInput()
+    html = widget.render('foo', 'bar')
+    query = PyQuery(html)
+
+    textinput = query.find('input')
+    assert textinput.attr('data-min-strength') == '3'
-- 
2.35.1

