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 %} -
- -
-
-
-

Teacher/Tutor

-
-

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. -
-
- - {% csrf_token %} - - {{ teacher_signup_form.non_field_errors }} -
- - {{ teacher_signup_form.teacher_first_name }} - {{ teacher_signup_form.teacher_first_name.help_text }} - {{ teacher_signup_form.teacher_first_name.errors }} -
-
- - {{ teacher_signup_form.teacher_last_name }} - {{ teacher_signup_form.teacher_last_name.help_text }} - {{ teacher_signup_form.teacher_last_name.errors }} -
-
- - {{ teacher_signup_form.teacher_email }} - {{ teacher_signup_form.teacher_email.help_text }} - {{ teacher_signup_form.teacher_email.errors }} -
-
-
-
- {{ teacher_signup_form.consent_ticked }} -
-

I am over 18 years old have read and understood the - Terms of use and the - Privacy notice.

-
-
-
- {% include 'portal/partials/register_newsletter_tickbox.html' with newsletter_ticked=teacher_signup_form.newsletter_ticked %} -
-
- -
- {{ teacher_signup_form.teacher_password }} -
- {{ teacher_signup_form.teacher_password.help_text }} - {{ teacher_signup_form.teacher_password.errors }} -
+
+
+
+
+

Teacher/Tutor

- -
- {{ teacher_signup_form.teacher_confirm_password }} -
- {{ teacher_signup_form.teacher_confirm_password.help_text }} - {{ teacher_signup_form.teacher_confirm_password.errors }} -
- -
-
-
-
-
-
- -
{{ teacher_signup_form.captcha }}
-
- +

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

Independent learner

-
-

Register below if you are not part of a school or club and wish to set up a home - learning account.

- You will have access to learning resources for Rapid Router. -
- -
- - {% csrf_token %} - -
- {{ independent_student_signup_form.date_of_birth.help_text }} -
- {{ independent_student_signup_form.date_of_birth }} + + {% csrf_token %} + {{ teacher_signup_form.non_field_errors }} +
+ + {{ teacher_signup_form.teacher_first_name }} + {{ teacher_signup_form.teacher_first_name.help_text }} + {{ teacher_signup_form.teacher_first_name.errors }}
-
- {{ independent_student_signup_form.date_of_birth.errors }} - -
- {{ independent_student_signup_form.non_field_errors }}
- - {{ independent_student_signup_form.name }} - {{ independent_student_signup_form.name.help_text }} - {{ independent_student_signup_form.name.errors }} + + {{ teacher_signup_form.teacher_last_name }} + {{ teacher_signup_form.teacher_last_name.help_text }} + {{ teacher_signup_form.teacher_last_name.errors }}
- - {{ independent_student_signup_form.email }} - {{ independent_student_signup_form.email.help_text }} - {{ independent_student_signup_form.email.errors }} + + {{ teacher_signup_form.teacher_email }} + {{ teacher_signup_form.teacher_email.help_text }} + {{ teacher_signup_form.teacher_email.errors }}
- +
+
+

Independent learner

+
+

+ Register below if you are not part of a school or club and wish to set up a home + learning account. +

