Skip to content

Commit 441ed47

Browse files
committed
FIX - pw security issue
1 parent 10cce03 commit 441ed47

5 files changed

Lines changed: 84 additions & 18 deletions

File tree

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/authRoutes.js

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
getUserById,
88
setPasswordForExistingUser,
99
toPublicUser,
10+
updateUserPasswordHash,
1011
updateUserRoleFromEmail,
1112
} from "./users.js";
13+
import { hashPasswordForStorage, verifyPasswordForLogin } from "./passwordHash.js";
1214

1315
const passwordLoginLimiter = rateLimit({
1416
windowMs: 10 * 60 * 1000,
@@ -57,21 +59,6 @@ function isValidEmail(email) {
5759
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
5860
}
5961

60-
function hashPassword(password) {
61-
const salt = crypto.randomBytes(16).toString("hex");
62-
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
63-
return `scrypt:${salt}:${hash}`;
64-
}
65-
66-
function verifyPassword(password, storedHash) {
67-
const [algorithm, salt, hash] = String(storedHash || "").split(":");
68-
if (algorithm !== "scrypt" || !salt || !hash) return false;
69-
70-
const submittedHash = crypto.scryptSync(password, salt, 64);
71-
const storedBuffer = Buffer.from(hash, "hex");
72-
return storedBuffer.length === submittedHash.length && crypto.timingSafeEqual(storedBuffer, submittedHash);
73-
}
74-
7562
function signLocalUserId(userId) {
7663
const payload = String(userId);
7764
const signature = crypto.createHmac("sha256", localDevCookieSecret()).update(payload).digest("hex");
@@ -135,15 +122,19 @@ export function createAuthRouter() {
135122
let user;
136123

137124
if (existingUser?.password_hash) {
138-
if (!verifyPassword(password, existingUser.password_hash)) {
125+
const outcome = verifyPasswordForLogin(password, existingUser.password_hash);
126+
if (!outcome.valid) {
139127
res.status(401).json({ error: "Wrong email or password." });
140128
return;
141129
}
130+
if (outcome.migrateToHash) {
131+
await updateUserPasswordHash(existingUser.id, outcome.migrateToHash);
132+
}
142133
user = await updateUserRoleFromEmail(existingUser.id, email);
143134
} else if (existingUser) {
144-
user = await setPasswordForExistingUser(existingUser.id, email, hashPassword(password));
135+
user = await setPasswordForExistingUser(existingUser.id, email, hashPasswordForStorage(password));
145136
} else {
146-
user = await createUserFromEmailPassword(email, hashPassword(password));
137+
user = await createUserFromEmailPassword(email, hashPasswordForStorage(password));
147138
}
148139

149140
req.session.userId = user.id;

server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"dev": "node index.js"
88
},
99
"dependencies": {
10+
"bcryptjs": "^3.0.3",
1011
"cookie-parser": "^1.4.7",
1112
"dotenv": "^17.4.2",
1213
"express": "^4.21.2",

server/passwordHash.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import crypto from "crypto";
2+
import bcrypt from "bcryptjs";
3+
4+
/** Work factor for bcrypt; OWASP suggests >= 10 for interactive logins. */
5+
const BCRYPT_COST = 12;
6+
7+
/**
8+
* Hash a password for storage (bcrypt).
9+
* @param {string} plain
10+
*/
11+
export function hashPasswordForStorage(plain) {
12+
return bcrypt.hashSync(plain, BCRYPT_COST);
13+
}
14+
15+
function verifyLegacyScrypt(plain, storedHash) {
16+
const [algorithm, salt, hash] = storedHash.split(":");
17+
if (algorithm !== "scrypt" || !salt || !hash) return false;
18+
19+
/* Legacy `scrypt:` rows only; verifying with the same parameters used when they were stored.
20+
New passwords use bcrypt. */
21+
const submittedHash = crypto.scryptSync(plain, salt, 64); // lgtm[js/insufficient-password-hash]
22+
const storedBuffer = Buffer.from(hash, "hex");
23+
return storedBuffer.length === submittedHash.length && crypto.timingSafeEqual(storedBuffer, submittedHash);
24+
}
25+
26+
/**
27+
* Verify a password against the stored value. Supports legacy `scrypt:` rows
28+
* and bcrypt (`$2a$` / `$2b$` / `$2y$`).
29+
*
30+
* @returns {{ valid: boolean, migrateToHash?: string }}
31+
*/
32+
export function verifyPasswordForLogin(plain, storedHash) {
33+
const stored = String(storedHash || "");
34+
if (!stored) return { valid: false };
35+
36+
if (stored.startsWith("scrypt:")) {
37+
if (!verifyLegacyScrypt(plain, stored)) return { valid: false };
38+
return { valid: true, migrateToHash: hashPasswordForStorage(plain) };
39+
}
40+
41+
if (!bcrypt.compareSync(plain, stored)) return { valid: false };
42+
return { valid: true };
43+
}

server/users.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,27 @@ export async function setPasswordForExistingUser(id, email, passwordHash) {
415415
return result.rows[0];
416416
}
417417

418+
export async function updateUserPasswordHash(id, passwordHash) {
419+
if (!pool) {
420+
throw new Error("DATABASE_URL is not set.");
421+
}
422+
423+
const result = await pool.query(
424+
`
425+
UPDATE users
426+
SET
427+
password_hash = $1,
428+
password_set_at = NOW(),
429+
updated_at = NOW()
430+
WHERE id = $2
431+
RETURNING *
432+
`,
433+
[passwordHash, id]
434+
);
435+
436+
return result.rows[0] ?? null;
437+
}
438+
418439
export async function createUserFromEmailPassword(email, passwordHash) {
419440
if (!pool) {
420441
throw new Error("DATABASE_URL is not set.");

0 commit comments

Comments
 (0)