The following code will create and update on user input a password strength meter relying on data provided by the zxcvbn estimator from Dropbox and the HaveIBeenPwned API (if reachable).
- crypto-js (or any other JS library providing SHA-1 hashing, as it is required for HIBP API consuming)
- zxcvbn
- babel-polyfill (required to use async/await)
assets/js/components/password-strength-meter.js
import 'babel-polyfill'; // Required to use async await.
import SHA1 from 'crypto-js/sha1';
import estimatePasswordStrength from 'zxcvbn';
import {body} from './helpers/jquery/selectors';
let typingTimer;
body.on('keyup', '#App_user_plainPassword_first', function () {
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
checkPasswordStrength($(this).val());
}, 200);
});
async function checkPasswordStrength(plainPassword) {
const passwordLength = plainPassword.length;
if (passwordLength === 0) {
updatePasswordMeter('empty');
return;
}
const customBlacklist = getCustomBlacklist();
const passwordBreached = await haveIBeenPwnedPasswordCheck(plainPassword);
/*
Note that zxcvbn performance seems to deteriorate if password is longer than ~150 characters. Password is truncated
before zxcvbn estimation to prevent this performance drop. A password this long is probably assured to be secure
anyway, so accurate testing is redundant and a waste of resources.
*/
const passwordStrengthEstimation = estimatePasswordStrength(plainPassword.slice(0, 150), customBlacklist).score;
let passwordStrength = '';
if (passwordLength < 8 || passwordBreached || passwordStrengthEstimation < 3) {
passwordStrength = 'weak';
} else if (passwordLength >= 8 && passwordLength < 16 && passwordStrengthEstimation >= 3) {
passwordStrength = 'average';
} else {
passwordStrength = 'good';
}
updatePasswordMeter(passwordStrength);
}
function getCustomBlacklist() {
// Attempts to retrieve blacklist generated by the server on pages missing relevant data (e.g. password reset page).
const customBlacklistJsonFromBackEnd = $('#password-strength-meter').attr('data-blacklist');
let customBlacklistArrayFromBackEnd = [];
// IF page contains server-side generated blacklist.
if (typeof customBlacklistJsonFromBackEnd !== 'undefined' && customBlacklistJsonFromBackEnd !== '') {
customBlacklistArrayFromBackEnd = JSON.parse(customBlacklistJsonFromBackEnd);
}
// Retrieves value in current document that should not be reused as a password.
const customBlacklistFromInputs = [
$('#App_user_username').val(),
$('#App_user_email').val(),
window.location.href,
document.title
];
return customBlacklistFromInputs.concat(customBlacklistArrayFromBackEnd);
}
function haveIBeenPwnedPasswordCheck(plainPassword) {
const plainPasswordSHA1 = SHA1(plainPassword).toString().toUpperCase();
const plainPasswordSHA1Prefix = plainPasswordSHA1.slice(0, 5);
const plainPasswordSHA1Suffix = plainPasswordSHA1.slice(5);
return new Promise(resolve => {
let didTimeout = false;
// Defaults to NOT breached in case of high latency.
const latencyTimeout = setTimeout(() => {
didTimeout = true;
resolve(false);
}, 250);
fetch('https://api.pwnedpasswords.com/range/' + plainPasswordSHA1Prefix)
.then(async response => {
// Prevents a second resolve if latencyTimeout has been triggered.
if (didTimeout) {
return;
}
clearTimeout(latencyTimeout);
const breachedPasswordsSHA1Suffixes = await response.text();
if (breachedPasswordsSHA1Suffixes.includes(plainPasswordSHA1Suffix)) {
resolve(true);
return;
}
resolve(false);
})
.catch(() => {
// Prevents a second resolve if latencyTimeout has been triggered.
if (didTimeout) {
return;
}
clearTimeout(latencyTimeout);
resolve(false);
})
});
}
function updatePasswordMeter(passwordStrength) {
const passwordMeter = $('#password-strength-meter');
passwordMeter.find('.d-inline').removeClass('d-inline').addClass('d-none');
switch (passwordStrength) {
case 'weak':
passwordMeter.find('#password-strength-meter-feedback-weak').removeClass('d-none').addClass('d-inline');
break;
case 'average':
passwordMeter.find('#password-strength-meter-feedback-average').removeClass('d-none').addClass('d-inline');
break;
case 'good':
passwordMeter.find('#password-strength-meter-feedback-good').removeClass('d-none').addClass('d-inline');
break;
case 'empty':
passwordMeter.find('#password-strength-meter-feedback-weak').removeClass('d-none').addClass('d-inline');
break;
}
}
Server-side generated blacklist example, here located at src/Controller/User/PasswordResetController.php::reset()
// Password blacklist to be used by zxcvbn.
$passwordBlacklist = [
$this->getParameter('app.website_name'),
$user->getUsername(),
$user->getEmail(),
$user->getPasswordResetToken()
];
return $this->render('user/password_reset_reset.html.twig', [
'form' => $form->createView(),
'passwordBlacklist' => json_encode($passwordBlacklist)
]);
**Insert this in your template, just below {{ form_widget(form.plainPassword.first) }}**
{% set password_blacklist = password_blacklist is defined ? password_blacklist : '' %}
<p id="password-strength-meter" data-blacklist="{{ password_blacklist }}">
{{ 'password_strength_meter.password_strength'|trans }}
<span id="password-strength-meter-feedback-weak" class="text-danger d-inline">
{{ 'password_strength_meter.strength.weak'|trans }}
</span>
<span id="password-strength-meter-feedback-average" class="text-warning d-none">
{{ 'password_strength_meter.strength.average'|trans }}
</span>
<span id="password-strength-meter-feedback-good" class="text-success d-none">
{{ 'password_strength_meter.strength.good'|trans }}
</span>
</p>