From 3bd43cdcb345b0ee61fd8eade29426ee65915c31 Mon Sep 17 00:00:00 2001 From: xpoes123 Date: Mon, 29 Jun 2026 23:54:36 -0400 Subject: [PATCH] security: migrate password hashing to bcrypt with soft legacy fallback --- package-lock.json | 12 ++++++++++++ package.json | 1 + routes/auth/signup.js | 2 +- server/authentication.js | 41 ++++++++++++++++++++++++++++++++++------ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c42f85fb..2353ee6cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "qbreader", "version": "26.04", "dependencies": { + "bcryptjs": "^2.4.3", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "cookie-session": "^2.0.0", @@ -1672,6 +1673,12 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -8361,6 +8368,11 @@ } } }, + "bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", diff --git a/package.json b/package.json index 9b664feaa..c84a7d78b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "webpack": "webpack --config webpack.config.js" }, "dependencies": { + "bcryptjs": "^2.4.3", "bootstrap": "^5.3.8", "bootstrap-icons": "^1.13.1", "cookie-session": "^2.0.0", diff --git a/routes/auth/signup.js b/routes/auth/signup.js index 519d8cc04..4f4791c46 100644 --- a/routes/auth/signup.js +++ b/routes/auth/signup.js @@ -30,7 +30,7 @@ router.post('/', async (req, res) => { req.session.token = generateToken(username); req.session.expires = expires; - const password = saltAndHashPassword(req.body.password); + const password = await saltAndHashPassword(req.body.password); const email = req.body.email; await createUser(username, password, email); sendVerificationEmail(username); diff --git a/server/authentication.js b/server/authentication.js index 33d17e082..30cb97ce0 100644 --- a/server/authentication.js +++ b/server/authentication.js @@ -6,11 +6,14 @@ import getUserId from '../database/account-info/get-user-id.js'; import updateUser from '../database/account-info/update-user.js'; import verifyEmail from '../database/account-info/verify-email.js'; +import bcrypt from 'bcryptjs'; import { createHash } from 'crypto'; import jsonwebtoken from 'jsonwebtoken'; import { ObjectId } from 'mongodb'; const { sign, verify } = jsonwebtoken; +const BCRYPT_ROUNDS = 12; + const baseURL = process.env.BASE_URL ?? (process.env.NODE_ENV === 'production' ? 'https://www.qbreader.org' : 'http://localhost:3000'); const salt = process.env.SALT ? process.env.SALT : 'salt'; @@ -32,7 +35,23 @@ const activeResetPasswordTokens = {}; * @returns {Promise} */ export async function checkPassword (username, password) { - return await getUserField(username, 'password') === saltAndHashPassword(password); + const stored = await getUserField(username, 'password'); + if (!stored) { + return false; + } + + // bcrypt hashes always start with `$2`; legacy base64 hashes never do. + if (stored.startsWith('$2')) { + return await bcrypt.compare(password, stored); + } + + // Legacy triple-SHA256 hash: verify, then transparently upgrade to bcrypt. + if (stored === legacyHashPassword(password)) { + await updatePassword(username, password); + return true; + } + + return false; } /** @@ -63,11 +82,21 @@ export function generateToken (username, verifiedEmail = false) { } /** - * + * Hashes a plaintext password using bcrypt (per-hash salt, configurable work factor). + * @param {String} password + * @returns {Promise} A bcrypt hash (60 chars, starts with `$2`). + */ +export async function saltAndHashPassword (password) { + return await bcrypt.hash(password, BCRYPT_ROUNDS); +} + +/** + * Legacy triple-SHA256 hash with a single global salt. Retained only to verify + * pre-migration passwords so they can be transparently upgraded to bcrypt. * @param {String} password - * @returns Base64 encoded hashed password. + * @returns {String} Base64 encoded hashed password. */ -export function saltAndHashPassword (password) { +function legacyHashPassword (password) { password = salt + password + salt; const hash = createHash('sha256').update(password).digest('base64'); const hash2 = createHash('sha256').update(hash).digest('base64'); @@ -129,8 +158,8 @@ export async function sendVerificationEmail (username) { return true; } -export function updatePassword (username, newPassword) { - return updateUser(username, { password: saltAndHashPassword(newPassword) }); +export async function updatePassword (username, newPassword) { + return await updateUser(username, { password: await saltAndHashPassword(newPassword) }); } /**