Skip to content

Commit 8b46046

Browse files
authored
Merge pull request #94 from isaacphysics/feature/zxcvbn-password-strength
zxcvbn Password Strength Warnings
2 parents f221cf7 + d5597e6 commit 8b46046

File tree

5 files changed

+128
-13
lines changed

5 files changed

+128
-13
lines changed

src/IsaacAppTypes.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,3 +387,15 @@ export interface AppAssignmentProgress {
387387
incorrectQuestionPartsCount: number;
388388
notAttemptedPartResults: number[];
389389
}
390+
391+
export interface ZxcvbnResult {
392+
calc_time: number;
393+
crack_times_display: { [key: string]: string };
394+
crack_times_seconds: { [key: string]: number };
395+
feedback: { [key: string]: any };
396+
guesses: number;
397+
guesses_log10: number;
398+
password: string;
399+
score: number;
400+
sequence: any;
401+
}

src/app/components/elements/UserPassword.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import {Button, CardBody, Col, Form, FormFeedback, FormGroup, Input, Label, Row} from "reactstrap";
1+
import {Button, CardBody, Col, FormFeedback, FormGroup, Input, Label, Row} from "reactstrap";
22
import React, {useState} from "react";
3-
import {LoggedInUser, ValidationUser} from "../../../IsaacAppTypes";
3+
import {ValidationUser, ZxcvbnResult} from "../../../IsaacAppTypes";
44
import {UserAuthenticationSettingsDTO} from "../../../IsaacApiTypes";
5-
import {MINIMUM_PASSWORD_LENGTH, validateEmail, validatePassword} from "../../services/validation";
5+
import {MINIMUM_PASSWORD_LENGTH, validateEmail} from "../../services/validation";
66
import {resetPassword} from "../../state/actions";
7+
import {loadZxcvbnIfNotPresent, passwordDebounce, passwordStrengthText} from "../../services/passwordStrength";
78

