diff --git a/package-lock.json b/package-lock.json index 68bc1cd28..f52534404 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,8 @@ "redux-thunk": "~2.4.0", "serve-favicon": "~2.5.0", "usehooks-ts": "~3.1.0", - "xml2js": "~0.5.0" + "xml2js": "~0.5.0", + "zxcvbn": "~4.4.2" }, "devDependencies": { "@redux-devtools/extension": "~3.2.5", @@ -10797,6 +10798,12 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 29a440be8..6b62d4dc1 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,8 @@ "redux-thunk": "~2.4.0", "serve-favicon": "~2.5.0", "usehooks-ts": "~3.1.0", - "xml2js": "~0.5.0" + "xml2js": "~0.5.0", + "zxcvbn": "~4.4.2" }, "devDependencies": { "@redux-devtools/extension": "~3.2.5", diff --git a/src/server/routes/users.js b/src/server/routes/users.js index 66a7e9e53..ffbda3785 100644 --- a/src/server/routes/users.js +++ b/src/server/routes/users.js @@ -11,6 +11,7 @@ const validate = require('jsonschema').validate; const { getConnection } = require('../db'); const jwt = require('jsonwebtoken'); const secretToken = require('../config').secretToken; +const { validatePasswordPolicy } = require('../util/validatePassword'); const { STRING_GENERAL_MAX_LENGTH, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, TOKEN_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, NUMERIC_ID_MAX_LENGTH } = require('../util/validationConstants'); const router = express.Router(); @@ -120,6 +121,21 @@ router.post('/create', adminAuthMiddleware('create a user.'), async (req, res) = } else { try { const { username, password, role, note } = req.body; + + /* Determine the password’s size in bytes (using UTF-8 encoding) since some characters + take more than one byte, then reject the request if it exceeds 72 bytes to prevent + bcrypt from silently truncating the password before hashing */ + const byteLength = Buffer.byteLength(password, 'utf8'); + if (byteLength > 72) { + return res.status(400).send({ message: 'Password must not exceed 72 bytes.' }); + } + + // Password policy validation + const errorMessage = validatePasswordPolicy(password, username, role); + if (errorMessage) { + return res.status(400).send({ message: errorMessage }); + } + const conn = getConnection(); // Check if user already exists const currentUser = await User.getByUsername(username, conn); diff --git a/src/server/test/util/passwordPolicy.js b/src/server/test/util/passwordPolicy.js new file mode 100644 index 000000000..94f8379a0 --- /dev/null +++ b/src/server/test/util/passwordPolicy.js @@ -0,0 +1,72 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +const { chai, mocha, app } = require('../common'); +const expect = chai.expect; + +const { validatePasswordPolicy } = require('../../util/validatePassword'); + +mocha.describe('Password Policy Validation', () => { + + mocha.it('Rejects short passwords', () => { + const result = validatePasswordPolicy('short', 'user1', 'user'); + expect(result).to.be.a('string'); + }); + + mocha.it('Rejects admin passwords under 14 chars', () => { + const result = validatePasswordPolicy('shortpassword', 'admin1', 'admin'); + expect(result).to.include('14'); + }); + + mocha.it('Rejects passwords containing username', () => { + const result = validatePasswordPolicy('user1password', 'user1', 'user'); + expect(result).to.include('username'); + }); + + mocha.it('Rejects weak passwords (zxcvbn)', () => { + const result = validatePasswordPolicy('aaaaaaaa', 'user1', 'user'); + expect(result).to.include('weak'); + }); + + mocha.it('Accepts strong passwords', () => { + const result = validatePasswordPolicy('StrongPassphrase123!', 'user1', 'user'); + expect(result).to.equal(null); + }); + +}); + +const chaiHttp = require('chai-http'); +chai.use(chaiHttp); + +mocha.describe('User Creation Password Policy', () => { + + let adminToken; + +mocha.before(async () => { + const res = await chai.request(app) + .post('/api/login') + .send({ + username: 'test@example.invalid', + password: 'password' + }); + + adminToken = res.body.token; +}); + + mocha.it('Rejects weak password on create', async () => { + const res = await chai.request(app) + .post('/api/users/create') + .set('token', adminToken) + .send({ + username: 'testuser', + password: 'password', + role: 'user', + note: 'test' + }); + + expect(res).to.have.status(400); + }); +}); \ No newline at end of file diff --git a/src/server/util/validatePassword.js b/src/server/util/validatePassword.js new file mode 100644 index 000000000..246cd632b --- /dev/null +++ b/src/server/util/validatePassword.js @@ -0,0 +1,39 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +const zxcvbn = require('zxcvbn'); + +//Temporary list, until we decide a better approach like a specific file +const COMMON_PASSWORDS = new Set([ + 'password', + '12345678', +]); + +function validatePasswordPolicy(password, username, role = []) { + const minLength = role === 'admin' ? 14 : 8; + if (password.length < minLength) { + return `Password must be at least ${minLength} characters long.`; + } + + // Username inclusion + if (username && password.toLowerCase().includes(username.toLowerCase())) { + return 'Password must not contain your username.'; + } + + // Common passwords + if (COMMON_PASSWORDS.has(password.toLowerCase())) { + return 'Password is too common.'; + } + + // Strength check + const result = zxcvbn(password); + if (result.score < 3) { + return 'Password is too weak.'; + } + + return null; // valid +} +module.exports = { validatePasswordPolicy }; \ No newline at end of file