Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save thibaut-decherit/fe273b0422f4482150f47aaa2527cbd9 to your computer and use it in GitHub Desktop.
Save thibaut-decherit/fe273b0422f4482150f47aaa2527cbd9 to your computer and use it in GitHub Desktop.
Symfony - Password Strength Meter (zxcvbn-HIBP powered)

Symfony - Password Strength Meter (zxcvbn-HIBP powered)

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).

Dependencies

  • 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)

Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment