From fe00f5fb1c89e3bb5eb4686f2894249105998bc8 Mon Sep 17 00:00:00 2001 From: SKairinos Date: Mon, 15 Jul 2024 16:22:06 +0000 Subject: [PATCH] new password form --- src/app/schemas.ts | 115 ++++++++++++++++------- src/components/NewPasswordField.tsx | 72 ++++++++++++++ src/components/index.tsx | 5 + src/pages/resetPassword/PasswordForm.tsx | 4 +- 4 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 src/components/NewPasswordField.tsx create mode 100644 src/components/index.tsx diff --git a/src/app/schemas.ts b/src/app/schemas.ts index 96a8f77..0f27d91 100644 --- a/src/app/schemas.ts +++ b/src/app/schemas.ts @@ -1,35 +1,80 @@ -import * as yup from "yup" - -export const classIdSchema = yup - .string() - .matches(/^[A-Z0-9]{5}$/, "Invalid class code") - -const passwordSchema = yup.string().required("required") - -export const teacherPasswordSchema = passwordSchema.test({ - message: "too-weak", - test: password => - password.length >= 10 && - !( - password.search(/[A-Z]/) === -1 || - password.search(/[a-z]/) === -1 || - password.search(/[0-9]/) === -1 || - password.search(/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/) === -1 - ), -}) - -export const studentPasswordSchema = passwordSchema.test({ - message: "too-weak", - test: password => password.length >= 6, -}) - -export const indyPasswordSchema = passwordSchema.test({ - message: "too-weak", - test: password => - password.length >= 8 && - !( - password.search(/[A-Z]/) === -1 || - password.search(/[a-z]/) === -1 || - password.search(/[0-9]/) === -1 - ), -}) +import CryptoJS from "crypto-js" +import { string as YupString, type Schema, type StringSchema } from "yup" + +type Options = Partial<{ schema: S } & Extras> + +export function classIdSchema(options?: Options) { + const { schema = YupString() } = options || {} + + return schema.matches(/^[A-Z0-9]{5}$/, "invalid class code") +} + +export function teacherPasswordSchema(options?: Options) { + const { schema = YupString() } = options || {} + + return schema + .min(10, "password must be at least 10 characters long") + .matches(/[A-Z]/, "password must contain at least one uppercase letter") + .matches(/[a-z]/, "password must contain at least one lowercase letter") + .matches(/[0-9]/, "password must contain at least one digit") + .matches( + /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/, + "password must contain at least one special character", + ) +} + +export function studentPasswordSchema(options?: Options) { + const { schema = YupString() } = options || {} + + return schema.min(6, "password must be at least 6 characters long") +} + +export function indyPasswordSchema(options?: Options) { + const { schema = YupString() } = options || {} + + return schema + .min(8, "password must be at least 8 characters long") + .matches(/[A-Z]/, "password must contain at least one uppercase letter") + .matches(/[a-z]/, "password must contain at least one lowercase letter") + .matches(/[0-9]/, "password must contain at least one digit") +} + +export function pwnedPasswordSchema( + options?: Options void }>, +) { + const { schema = YupString().required(), onError } = options || {} + + return schema.test({ + message: "password is too common", + test: async password => { + try { + // Do not raise validation error if no password. + if (!password) return true + + // Hash the password. + const hashedPassword = CryptoJS.SHA1(password).toString().toUpperCase() + const hashPrefix = hashedPassword.substring(0, 5) + const hashSuffix = hashedPassword.substring(5) + + // Call Pwned Passwords API. + // https://haveibeenpwned.com/API/v3#SearchingPwnedPasswordsByRange + const response = await fetch( + `https://api.pwnedpasswords.com/range/${hashPrefix}`, + ) + // TODO: Standardize how to log non-okay responses. + if (!response.ok) throw Error() + + // Parse response. + const data = await response.text() + return !data.includes(hashSuffix) + } catch (error) { + console.error(error) + + if (onError) onError(error) + + // Do not raise validation error if a different error occurred. + return true + } + }, + }) +} diff --git a/src/components/NewPasswordField.tsx b/src/components/NewPasswordField.tsx new file mode 100644 index 0000000..bc665d7 --- /dev/null +++ b/src/components/NewPasswordField.tsx @@ -0,0 +1,72 @@ +import { Button, Dialog, Typography } from "@mui/material" +import { type FC, useState } from "react" + +import { PasswordField } from "codeforlife/components/form" + +import { + indyPasswordSchema, + pwnedPasswordSchema, + studentPasswordSchema, + teacherPasswordSchema, +} from "../app/schemas" + +export interface NewPasswordFieldProps { + userType: "teacher" | "independent" | "student" +} + +const NewPasswordField: FC = ({ userType }) => { + const [pwnedPasswords, setPwnedPasswords] = useState<{ + online: boolean + dialogOpen: boolean + }>({ online: true, dialogOpen: false }) + + let schema = { + teacher: teacherPasswordSchema, + independent: indyPasswordSchema, + student: studentPasswordSchema, + }[userType]() + + if ( + pwnedPasswords.online && + (userType === "teacher" || userType === "independent") + ) { + schema = pwnedPasswordSchema({ + schema, + onError: () => { + // Alert user test couldn't be carried out. + setPwnedPasswords({ online: false, dialogOpen: true }) + }, + }) + } + + return ( + <> + + + + Password Vulnerability Check Unavailable + + + We are currently unable to check your password vulnerability. Please + ensure that you are using a strong password. If you are happy to + continue, please confirm. + + + + + ) +} + +export default NewPasswordField diff --git a/src/components/index.tsx b/src/components/index.tsx new file mode 100644 index 0000000..1b05841 --- /dev/null +++ b/src/components/index.tsx @@ -0,0 +1,5 @@ +import NewPasswordField, { + type NewPasswordFieldProps, +} from "./NewPasswordField" + +export { NewPasswordField, type NewPasswordFieldProps } diff --git a/src/pages/resetPassword/PasswordForm.tsx b/src/pages/resetPassword/PasswordForm.tsx index 0d8e2db..9fbec4d 100644 --- a/src/pages/resetPassword/PasswordForm.tsx +++ b/src/pages/resetPassword/PasswordForm.tsx @@ -6,7 +6,7 @@ import * as form from "codeforlife/components/form" import { LinkButton } from "codeforlife/components/router" import { useResetPasswordMutation } from "../../api/user" -// import CflPasswordFields from "../../features/cflPasswordFields/CflPasswordFields" +import { NewPasswordField } from "../../components" import { paths } from "../../router" export interface PasswordFormProps { @@ -52,7 +52,7 @@ const PasswordForm: FC = ({ userType, userId, token }) => { resetPassword([userId, { token, password }]) } > - {/* */} + Cancel