diff --git a/Pipfile.lock b/Pipfile.lock index 1b3eb411f..97bafc37a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -18,10 +18,10 @@ "default": { "aimmo": { "hashes": [ - "sha256:70e68dd9a1696fb6a8ce22a3f9410ca4c1473d1a142710e4ecebcfbbe425a33a", - "sha256:80753d00665b5fbb13ea879ea0ad137406a4e5296caca4251b74540e79695f0c" + "sha256:a4515d66e1220aca2b22eb3ca81a21639b8dddcf57d75b8b8ce4ad9e1c4ac18e", + "sha256:a86a4363b125faedb6ca0cacdf4fa36a7fd84a599e864d5ce6368aeab5d6c28e" ], - "version": "==2.8.5" + "version": "==2.8.7" }, "asgiref": { "hashes": [ @@ -306,11 +306,11 @@ }, "google-auth": { "hashes": [ - "sha256:a9cfa88b3e16196845e64a3658eb953992129d13ac7337b064c6546f77c17183", - "sha256:ea165e014c7cbd496558796b627c271aa8c18b4cba79dc1cc962b24c5efdfb85" + "sha256:030af34138909ccde0fbce611afc178f1d65d32fbff281f25738b1fe1c6f3eaa", + "sha256:23b7b0950fcda519bfb6692bf0d5289d2ea49fc143717cc7188458ec620e63fa" ], "markers": "python_version >= '3.6'", - "version": "==2.19.1" + "version": "==2.20.0" }, "greenlet": { "hashes": [ @@ -580,7 +580,7 @@ "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.2" }, "pytz": { @@ -728,7 +728,7 @@ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, "sortedcontainers": { @@ -779,11 +779,11 @@ }, "websocket-client": { "hashes": [ - "sha256:c7d67c13b928645f259d9b847ab5b57fd2d127213ca41ebd880de1f553b7c23b", - "sha256:f8c64e28cd700e7ba1f04350d66422b6833b82a796b525a51e740b8cc8dab4b1" + "sha256:72d7802608745b0a212f79b478642473bd825777d8637b6c8c421bf167790d4f", + "sha256:e84c7eafc66aade6d1967a51dfd219aabdf81d15b9705196e11fd81f48666b78" ], "markers": "python_version >= '3.7'", - "version": "==1.5.2" + "version": "==1.6.0" }, "xlrd": { "hashes": [ @@ -1163,11 +1163,11 @@ }, "platformdirs": { "hashes": [ - "sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f", - "sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5" + "sha256:57e28820ca8094678b807ff529196506d7a21e17156cb1cddb3e74cebce54640", + "sha256:ffa199e3fbab8365778c4a10e1fbf1b9cd50707de826eb304b50e57ec0cc8d38" ], "markers": "python_version >= '3.7'", - "version": "==3.5.1" + "version": "==3.6.0" }, "pluggy": { "hashes": [ @@ -1187,11 +1187,11 @@ }, "pytest": { "hashes": [ - "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362", - "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3" + "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295", + "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b" ], "index": "pypi", - "version": "==7.3.1" + "version": "==7.3.2" }, "pytest-cov": { "hashes": [ @@ -1211,11 +1211,11 @@ }, "pytest-mock": { "hashes": [ - "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b", - "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f" + "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39", + "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f" ], "index": "pypi", - "version": "==3.10.0" + "version": "==3.11.1" }, "pytest-order": { "hashes": [ @@ -1312,7 +1312,7 @@ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, "snapshottest": { diff --git a/cfl_common/common/csp_config.py b/cfl_common/common/csp_config.py index 2ec8a34a7..351d33eeb 100644 --- a/cfl_common/common/csp_config.py +++ b/cfl_common/common/csp_config.py @@ -11,6 +11,7 @@ "https://api.iconify.design/", "https://api.simplesvg.com/", "https://api.unisvg.com/", + "https://api.pwnedpasswords.com", "https://www.google-analytics.com/", "https://pyodide-cdn2.iodide.io/v0.15.0/full/", "https://crowdin.com/", @@ -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/cfl_common/common/tests/utils/student.py b/cfl_common/common/tests/utils/student.py index df8c2fa7e..ab0aff568 100644 --- a/cfl_common/common/tests/utils/student.py +++ b/cfl_common/common/tests/utils/student.py @@ -5,6 +5,7 @@ from common.helpers.generators import generate_login_id from common.models import Class, Student from django.core import mail +from portal.helpers.password import generate_strong_password from . import email @@ -91,7 +92,7 @@ def generate_independent_student_details(): name = "Independent Student %d" % generate_independent_student_details.next_id email_address = "student%d@codeforlife.com" % generate_independent_student_details.next_id username = email_address - password = "Password2" + password = generate_strong_password() generate_independent_student_details.next_id += 1 diff --git a/cfl_common/common/tests/utils/teacher.py b/cfl_common/common/tests/utils/teacher.py index 15ea6cb76..e91d5228d 100644 --- a/cfl_common/common/tests/utils/teacher.py +++ b/cfl_common/common/tests/utils/teacher.py @@ -4,6 +4,7 @@ from common.helpers.emails import generate_token from common.models import Teacher from django.core import mail +from portal.helpers.password import generate_strong_password from . import email @@ -13,7 +14,7 @@ def generate_details(**kwargs): first_name = kwargs.get("first_name", "Test") last_name = kwargs.get("last_name", f"Teacher {random_int}") email_address = kwargs.get("email_address", f"testteacher{random_int}@codeforlife.com") - password = kwargs.get("password", "Password2!") + password = kwargs.get("password", generate_strong_password()) return first_name, last_name, email_address, password diff --git a/portal/forms/play.py b/portal/forms/play.py index 6b3d56837..0767c42d4 100644 --- a/portal/forms/play.py +++ b/portal/forms/play.py @@ -12,7 +12,7 @@ from django.utils import timezone from portal.forms.error_messages import INVALID_LOGIN_MESSAGE -from portal.helpers.password import PasswordStrength, form_clean_password +from portal.helpers.password import PasswordStrength, form_clean_password, check_pwned_password from portal.helpers.regexes import ACCESS_CODE_PATTERN @@ -238,6 +238,7 @@ def clean(self): password = self.cleaned_data.get("password", None) confirm_password = self.cleaned_data.get("confirm_password", None) + check_pwned_password(password) if password and confirm_password and password != confirm_password: raise forms.ValidationError("Your passwords do not match") diff --git a/portal/forms/teach.py b/portal/forms/teach.py index 3d4e11fed..312f511d0 100644 --- a/portal/forms/teach.py +++ b/portal/forms/teach.py @@ -5,6 +5,7 @@ from captcha.fields import ReCaptchaField from captcha.widgets import ReCaptchaV2Invisible from common.helpers.emails import send_verification_email +from portal.helpers.password import check_pwned_password from common.models import Student, stripStudentName, UserSession, Teacher from django import forms from django.contrib.auth import authenticate @@ -12,6 +13,7 @@ from django.contrib.auth.models import User from game.models import Episode + from portal.forms.error_messages import INVALID_LOGIN_MESSAGE from portal.helpers.password import PasswordStrength, form_clean_password from portal.helpers.ratelimit import clear_ratelimit_cache_for_user @@ -39,7 +41,7 @@ def clean(self): password = self.cleaned_data.get("teacher_password", None) confirm_password = self.cleaned_data.get("teacher_confirm_password", None) - + check_pwned_password(password) check_passwords(password, confirm_password) return self.cleaned_data @@ -114,6 +116,7 @@ def clean(self): return self.cleaned_data def check_password_errors(self, password, confirm_password, current_password): + check_pwned_password(password) check_passwords(password, confirm_password) if not self.user.check_password(current_password): diff --git a/portal/helpers/password.py b/portal/helpers/password.py index 5ae71aa95..9f3479fb5 100644 --- a/portal/helpers/password.py +++ b/portal/helpers/password.py @@ -1,4 +1,8 @@ import re +import hashlib +import requests +import secrets +import string from enum import Enum, auto from django import forms @@ -7,6 +11,27 @@ from django.core.exceptions import ValidationError +def generate_strong_password(password_length=12): + if password_length < 4: + print("Password length should be at least 4.") + return None + + # The password will contain at least one lowercase, one uppercase, one digit, and one special character. + all_possible_characters = string.ascii_letters + string.digits + string.punctuation + + while True: + generated_password = "".join(secrets.choice(all_possible_characters) for i in range(password_length)) + if ( + any(character.islower() for character in generated_password) + and any(character.isupper() for character in generated_password) + and any(character.isdigit() for character in generated_password) + and any(character in string.punctuation for character in generated_password) + ): + break + + return generated_password + + class PasswordStrength(Enum): STUDENT = auto() INDEPENDENT = auto() @@ -106,3 +131,25 @@ def check_update_password(form, user, request, data): update_session_auth_hash(request, form.user) return changing_password + + +def check_pwned_password(password): + # Create SHA1 hash of the password + sha1_hash = hashlib.sha1(password.encode()).hexdigest() + prefix = sha1_hash[:5] # Take the first 5 characters of the hash as the prefix + + # Make a request to the Pwned Passwords API + url = f"https://api.pwnedpasswords.com/range/{prefix}" + response = requests.get(url) + + if response.status_code != 200: + return True # backend ignore this and frontend tells the user + # that we cannot verify this at the moment + + # Check if the password's hash is found in the response body + hash_suffixes = response.text.split("\r\n") + for suffix in hash_suffixes: + if sha1_hash[5:].upper() == suffix[:35].upper(): + raise forms.ValidationError("Your current password is too common") + + return True diff --git a/portal/static/portal/js/common.js b/portal/static/portal/js/common.js index 51573fc00..568369851 100644 --- a/portal/static/portal/js/common.js +++ b/portal/static/portal/js/common.js @@ -69,7 +69,7 @@ function hideScreentimePopup() { setTimeout(showScreentimePopup, 3600000); } -let interval; +let timeInterval; function showSessionPopup() { $("#session-popup").addClass("popup--fade"); @@ -95,7 +95,7 @@ function hideSessionPopup() { function startTimer(duration, minutesDisplay, secondsDisplay) { let timer = duration, minutes, seconds; - interval = setInterval(function () { + timeInterval = setInterval(function () { minutes = parseInt(timer / 60, 10); seconds = parseInt(timer % 60, 10); @@ -112,7 +112,7 @@ function startTimer(duration, minutesDisplay, secondsDisplay) { } function resetTimer(minutesDisplay, secondsDisplay) { - clearInterval(interval); + clearInterval(timeInterval); minutesDisplay.text("2"); secondsDisplay.text("00"); } diff --git a/portal/static/portal/js/passwordStrength.js b/portal/static/portal/js/passwordStrength.js index 0667596c5..d47530c7f 100644 --- a/portal/static/portal/js/passwordStrength.js +++ b/portal/static/portal/js/passwordStrength.js @@ -1,78 +1,51 @@ -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() { - - 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); - - updatePasswordStrength(true); - updatePasswordStrength(false); -}); - -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) - }); +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); } -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. - - setTimeout(function() { - let password; - - if (isTeacher) { - password = $('#' + TEACHER_PASSWORD_FIELD_ID).val(); - } - else { - password = $('#' + INDEP_STUDENT_PASSWORD_FIELD_ID).val(); - } - - let strength = 0; - if (password.length > 0) { strength++; } - if (isPasswordStrong(password, isTeacher)) { strength++; } - - if ($.inArray(password, most_used_passwords) >= 0 && strength == 2) { strength = 3; } - - if (isTeacher) { - updatePasswordCSS('#teacher-password-sign', '#teacher-password-text', strength); - } - else { - updatePasswordCSS('#student-password-sign', '#student-password-text', strength); - } - - }); -} const getPwnedStatus = async (password) => { - const sha1Hash = (password) => + const computeSHA1Hash = (password) => CryptoJS.SHA1(password).toString().toUpperCase(); - const checkIfHashExists = (data, suffix) => data.includes(suffix); + const doesSuffixExist = (data, suffix) => data.includes(suffix); - const getLevenshteinDistance = (a, b) => { + const calculateLevenshteinDistance = async (a, b) => { if (a.length === 0) return b.length; if (b.length === 0) return a.length; @@ -94,50 +67,64 @@ const getPwnedStatus = async (password) => { }; try { - const hashedPassword = sha1Hash(password); - const [prefix, suffix] = [hashedPassword.substring(0, 5), hashedPassword.substring(5)]; - const url = `https://api.pwnedpasswords.com/range/${prefix}`; + const hashedPassword = computeSHA1Hash(password); + const prefix = hashedPassword.substring(0, 5); + const suffix = hashedPassword.substring(5); + const apiUrl = `https://api.pwnedpasswords.com/range/${prefix}`; + + const response = await fetch(apiUrl); + + 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 response = await fetch(url); - if (!response.ok) throw new Error(`Request failed with status code: ${response.status}`); const data = await response.text(); - if (checkIfHashExists(data, suffix)) { - console.log(`Password is pwned`); - return; + if (doesSuffixExist(data, suffix)) { + return false; } const similarPasswords = data .split('\n') .map((line) => line.split(':')) - .filter(([hashSuffix]) => getLevenshteinDistance(suffix, hashSuffix) <= 2); + .filter( + ([hashSuffix]) => calculateLevenshteinDistance(suffix, hashSuffix) <= 2 + ); if (similarPasswords.length > 0) { - console.log(`Similar passwords found within Levenshtein distance of 2:`); similarPasswords.forEach(([hashSuffix, count]) => console.log(`Hash: ${hashSuffix}, Occurrences: ${count}`) ); } else { - console.log(`Password is not pwned`); + return true; } + return true; } catch (error) { console.error(`Request failed with error: ${error.message}`); + return false; } }; - function isPasswordStrong(password, isTeacher) { - const isPasswordBreached = getPwnedStatus(password); - if (isTeacher) { - return password.length >= 10 && !(isPasswordBreached || 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) - } -} - -function updatePasswordCSS(passwordStrengthSign, passwordStrengthText, strength) { - $(passwordStrengthSign).css('background-color', password_strengths[strength].colour); - $(passwordStrengthText).html(password_strengths[strength].name); + 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/static/portal/sass/partials/_popup.scss b/portal/static/portal/sass/partials/_popup.scss index 4e58c18ac..1ab33e28f 100644 --- a/portal/static/portal/sass/partials/_popup.scss +++ b/portal/static/portal/sass/partials/_popup.scss @@ -17,12 +17,19 @@ -ms-transition: visibility 0s linear $fade-time, opacity $fade-time ease-out; transition: visibility 0s linear $fade-time, opacity $fade-time ease-out; + .popup-box__buttons > a { + color: black; + } + .popup-box { background-color: $color-background-secondary; box-shadow: 0 0 4 * $spacing $color-background-box-shadow; margin: 20% auto 0; overflow: hidden; width: 460px; + > * { + color: black; + } .popup-box__msg { @include _padding(0px, 5 * $spacing, 5 * $spacing, 5 * $spacing); diff --git a/portal/templates/portal/register.html b/portal/templates/portal/register.html index b6b200fe8..f6be1a631 100644 --- a/portal/templates/portal/register.html +++ b/portal/templates/portal/register.html @@ -1,187 +1,230 @@ {% extends 'portal/base.html' %} {% load static %} {% load app_tags %} - {% block content %} -
- -Register below to create your school or club.
- You will have access to teaching resources, progress tracking and lesson plans - for both Rapid Router and Kurono. -