diff --git a/cfl_common/common/csp_config.py b/cfl_common/common/csp_config.py index 1c06b5666..f28f91375 100644 --- a/cfl_common/common/csp_config.py +++ b/cfl_common/common/csp_config.py @@ -6,6 +6,7 @@ CSP_CONNECT_SRC = ( "'self'", "https://*.onetrust.com/", + "https://api.pwnedpasswords.com", "https://euc-widget.freshworks.com/", "https://codeforlife.freshdesk.com/", "https://api.iconify.design/", @@ -14,7 +15,7 @@ "https://www.google-analytics.com/", "https://pyodide-cdn2.iodide.io/v0.15.0/full/", "https://crowdin.com/", - f"wss://{MODULE_NAME}-aimmo.codeforlife.education/", + "" f"wss://{MODULE_NAME}-aimmo.codeforlife.education/", f"https://{MODULE_NAME}-aimmo.codeforlife.education/", ) CSP_FONT_SRC = ("'self'", "https://fonts.gstatic.com/", "https://fonts.googleapis.com/", "https://use.typekit.net/") @@ -22,6 +23,7 @@ "'self'", "'unsafe-inline'", "'unsafe-eval'", + "https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js", "https://cdn.crowdin.com/", "https://*.onetrust.com/", "https://code.jquery.com/", diff --git a/portal/helpers/password.py b/portal/helpers/password.py index 4a6d0fdee..11e454a9a 100644 --- a/portal/helpers/password.py +++ b/portal/helpers/password.py @@ -54,9 +54,7 @@ def password_test(self, password): f"Password not strong enough, consider using at least {minimum_password_length} characters and making it hard to guess." ) if is_password_pwned(password): - raise forms.ValidationError( - "Password has been found in a data breach, please choose a different password." - ) + raise forms.ValidationError("Password is too common, consider using a different password.") elif self is PasswordStrength.INDEPENDENT: minimum_password_length = 8 @@ -73,9 +71,7 @@ def password_test(self, password): "upper and lower case letters, and numbers and making it hard to guess." ) if is_password_pwned(password): - raise forms.ValidationError( - "Password has been found in a data breach, please choose a different password." - ) + raise forms.ValidationError("Password is too common, consider using a different password.") else: minimum_password_length = 10 if password and not password_strength_test( @@ -91,9 +87,7 @@ def password_test(self, password): "upper and lower case letters, numbers, special characters and making it hard to guess." ) if is_password_pwned(password): - raise forms.ValidationError( - "Password has been found in a data breach, please choose a different password." - ) + raise forms.ValidationError("Password is too common, consider using a different password.") return password diff --git a/portal/static/portal/js/passwordStrength.js b/portal/static/portal/js/passwordStrength.js index 82e528b32..d47530c7f 100644 --- a/portal/static/portal/js/passwordStrength.js +++ b/portal/static/portal/js/passwordStrength.js @@ -1,82 +1,130 @@ -var TEACHER_PASSWORD_FIELD_ID = ''; -var INDEP_STUDENT_PASSWORD_FIELD_ID = ''; -let teacher_password_field = ''; -let indep_student_password_field = ''; -let most_used_passwords = ['Abcd1234', 'Password1', 'Qwerty123']; - let password_strengths = [ - { name: 'No password!', colour: '#FF0000' }, - { name: 'Password too weak!', colour: '#DBA901' }, - { name: 'Strong password!', colour: '#088A08' }, - { name: 'Password too common!', colour: '#DBA901' } + { name: 'No password!', colour: '#FF0000' }, + { name: 'Password too weak!', colour: '#DBA901' }, + { name: 'Password too common!', colour: '#DBA901' }, + { name: 'Strong password!', colour: '#088A08' } ]; -$(function() { +async function handlePasswordStrength() { + const teacherPassword = $('#id_teacher_signup-teacher_password').val(); + const independentStudentPassword = $( + '#id_independent_student_signup-password' + ).val(); + + const isTeacherPasswordNonEmpty = teacherPassword.length > 0; + const isIndependentStudentPasswordNonEmpty = independentStudentPassword.length > 0; + + const isTeacherPasswordComplex = isPasswordStrong(teacherPassword, true) && isTeacherPasswordNonEmpty; + const isIndependentStudentPasswordComplex = isPasswordStrong(independentStudentPassword, false) && isIndependentStudentPasswordNonEmpty; + + const isTeacherPasswordNotPwned = await getPwnedStatus(teacherPassword) && isTeacherPasswordComplex; + const isIndependentStudentPasswordNotPwned = await getPwnedStatus(independentStudentPassword) && isIndependentStudentPasswordComplex; + + const teacherPasswordStrength = [isTeacherPasswordNonEmpty, isTeacherPasswordComplex, isTeacherPasswordNotPwned].filter(value => value).length; + const independentStudentPasswordStrength = [isIndependentStudentPasswordNonEmpty, isIndependentStudentPasswordComplex, isIndependentStudentPasswordNotPwned].filter(value => value).length; + + console.log(teacherPasswordStrength, independentStudentPasswordStrength) + console.log(password_strengths) + + $('#teacher-password-sign').css( + 'background-color', + password_strengths[teacherPasswordStrength].colour + ); + $('#teacher-password-text').html(password_strengths[teacherPasswordStrength].name); + $('#student-password-sign').css( + 'background-color', + password_strengths[independentStudentPasswordStrength].colour + ); + $('#student-password-text').html(password_strengths[independentStudentPasswordStrength].name); +} - teacher_password_field = $('#' + TEACHER_PASSWORD_FIELD_ID); - indep_student_password_field = $('#' + INDEP_STUDENT_PASSWORD_FIELD_ID); - setUpDynamicUpdates(teacher_password_field, true); - setUpDynamicUpdates(indep_student_password_field, false); +const getPwnedStatus = async (password) => { + const computeSHA1Hash = (password) => + CryptoJS.SHA1(password).toString().toUpperCase(); - updatePasswordStrength(true); - updatePasswordStrength(false); -}); + const doesSuffixExist = (data, suffix) => data.includes(suffix); -function setUpDynamicUpdates(password_field, isTeacher) { - password_field.on('keyup', function(){ - updatePasswordStrength(isTeacher) - }); - password_field.on('paste', function(){ - updatePasswordStrength(isTeacher) - }); - password_field.on('cut', function(){ - updatePasswordStrength(isTeacher) - }); -} + const calculateLevenshteinDistance = async (a, b) => { + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; -function updatePasswordStrength(isTeacher) { - // The reason for the timeout is that if we just got $('#...').val() we'd get the - // old value before the keypress / change. Apparently even jQuery itself implements - // things this way, so maybe there is no better workaround. + const matrix = Array.from({ length: a.length + 1 }, (_, i) => [i]); + for (let j = 1; j <= b.length; j++) matrix[0][j] = j; - setTimeout(function() { - let password; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + cost // substitution + ); + } + } - if (isTeacher) { - password = $('#' + TEACHER_PASSWORD_FIELD_ID).val(); - } - else { - password = $('#' + INDEP_STUDENT_PASSWORD_FIELD_ID).val(); - } + return matrix[a.length][b.length]; + }; - let strength = 0; - if (password.length > 0) { strength++; } - if (isPasswordStrong(password, isTeacher)) { strength++; } + try { + const hashedPassword = computeSHA1Hash(password); + const prefix = hashedPassword.substring(0, 5); + const suffix = hashedPassword.substring(5); + const apiUrl = `https://api.pwnedpasswords.com/range/${prefix}`; - if ($.inArray(password, most_used_passwords) >= 0 && strength == 2) { strength = 3; } + const response = await fetch(apiUrl); - if (isTeacher) { - updatePasswordCSS('#teacher-password-sign', '#teacher-password-text', strength); - } - else { - updatePasswordCSS('#student-password-sign', '#student-password-text', strength); - } + if (!response.ok) { + return true; // ignore the check if the server is down as the popup warns + // the user that we cannot check the password + } - }); -} + const data = await response.text(); -function isPasswordStrong(password, isTeacher) { - if (isTeacher) { - return password.length >= 10 && !(password.search(/[A-Z]/) === -1 || password.search(/[a-z]/) === -1 || password.search(/[0-9]/) === -1 || password.search(/[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>\/?]/) === -1) + if (doesSuffixExist(data, suffix)) { + return false; } - else { - return password.length >= 8 && !(password.search(/[A-Z]/) === -1 || password.search(/[a-z]/) === -1 || password.search(/[0-9]/) === -1) + + const similarPasswords = data + .split('\n') + .map((line) => line.split(':')) + .filter( + ([hashSuffix]) => calculateLevenshteinDistance(suffix, hashSuffix) <= 2 + ); + + if (similarPasswords.length > 0) { + similarPasswords.forEach(([hashSuffix, count]) => + console.log(`Hash: ${hashSuffix}, Occurrences: ${count}`) + ); + } else { + return true; } -} + return true; + } catch (error) { + console.error(`Request failed with error: ${error.message}`); + return false; + } +}; -function updatePasswordCSS(passwordStrengthSign, passwordStrengthText, strength) { - $(passwordStrengthSign).css('background-color', password_strengths[strength].colour); - $(passwordStrengthText).html(password_strengths[strength].name); +function isPasswordStrong(password, isTeacher) { + if (isTeacher) { + return ( + password.length >= 10 && + !( + password.search(/[A-Z]/) === -1 || + password.search(/[a-z]/) === -1 || + password.search(/[0-9]/) === -1 || + password.search(/[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>\/?]/) === -1 + ) + ); + } else { + return ( + password.length >= 8 && + !( + password.search(/[A-Z]/) === -1 || + password.search(/[a-z]/) === -1 || + password.search(/[0-9]/) === -1 + ) + ); + } } - diff --git a/portal/templates/portal/base.html b/portal/templates/portal/base.html index 3aeb9cb13..e8fba462d 100644 --- a/portal/templates/portal/base.html +++ b/portal/templates/portal/base.html @@ -1,70 +1,77 @@ {% load static %} {% load app_tags %} - {% load sekizai_tags %} - - - {% render_block "css" %} - - {% block title %}Code for Life{% endblock %} - - - - {% include "common/onetrust_cookies_consent_notice.html" %} - - {% block head %} - {% endblock head %} - - {% block css %} - - - - - - - - - {% endblock css %} - - {% include "portal/tag_manager/tag_manager_head.html" %} - - - - - - - - - - - {% block check_user_status %} - + + + + + + + + + + {% block check_user_status %} + - {% endblock check_user_status %} - - {% block google_analytics %} - {% if request|is_production %} - - - + {% endblock check_user_status %} + {% block google_analytics %} + {% if request|is_production %} + + + - - - - + + + - {% endif %} - {% endblock google_analytics %} - - - -{% include "portal/tag_manager/tag_manager_body.html" %} -{% render_block "js" %} -{% include 'portal/mouseflow.html' %} - -