feat(signup): add password strength and breach check using HIBP API
- Implemented frontend password validation for minimum strength: - Requires 8+ characters with uppercase, lowercase, digit, and special character. - Integrated haveibeenpwned (HIBP) k-anonymity API to detect breached passwords. - Display appropriate error messages for weak or pwned passwords. - Updated Message component to support "error" and "default" types with styling. - Cleaned up SignupPage form UI and removed unused refs (e.g., roleElement). - Created passwordUtils.js to isolate SHA-1 hashing and API call logic.
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
const Message = ({ message }) => {
|
||||
const Message = ({ message, type = "error" }) => {
|
||||
const date = new Date();
|
||||
|
||||
const background =
|
||||
type === "error"
|
||||
? "bg-red-100 border border-red-400 text-red-700"
|
||||
: "bg-gray-100 border border-gray-300 text-gray-800";
|
||||
|
||||
return (
|
||||
<div className="w-auto h-auto bg-gray-200 rounded-md text-start p-3 mx-4">
|
||||
<p className="">{message}</p>
|
||||
<p className="text-end text-sm ">
|
||||
{date.getDate()}/{date.getMonth()}/{date.getFullYear()}{" "}
|
||||
<div className={`rounded-md p-3 ${background}`}>
|
||||
<p className="font-medium">{message}</p>
|
||||
<p className="text-end text-sm text-gray-600">
|
||||
{date.getDate()}/{date.getMonth() + 1}/{date.getFullYear()}{" "}
|
||||
{date.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useRef } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { BACKEND_URL } from "../../constants";
|
||||
import { t } from "../../service/translation";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { isPasswordPwned } from "../../utils/passwordUtils";
|
||||
|
||||
const SignupPage = (props) => {
|
||||
const outletContext = useOutletContext?.();
|
||||
@@ -12,41 +13,72 @@ const SignupPage = (props) => {
|
||||
const firstNameElement = useRef();
|
||||
const lastNameElement = useRef();
|
||||
const emailElement = useRef();
|
||||
const roleElement = useRef();
|
||||
const passwordElement = useRef();
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRegisteration = async (event) => {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
|
||||
const password = passwordElement.current.value;
|
||||
|
||||
const strongPasswordRegex =
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$/;
|
||||
|
||||
if (!strongPasswordRegex.test(password)) {
|
||||
setError(
|
||||
"Password must be at least 8 characters long and include uppercase, lowercase, number, and special character."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pwned = await isPasswordPwned(password);
|
||||
if (pwned) {
|
||||
setError("This password previously appeared in a data breach. Please use a new password.");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Password breach check failed:", err);
|
||||
setError("Something went wrong while checking password security. Try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const user = {
|
||||
name:
|
||||
firstNameElement.current.value + " " + lastNameElement.current.value,
|
||||
email: emailElement.current.value,
|
||||
password: passwordElement.current.value,
|
||||
password: password,
|
||||
};
|
||||
|
||||
event.preventDefault();
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/v1/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(user),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const responce = await fetch(`${BACKEND_URL}/api/v1/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(user),
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await responce.json();
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success === true) {
|
||||
navigate("/user/login");
|
||||
} else {
|
||||
setError(data.message || "Registration failed. Please try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Registration error:", err);
|
||||
setError("Something went wrong on the server. Please try again later.");
|
||||
}
|
||||
|
||||
firstNameElement.current.value = "";
|
||||
lastNameElement.current.value = "";
|
||||
emailElement.current.value = "";
|
||||
passwordElement.current.value = "";
|
||||
|
||||
if (data.success == true) {
|
||||
navigate("/user/login");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -57,39 +89,31 @@ const SignupPage = (props) => {
|
||||
{t("signup_register_heading", language)}
|
||||
</h1>
|
||||
<p className="text-gray-100">{t("signup_welcome", language)}</p>
|
||||
<p className="text-gray-100 mb-6">
|
||||
{t("signup_subtitle", language)}
|
||||
</p>
|
||||
<form action="#" className="space-y-6" onSubmit={handleRegisteration}>
|
||||
<p className="text-gray-100 mb-6">{t("signup_subtitle", language)}</p>
|
||||
<form className="space-y-6" onSubmit={handleRegisteration}>
|
||||
<div className="flex flex-col gap-5 sm:flex-row">
|
||||
<div className="w-full">
|
||||
<label
|
||||
htmlFor="firstName"
|
||||
className="block mb-2 text-sm font-medium text-gray-100 dark:text-white"
|
||||
>
|
||||
<label htmlFor="firstName" className="block mb-2 text-sm font-medium text-gray-100">
|
||||
{t("signup_first_name_label", language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
ref={firstNameElement}
|
||||
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
|
||||
placeholder={t("signup_first_name_placeholder", language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<label
|
||||
htmlFor="LastName"
|
||||
className="block mb-2 text-sm font-medium text-gray-100 dark:text-white"
|
||||
>
|
||||
<label htmlFor="lastName" className="block mb-2 text-sm font-medium text-gray-100">
|
||||
{t("signup_last_name_label", language)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="LastName"
|
||||
id="lastName"
|
||||
ref={lastNameElement}
|
||||
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
|
||||
placeholder={t("signup_last_name_placeholder", language)}
|
||||
required
|
||||
/>
|
||||
@@ -97,61 +121,44 @@ const SignupPage = (props) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block mb-2 text-sm font-medium text-gray-100 dark:text-white"
|
||||
>
|
||||
<label htmlFor="email" className="block mb-2 text-sm font-medium text-gray-100">
|
||||
{t("signup_email_label", language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
ref={emailElement}
|
||||
className="bg-gray-50 border border-gray-300 text-black text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
className="bg-gray-50 border border-gray-300 text-black text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
|
||||
placeholder={t("signup_email_placeholder", language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
<label htmlFor="password" className="block mb-2 text-sm font-medium text-gray-100">
|
||||
{t("signup_password_label", language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
ref={passwordElement}
|
||||
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
|
||||
placeholder={t("signup_password_placeholder", language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember_me"
|
||||
type="checkbox"
|
||||
value=""
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember_me"
|
||||
className="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>
|
||||
{t("signup_remember_me", language)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm font-medium">{error}</p>}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
className="text-white w-1/2 backdrop-blur-lg bg-gradient-to-tr from-slate-100/15 to-slate-200/15 shadow-lg hover:backdrop-blur-lg focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
className="text-white w-1/2 backdrop-blur-lg bg-gradient-to-tr from-slate-100/15 to-slate-200/15 shadow-lg hover:backdrop-blur-lg focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5"
|
||||
>
|
||||
{t("signup_register_button", language)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-center mt-4">
|
||||
{t("signup_already_have_account", language)}{" "}
|
||||
<Link to={"/user/login"} className="text-blue-600 hover:underline">
|
||||
@@ -162,7 +169,7 @@ const SignupPage = (props) => {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg shadow-md text-center w-auto">
|
||||
<div className="flex flex-col items-center justify-center h-full ">
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<h1 className="text-6xl font-bold text-white mb-4 md:text-6xl lg:text-9xl ml-8">
|
||||
{t("signup_journey_heading", language)}
|
||||
<br />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export async function isPasswordPwned(password) {
|
||||
const sha1 = await hashPassword(password);
|
||||
const prefix = sha1.substring(0, 5);
|
||||
const suffix = sha1.substring(5);
|
||||
|
||||
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
|
||||
const text = await response.text();
|
||||
|
||||
const found = text
|
||||
.split("\n")
|
||||
.some((line) => line.split(":")[0] === suffix.toUpperCase());
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
async function hashPassword(password) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
||||
}
|
||||
Reference in New Issue
Block a user