+ You will have access to learning resources for Rapid Router.
- +
+ {% csrf_token %} +
+ {{ independent_student_signup_form.date_of_birth.help_text }} +
+ {{ independent_student_signup_form.date_of_birth }} +
+
+ {{ independent_student_signup_form.date_of_birth.errors }} +
+ {{ independent_student_signup_form.non_field_errors }} +
+ + {{ independent_student_signup_form.name }} + {{ independent_student_signup_form.name.help_text }} + {{ independent_student_signup_form.name.errors }} +
+
+ + {{ independent_student_signup_form.email }} + {{ independent_student_signup_form.email.help_text }} + {{ independent_student_signup_form.email.errors }} +
+ +
+ {% include 'portal/partials/register_newsletter_tickbox.html' with newsletter_ticked=independent_student_signup_form.newsletter_ticked %} +
+
+ We will send your parent/guardian an email to ask them to activate the account + for you. Once they've done this you'll be able to log in using your name and password. +
+
+ +
+ {{ independent_student_signup_form.password }} +
+ {{ independent_student_signup_form.password.help_text }} + {{ independent_student_signup_form.password.errors }} +
+
+ +
+ {{ independent_student_signup_form.confirm_password }} +
+ {{ independent_student_signup_form.confirm_password.help_text }} + {{ independent_student_signup_form.confirm_password.errors }} +
+
+
+
+
+
+
+
+
+
+ {{ independent_student_signup_form.captcha }} +
+
+ +
+ title + message + Cancel Confirm +
+
+
-
- - - - + + + - + async function isPwnedPasswordApiAvailable(url) { + try { + const response = await fetch(url, {metheod: 'GET'}) + return response.ok; + } + catch (error) { + console.error(error) + return false; + } + } + async function handlePwnedPasswordApiAvailability() { + const url = 'https://api.pwnedpasswords.com/range/00000'; + const isAvailable = await isPwnedPasswordApiAvailable(url); + const errorTitle = 'Password Leak Verification Unavailable'; + const errorMessage = "We were unable to verify if the password you entered was involved in any known password leaks. For your account's security, please ensure that you choose a strong and unique password, avoiding commonly used passwords."; + if (!isAvailable) { + showPopupConfirmation(errorTitle, errorMessage, 'hidePopupConfirmation()') + } + } + handlePwnedPasswordApiAvailability() + handlePasswordStrength(); // the password strength text is updated dynamically hence this is the initial first call + $(document).ready(function() { + $('#id_teacher_signup-teacher_password, #id_independent_student_signup-password').on('input change focus blur', handlePasswordStrength); + +}); + {% endblock content %} diff --git a/portal/tests/test_independent_student.py b/portal/tests/test_independent_student.py index cdbb7697a..38902ecda 100644 --- a/portal/tests/test_independent_student.py +++ b/portal/tests/test_independent_student.py @@ -22,6 +22,7 @@ from selenium.webdriver.support.wait import WebDriverWait from portal.forms.error_messages import INVALID_LOGIN_MESSAGE +from portal.helpers.password import generate_strong_password from .base_test import BaseTest from .pageObjects.portal.home_page import HomePage from .utils.messages import ( @@ -59,65 +60,47 @@ def test_signup_short_password_fails(self): def test_signup_common_password_fails(self): c = Client() - response = c.post( - reverse("register"), - { - "independent_student_signup-date_of_birth_day": 7, - "independent_student_signup-date_of_birth_month": 10, - "independent_student_signup-date_of_birth_year": 1997, - "independent_student_signup-name": "Test Name", - "independent_student_signup-email": "test@email.com", - "independent_student_signup-consent_ticked": "on", - "independent_student_signup-password": "Password1", - "independent_student_signup-confirm_password": "Password1", - "g-recaptcha-response": "something", - }, - ) - - # Assert response isn't a redirect (submit failure) - assert response.status_code == 200 - - def test_signup_passwords_do_not_match_fails(self): - c = Client() - - response = c.post( - reverse("register"), - { - "independent_student_signup-date_of_birth_day": 7, - "independent_student_signup-date_of_birth_month": 10, - "independent_student_signup-date_of_birth_year": 1997, - "independent_student_signup-name": "Test Name", - "independent_student_signup-email": "test@email.com", - "independent_student_signup-consent_ticked": "on", - "independent_student_signup-password": "Password1!", - "independent_student_signup-confirm_password": "Password2!", - "g-recaptcha-response": "something", - }, - ) - - # Assert response isn't a redirect (submit failure) - assert response.status_code == 200 - - def test_signup_invalid_name_fails(self): - c = Client() - - response = c.post( - reverse("register"), - { - "independent_student_signup-date_of_birth_day": 7, - "independent_student_signup-date_of_birth_month": 10, - "independent_student_signup-date_of_birth_year": 1997, - "independent_student_signup-name": "///", - "independent_student_signup-email": "test@email.com", - "independent_student_signup-consent_ticked": "on", - "independent_student_signup-password": "Password1!", - "independent_student_signup-confirm_password": "Password1!", - "g-recaptcha-response": "something", - }, - ) - - # Assert response isn't a redirect (submit failure) - assert response.status_code == 200 + common_passwords = ["Password1", "qwerty", "123456", "Admin123"] + passwords_with_distance_2 = [ + "Bassword1", + "Pqssword1", + "Pasaword1", + "Passwords", + "Password2", + "ewerty", + "qwertyu", + "qwedty", + "qwrty", + "qwertr", + "023456", + "133456", + "123450", + "123476", + "123457", + "Badmin123", + "Aemin123", + "Admun123", + "Admin124", + "Admin113", + ] + + for password in common_passwords + passwords_with_distance_2: + response = c.post( + reverse("register"), + { + "independent_student_signup-date_of_birth_day": 7, + "independent_student_signup-date_of_birth_month": 10, + "independent_student_signup-date_of_birth_year": 1997, + "independent_student_signup-name": "Test Name", + "independent_student_signup-email": "test@email.com", + "independent_student_signup-consent_ticked": "on", + "independent_student_signup-password": password, + "independent_student_signup-confirm_password": password, + "g-recaptcha-response": "something", + }, + ) + # Assert response isn't a redirect (submit failure) + assert response.status_code == 200 def test_signup_under_13_sends_parent_email(self): c = Client() @@ -131,7 +114,7 @@ def test_signup_under_13_sends_parent_email(self): "independent_student_signup-name": "Young person", "independent_student_signup-email": "test@email.com", "independent_student_signup-consent_ticked": "on", - "independent_student_signup-password": "Password1!", + "independent_student_signup-password": generate_strong_password(), "independent_student_signup-confirm_password": "Password1!", "g-recaptcha-response": "something", }, @@ -215,8 +198,9 @@ def test_signup_duplicate_email_with_teacher(self): page = self.go_to_homepage() page = page.go_to_signup_page() + strong_password = generate_strong_password() page = page.independent_student_signup( - "indy", teacher_email, password="Password1!", confirm_password="Password1!" + "indy", teacher_email, password=strong_password, confirm_password=strong_password ) page.return_to_home_page() diff --git a/portal/tests/test_ratelimit.py b/portal/tests/test_ratelimit.py index bb700ea20..c4f2a3133 100644 --- a/portal/tests/test_ratelimit.py +++ b/portal/tests/test_ratelimit.py @@ -169,18 +169,23 @@ def test_student_login_ratelimit(self): c.login(username=teacher_email, password=teacher_password) c.post(url, data) assert not self._is_user_blocked(Student, student_name, klass_access_code) + from portal.helpers.password import generate_strong_password # now block again and test the edit by student method self._block_user(Student, student_name, klass_access_code) assert self._is_user_blocked(Student, student_name, klass_access_code) url = reverse_lazy("teacher_edit_student", kwargs={"pk": current_student.id}) - data = {"password": "password1", "confirm_password": "password1", "set_password": ""} + data = { + "password": generate_strong_password(), + "confirm_password": generate_strong_password(), + "set_password": "", + } c.post(url, data) assert not self._is_user_blocked(Student, student_name, klass_access_code) c.logout() student = Student.objects.get(id=student.id) - self._student_school_login(klass_access_code, student_name, "password1") + self._student_school_login(klass_access_code, student_name, generate_strong_password()) student = Student.objects.get(id=student.id) assert not self._is_user_blocked(Student, student_name, klass_access_code) diff --git a/portal/tests/test_teacher.py b/portal/tests/test_teacher.py index 8f10630ad..bd3e41c49 100644 --- a/portal/tests/test_teacher.py +++ b/portal/tests/test_teacher.py @@ -28,6 +28,7 @@ verify_email, ) from portal.forms.error_messages import INVALID_LOGIN_MESSAGE +from portal.helpers.password import generate_strong_password from portal.tests.base_test import click_buttons_by_id from portal.tests.test_invite_teacher import FADE_TIME from portal.tests.test_invite_teacher import WAIT_TIME @@ -339,8 +340,8 @@ def test_signup_passwords_do_not_match_fails(self): "teacher_signup-teacher_last_name": "Test Last Name", "teacher_signup-teacher_email": "test@email.com", "teacher_signup-consent_ticked": "on", - "teacher_signup-teacher_password": "StrongPassword1!", - "teacher_signup-teacher_confirm_password": "StrongPassword2!", + "teacher_signup-teacher_password": generate_strong_password(), + "teacher_signup-teacher_confirm_password": generate_strong_password(), "g-recaptcha-response": "something", }, ) @@ -357,8 +358,8 @@ def test_signup_fails_without_consent(self): "teacher_signup-teacher_first_name": "Test Name", "teacher_signup-teacher_last_name": "Test Last Name", "teacher_signup-teacher_email": "test@email.com", - "teacher_signup-teacher_password": "StrongPassword1!", - "teacher_signup-teacher_confirm_password": "StrongPassword1!", + "teacher_signup-teacher_password": generate_strong_password(), + "teacher_signup-teacher_confirm_password": generate_strong_password(), "g-recaptcha-response": "something", }, ) @@ -376,8 +377,8 @@ def test_signup_email_verification(self): "teacher_signup-teacher_last_name": "Test Last Name", "teacher_signup-teacher_email": "test@email.com", "teacher_signup-consent_ticked": "on", - "teacher_signup-teacher_password": "StrongPassword1!", - "teacher_signup-teacher_confirm_password": "StrongPassword1!", + "teacher_signup-teacher_password": generate_strong_password(), + "teacher_signup-teacher_confirm_password": generate_strong_password(), "g-recaptcha-response": "something", }, ) @@ -503,9 +504,7 @@ def test_edit_details(self): self.selenium.get(self.live_server_url) page = HomePage(self.selenium).go_to_teacher_login_page().login(email, password).open_account_tab() - page = page.change_teacher_details( - {"first_name": "Paulina", "last_name": "Koch", "current_password": "Password2!"} - ) + page = page.change_teacher_details({"first_name": "Paulina", "last_name": "Koch", "current_password": password}) assert self.is_dashboard_page(page) assert is_teacher_details_updated_message_showing(self.selenium) @@ -525,7 +524,7 @@ def test_edit_details_non_admin(self): page = HomePage(self.selenium).go_to_teacher_login_page().login(email_2, password_2).open_account_tab() page = page.change_teacher_details( - {"first_name": "Florian", "last_name": "Aucomte", "current_password": "Password2!"} + {"first_name": "Florian", "last_name": "Aucomte", "current_password": password_2} ) assert self.is_dashboard_page(page) assert is_teacher_details_updated_message_showing(self.selenium) diff --git a/portal/views/home.py b/portal/views/home.py index 2af791ac2..99c0b8288 100644 --- a/portal/views/home.py +++ b/portal/views/home.py @@ -1,5 +1,7 @@ import logging import math +import hashlib +import requests from common import email_messages from common.helpers.emails import (