89
interface UserPasswordProps {
910
currentPassword?: string;
@@ -22,6 +23,7 @@ export const UserPassword = (
2223
{currentPassword, currentUserEmail, setCurrentPassword, myUser, setMyUser, isNewPasswordConfirmed, userAuthSettings, setNewPassword, setNewPasswordConfirm, newPasswordConfirm}: UserPasswordProps) => {
2324

2425
const [passwordResetRequested, setPasswordResetRequested] = useState(false);
26+
const [passwordFeedback, setPasswordFeedback] = useState<ZxcvbnResult | null>(null);
2527

2628
const resetPasswordIfValidEmail = () => {
2729
if (currentUserEmail && validateEmail(currentUserEmail)) {
@@ -37,7 +39,7 @@ export const UserPassword = (
3739
<Row>
3840
<Col md={{size: 6, offset: 3}}>
3941
<FormGroup>
40-
<Label htmlFor="password-current">Current Password</Label>
42+
<Label htmlFor="password-current">Current password</Label>
4143
<Input
4244
id="password-current" type="password" name="current-password"
4345
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@@ -50,23 +52,36 @@ export const UserPassword = (
5052
<Row>
5153
<Col md={{size: 6, offset: 3}}>
5254
<FormGroup>
53-
<Label htmlFor="new-password">New Password</Label>
55+
<Label htmlFor="new-password">New password</Label>
5456
<Input
5557
invalid={!!newPasswordConfirm && !isNewPasswordConfirmed}
5658
id="new-password" type="password" name="new-password"
5759
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
5860
setNewPassword(e.target.value);
61+
passwordDebounce(e.target.value, setPasswordFeedback);
5962
}}
63+
onBlur={(e: React.ChangeEvent<HTMLInputElement>) => {
64+
passwordDebounce(e.target.value, setPasswordFeedback);
65+
}}
66+
onFocus={loadZxcvbnIfNotPresent}
6067
aria-describedby="passwordValidationMessage"
6168
disabled={currentPassword == ""}
6269
/>
70+
{passwordFeedback &&
71+
<span className='float-right small mt-1'>
72+
<strong>Password strength: </strong>
73+
<span id="password-strength-feedback">
74+
{passwordStrengthText[(passwordFeedback as ZxcvbnResult).score]}
75+
</span>
76+
</span>
77+
}
6378
</FormGroup>
6479
</Col>
6580
</Row>
6681
<Row>
6782
<Col md={{size: 6, offset: 3}}>
6883
<FormGroup>
69-
<Label htmlFor="password-confirm">Re-enter New Password</Label>
84+
<Label htmlFor="password-confirm">Re-enter new password</Label>
7085
<Input
7186
invalid={!!currentPassword && !isNewPasswordConfirmed}
7287
id="password-confirm"

src/app/components/handlers/PasswordResetHandler.tsx

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import React, {useEffect, useState} from 'react';
22
import {connect} from "react-redux";
3-
import {verifyPasswordReset, handlePasswordReset} from "../../state/actions";
4-
import {Button, Container, FormFeedback, Input, Label, Card, CardBody, Form, FormGroup, CardFooter} from "reactstrap";
3+
import {handlePasswordReset, verifyPasswordReset} from "../../state/actions";
4+
import {Button, Card, CardBody, CardFooter, Container, Form, FormFeedback, FormGroup, Input, Label} from "reactstrap";
55
import {AppState, ErrorState} from "../../state/reducers";
6+
import {ZxcvbnResult} from "../../../IsaacAppTypes";
7+
import {loadZxcvbnIfNotPresent, passwordDebounce, passwordStrengthText} from "../../services/passwordStrength";
68

79
const stateToProps = (state: AppState, {match: {params: {token}}}: any) => ({
810
errorMessage: state ? state.error : null,
@@ -25,6 +27,9 @@ const ResetPasswordHandlerComponent = ({urlToken, handleResetPassword, verifyPas
2527

2628
const [isValidPassword, setValidPassword] = useState(true);
2729
const [currentPassword, setCurrentPassword] = useState("");
30+
const [passwordFeedback, setPasswordFeedback] = useState<ZxcvbnResult | null>(null);
31+
32+
loadZxcvbnIfNotPresent();
2833

2934
const validateAndSetPassword = (event: any) => {
3035
setValidPassword(
@@ -41,16 +46,31 @@ const ResetPasswordHandlerComponent = ({urlToken, handleResetPassword, verifyPas
4146

4247
return <Container id="email-verification">
4348
<div>
44-
<h3>Password Change</h3>
49+
<h3>Password change</h3>
4550
<Card>
4651
<CardBody>
4752
<Form name="passwordReset">
4853
<FormGroup>
49-
<Label htmlFor="password-input">New Password</Label>
50-
<Input id="password" type="password" name="password" required/>
54+
<Label htmlFor="password-input">New password</Label>
55+
<Input id="password" type="password" name="password"
56+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
57+
passwordDebounce(e.target.value, setPasswordFeedback);
58+
}}
59+
onBlur={(e: React.ChangeEvent<HTMLInputElement>) => {
60+
passwordDebounce(e.target.value, setPasswordFeedback);
61+
}}
62+
required/>
63+
{passwordFeedback &&
64+
<span className='float-right small mt-1'>
65+
<strong>Password strength: </strong>
66+
<span id="password-strength-feedback">
67+
{passwordStrengthText[(passwordFeedback as ZxcvbnResult).score]}
68+
</span>
69+
</span>
70+
}
5171
</FormGroup>
5272
<FormGroup>
53-
<Label htmlFor="password-confirm">Re-enter New Password</Label>
73+
<Label htmlFor="password-confirm">Re-enter new password</Label>
5474
<Input invalid={!isValidPassword} id="password-confirm" type="password" name="password" onBlur={(e: any) => {
5575
validateAndSetPassword(e);
5676
(e.target.value == (document.getElementById("password") as HTMLInputElement).value) ? setCurrentPassword(e.target.value) : null}

src/app/components/pages/Registration.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ import {
1616
Label,
1717
Row
1818
} from "reactstrap";
19-
import {LoggedInUser, LoggedInValidationUser, UserPreferencesDTO} from "../../../IsaacAppTypes";
19+
import {LoggedInUser, LoggedInValidationUser, UserPreferencesDTO, ZxcvbnResult} from "../../../IsaacAppTypes";
2020
import {AppState} from "../../state/reducers";
2121
import {updateCurrentUser} from "../../state/actions";
2222
import {history} from "../../services/history"
2323
import {isDobOverThirteen, validateEmail, validatePassword} from "../../services/validation";
2424
import {TitleAndBreadcrumb} from "../elements/TitleAndBreadcrumb";
2525
import * as persistence from "../../services/localStorage"
2626
import {KEY} from "../../services/localStorage"
27+
import {loadZxcvbnIfNotPresent, passwordDebounce, passwordStrengthText} from "../../services/passwordStrength"
2728
import {DateInput} from "../elements/DateInput";
2829
import {FIRST_LOGIN_STATE} from "../../services/firstLogin";
2930
import {Redirect} from "react-router";
@@ -60,9 +61,13 @@ const RegistrationPageComponent = ({user, updateCurrentUser, errorMessage, userE
6061
password: null,
6162
})
6263
);
64+
65+
loadZxcvbnIfNotPresent();
66+
6367
const [unverifiedPassword, setUnverifiedPassword] = useState(userPassword);
6468
const [dobCheckboxChecked, setDobCheckboxChecked] = useState(false);
6569
const [attemptedSignUp, setAttemptedSignUp] = useState(false);
70+
const [passwordFeedback, setPasswordFeedback] = useState<ZxcvbnResult | null>(null);
6671

6772

6873
// Values derived from inputs (props and state)
@@ -165,8 +170,20 @@ const RegistrationPageComponent = ({user, updateCurrentUser, errorMessage, userE
165170
defaultValue={userPassword}
166171
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
167172
setUnverifiedPassword(e.target.value);
173+
passwordDebounce(e.target.value, setPasswordFeedback);
174+
}}
175+
onBlur={(e: React.ChangeEvent<HTMLInputElement>) => {
176+
passwordDebounce(e.target.value, setPasswordFeedback);
168177
}}
169178
/>
179+
{passwordFeedback &&
180+
<span className='float-right small mt-1'>
181+
<strong>Password strength: </strong>
182+
<span id="password-strength-feedback">
183+
{passwordStrengthText[(passwordFeedback as ZxcvbnResult).score]}
184+
</span>
185+
</span>
186+
}
170187
</FormGroup>
171188
</Col>
172189
<Col md={6}>

src/app/services/passwordStrength.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {ZxcvbnResult} from "../../IsaacAppTypes";
2+
3+
export const passwordStrengthText: {[score: number]: string} = {
4+
0: "Very Weak",
5+
1: "Weak",
6+
2: "Fair",
7+
3: "Strong",
8+
4: "Very Strong"
9+
};
10+
11+
12+
export function loadZxcvbnIfNotPresent() {
13+
// Since zxcvbn.js is ~1MB we only want to load it when it is genuinely required.
14+
// We also don't want to load it if we already have:
15+
let zxcvbnScriptId = "zxcvbn-script";
16+
if (!('zxcvbn' in window) && !document.getElementById(zxcvbnScriptId)) {
17+
let zxcvbnScript = document.createElement('script');
18+
zxcvbnScript.id = zxcvbnScriptId;
19+
zxcvbnScript.src = 'https://cdn.isaaccomputerscience.org/vendor/dropbox/zxcvbn-isaac.js';
20+
zxcvbnScript.type = 'text/javascript';
21+
zxcvbnScript.async = true;
22+
document.head.appendChild(zxcvbnScript);
23+
}
24+
}
25+
26+
const maxPasswordCheckChars = 50; // Check only the first maxPasswordCheckChars of a password.
27+
28+
function calculatePasswordStrength(password: string, firstName?: string, lastName?: string, email?: string) {
29+
if (!password || !('zxcvbn' in window)) {
30+
// Fail fast on empty input or if library not loaded!
31+
return null;
32+
}
33+
let isaacTerms = ["Isaac Computer Science", "Isaac", "IsaacComputerScience", "isaaccomputerscience.org",
34+
"Isaac Computer", "Isaac CS", "IsaacCS", "ICS",
35+
"ComputerScience", "Computer Science", "Computer", "Science", "CompSci", "Computing",
36+
"A Level", "ALevel", "A-Level", "Homework", "Classroom", "School", "College", "Lesson",
37+
"http", "https", "https://", firstName, lastName, email];
38+
let passwordToCheck = password.substring(0, maxPasswordCheckChars).replace(/\s/g, "");
39+
let feedback: ZxcvbnResult = (window as any)['zxcvbn'](passwordToCheck, isaacTerms);
40+
return feedback;
41+
}
42+
43+
44+
let timer: any = null;
45+
46+
export function passwordDebounce(password: string, callback: (feedback: ZxcvbnResult|null) => void) {
47+
if (timer !== null) {
48+
clearTimeout(timer);
49+
}
50+
timer = setTimeout(() => callback(calculatePasswordStrength(password)), 300);
51+
}

0 commit comments

Comments
 (0)