Compare commits
33 Commits
master
...
copilot/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692be2b476 | ||
|
|
87df009d66 | ||
|
|
049603f71b | ||
|
|
392c182735 | ||
|
|
95b97f8c95 | ||
|
|
eb01bba963 | ||
|
|
991aa1c037 | ||
|
|
e8b4907dfb | ||
|
|
28b40df577 | ||
|
|
b11a1d7765 | ||
|
|
b621a452a2 | ||
|
|
36d0bbdfea | ||
|
|
dec5146c58 | ||
|
|
07c2f75be5 | ||
|
|
3b34a3acf4 | ||
|
|
984f268af4 | ||
|
|
a5dd2ad75a | ||
|
|
d0bdcc575a | ||
|
|
e5f629041f | ||
|
|
3a872f78d0 | ||
|
|
854c54925a | ||
|
|
20f86cdbdd | ||
|
|
6b505447cc | ||
|
|
230b85edb7 | ||
|
|
87bf2247b3 | ||
|
|
3b1e9c01c7 | ||
|
|
a308c802af | ||
|
|
1a5379baec | ||
|
|
9c31da1756 | ||
|
|
125fb1ccf4 | ||
|
|
0a85868c1e | ||
|
|
5cd781bd51 | ||
|
|
1b886f8573 |
@ -12,6 +12,7 @@
|
|||||||
"shorthand-property-no-redundant-values": null,
|
"shorthand-property-no-redundant-values": null,
|
||||||
"color-hex-length": null,
|
"color-hex-length": null,
|
||||||
"declaration-block-no-redundant-longhand-properties": null,
|
"declaration-block-no-redundant-longhand-properties": null,
|
||||||
"at-rule-no-unknown": null
|
"at-rule-no-unknown": null,
|
||||||
|
"function-no-unknown": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ console.log("== Uptime Kuma Reset Password Tool ==");
|
|||||||
const Database = require("../server/database");
|
const Database = require("../server/database");
|
||||||
const { R } = require("redbean-node");
|
const { R } = require("redbean-node");
|
||||||
const readline = require("readline");
|
const readline = require("readline");
|
||||||
const { passwordStrength } = require("check-password-strength");
|
const { validatePassword } = require("../server/password-util");
|
||||||
const { initJWTSecret } = require("../server/util-server");
|
const { initJWTSecret } = require("../server/util-server");
|
||||||
const User = require("../server/model/user");
|
const User = require("../server/model/user");
|
||||||
const { io } = require("socket.io-client");
|
const { io } = require("socket.io-client");
|
||||||
@ -46,15 +46,24 @@ const main = async () => {
|
|||||||
"Warning: the password might be stored, in plain text, in your shell's history"
|
"Warning: the password might be stored, in plain text, in your shell's history"
|
||||||
);
|
);
|
||||||
password = confirmPassword = args["new-password"] + "";
|
password = confirmPassword = args["new-password"] + "";
|
||||||
if (passwordStrength(password).value === "Too weak") {
|
const passwordValidation = await validatePassword(password, true);
|
||||||
throw new Error("Password is too weak, please use a stronger password.");
|
if (!passwordValidation.ok) {
|
||||||
|
throw new Error(passwordValidation.msg);
|
||||||
|
}
|
||||||
|
if (passwordValidation.warning) {
|
||||||
|
console.warn("\x1b[33m%s\x1b[0m",
|
||||||
|
"Warning: " + passwordValidation.warning);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
password = await question("New Password: ");
|
password = await question("New Password: ");
|
||||||
if (passwordStrength(password).value === "Too weak") {
|
const passwordValidation = await validatePassword(password, true);
|
||||||
console.log("Password is too weak, please try again.");
|
if (!passwordValidation.ok) {
|
||||||
|
console.log(passwordValidation.msg);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (passwordValidation.warning) {
|
||||||
|
console.warn("\x1b[33m%s\x1b[0m", "Warning: " + passwordValidation.warning);
|
||||||
|
}
|
||||||
confirmPassword = await question("Confirm New Password: ");
|
confirmPassword = await question("Confirm New Password: ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
package-lock.json
generated
105
package-lock.json
generated
@ -18,7 +18,6 @@
|
|||||||
"badge-maker": "~3.3.1",
|
"badge-maker": "~3.3.1",
|
||||||
"bcryptjs": "~2.4.3",
|
"bcryptjs": "~2.4.3",
|
||||||
"chardet": "~1.4.0",
|
"chardet": "~1.4.0",
|
||||||
"check-password-strength": "^2.0.5",
|
|
||||||
"cheerio": "~1.0.0-rc.12",
|
"cheerio": "~1.0.0-rc.12",
|
||||||
"chroma-js": "~2.4.2",
|
"chroma-js": "~2.4.2",
|
||||||
"command-exists": "~1.2.9",
|
"command-exists": "~1.2.9",
|
||||||
@ -164,7 +163,8 @@
|
|||||||
"vue-toastification": "~2.0.0-rc.5",
|
"vue-toastification": "~2.0.0-rc.5",
|
||||||
"vuedraggable": "~4.1.0",
|
"vuedraggable": "~4.1.0",
|
||||||
"wait-on": "^7.2.0",
|
"wait-on": "^7.2.0",
|
||||||
"whatwg-url": "~12.0.1"
|
"whatwg-url": "~12.0.1",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.4.0"
|
"node": ">= 20.4.0"
|
||||||
@ -5833,40 +5833,6 @@
|
|||||||
"testcontainers": "^11.11.0"
|
"testcontainers": "^11.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testcontainers/mysql/node_modules/testcontainers": {
|
|
||||||
"version": "11.11.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.11.0.tgz",
|
|
||||||
"integrity": "sha512-nKTJn3n/gkyGg/3SVkOwX+isPOGSHlfI+CWMobSmvQrsj7YW01aWvl2pYIfV4LMd+C8or783yYrzKSK2JlP+Qw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@balena/dockerignore": "^1.0.2",
|
|
||||||
"@types/dockerode": "^3.3.47",
|
|
||||||
"archiver": "^7.0.1",
|
|
||||||
"async-lock": "^1.4.1",
|
|
||||||
"byline": "^5.0.0",
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"docker-compose": "^1.3.0",
|
|
||||||
"dockerode": "^4.0.9",
|
|
||||||
"get-port": "^7.1.0",
|
|
||||||
"proper-lockfile": "^4.1.2",
|
|
||||||
"properties-reader": "^2.3.0",
|
|
||||||
"ssh-remote-port-forward": "^1.0.4",
|
|
||||||
"tar-fs": "^3.1.1",
|
|
||||||
"tmp": "^0.2.5",
|
|
||||||
"undici": "^7.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testcontainers/mysql/node_modules/undici": {
|
|
||||||
"version": "7.18.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
|
|
||||||
"integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.18.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testcontainers/postgresql": {
|
"node_modules/@testcontainers/postgresql": {
|
||||||
"version": "11.11.0",
|
"version": "11.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.11.0.tgz",
|
||||||
@ -5877,40 +5843,6 @@
|
|||||||
"testcontainers": "^11.11.0"
|
"testcontainers": "^11.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testcontainers/postgresql/node_modules/testcontainers": {
|
|
||||||
"version": "11.11.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.11.0.tgz",
|
|
||||||
"integrity": "sha512-nKTJn3n/gkyGg/3SVkOwX+isPOGSHlfI+CWMobSmvQrsj7YW01aWvl2pYIfV4LMd+C8or783yYrzKSK2JlP+Qw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@balena/dockerignore": "^1.0.2",
|
|
||||||
"@types/dockerode": "^3.3.47",
|
|
||||||
"archiver": "^7.0.1",
|
|
||||||
"async-lock": "^1.4.1",
|
|
||||||
"byline": "^5.0.0",
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"docker-compose": "^1.3.0",
|
|
||||||
"dockerode": "^4.0.9",
|
|
||||||
"get-port": "^7.1.0",
|
|
||||||
"proper-lockfile": "^4.1.2",
|
|
||||||
"properties-reader": "^2.3.0",
|
|
||||||
"ssh-remote-port-forward": "^1.0.4",
|
|
||||||
"tar-fs": "^3.1.1",
|
|
||||||
"tmp": "^0.2.5",
|
|
||||||
"undici": "^7.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testcontainers/postgresql/node_modules/undici": {
|
|
||||||
"version": "7.18.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
|
|
||||||
"integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.18.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@testcontainers/rabbitmq": {
|
"node_modules/@testcontainers/rabbitmq": {
|
||||||
"version": "10.28.0",
|
"version": "10.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testcontainers/rabbitmq/-/rabbitmq-10.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testcontainers/rabbitmq/-/rabbitmq-10.28.0.tgz",
|
||||||
@ -8326,12 +8258,6 @@
|
|||||||
"dayjs": "^1.9.7"
|
"dayjs": "^1.9.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/check-password-strength": {
|
|
||||||
"version": "2.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/check-password-strength/-/check-password-strength-2.0.10.tgz",
|
|
||||||
"integrity": "sha512-HRM5ICPmtnNtLnTv2QrfVkq1IxI9z3bzYpDJ1k5ixwD9HtJGHuv265R6JmHOV6r8wLhQMlULnIUVpkrC2yaiCw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cheerio": {
|
"node_modules/cheerio": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz",
|
||||||
@ -18365,27 +18291,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/testcontainers": {
|
"node_modules/testcontainers": {
|
||||||
"version": "11.5.0",
|
"version": "11.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.11.0.tgz",
|
||||||
"integrity": "sha512-RPhuRqJ7OZR5e/uw9UEGbxuKjHGXruLlorRRcJvx429xzVapYammBNxmO2PNUW4M5lM/l6NryOY/AVECXaunSw==",
|
"integrity": "sha512-nKTJn3n/gkyGg/3SVkOwX+isPOGSHlfI+CWMobSmvQrsj7YW01aWvl2pYIfV4LMd+C8or783yYrzKSK2JlP+Qw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@balena/dockerignore": "^1.0.2",
|
"@balena/dockerignore": "^1.0.2",
|
||||||
"@types/dockerode": "^3.3.42",
|
"@types/dockerode": "^3.3.47",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"async-lock": "^1.4.1",
|
"async-lock": "^1.4.1",
|
||||||
"byline": "^5.0.0",
|
"byline": "^5.0.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.3",
|
||||||
"docker-compose": "^1.2.0",
|
"docker-compose": "^1.3.0",
|
||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.9",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.1.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"properties-reader": "^2.3.0",
|
"properties-reader": "^2.3.0",
|
||||||
"ssh-remote-port-forward": "^1.0.4",
|
"ssh-remote-port-forward": "^1.0.4",
|
||||||
"tar-fs": "^3.1.0",
|
"tar-fs": "^3.1.1",
|
||||||
"tmp": "^0.2.3",
|
"tmp": "^0.2.5",
|
||||||
"undici": "^7.12.0"
|
"undici": "^7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/testcontainers/node_modules/undici": {
|
"node_modules/testcontainers/node_modules/undici": {
|
||||||
@ -20409,6 +20335,13 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zxcvbn": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -80,7 +80,6 @@
|
|||||||
"badge-maker": "~3.3.1",
|
"badge-maker": "~3.3.1",
|
||||||
"bcryptjs": "~2.4.3",
|
"bcryptjs": "~2.4.3",
|
||||||
"chardet": "~1.4.0",
|
"chardet": "~1.4.0",
|
||||||
"check-password-strength": "^2.0.5",
|
|
||||||
"cheerio": "~1.0.0-rc.12",
|
"cheerio": "~1.0.0-rc.12",
|
||||||
"chroma-js": "~2.4.2",
|
"chroma-js": "~2.4.2",
|
||||||
"command-exists": "~1.2.9",
|
"command-exists": "~1.2.9",
|
||||||
@ -226,6 +225,7 @@
|
|||||||
"vue-toastification": "~2.0.0-rc.5",
|
"vue-toastification": "~2.0.0-rc.5",
|
||||||
"vuedraggable": "~4.1.0",
|
"vuedraggable": "~4.1.0",
|
||||||
"wait-on": "^7.2.0",
|
"wait-on": "^7.2.0",
|
||||||
"whatwg-url": "~12.0.1"
|
"whatwg-url": "~12.0.1",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
96
server/password-util.js
Normal file
96
server/password-util.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Password validation utility following NIST SP 800-63B guidelines
|
||||||
|
* @module password-util
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const axios = require("axios");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum password length as per NIST recommendations
|
||||||
|
*/
|
||||||
|
const MIN_PASSWORD_LENGTH = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if password appears in Have I Been Pwned database using k-anonymity
|
||||||
|
* @param {string} password - The password to check
|
||||||
|
* @returns {Promise<{ breached: boolean, count: number }>} Whether password is breached and count
|
||||||
|
*/
|
||||||
|
async function checkPasswordBreached(password) {
|
||||||
|
try {
|
||||||
|
// Generate SHA-1 hash of password
|
||||||
|
const hash = crypto.createHash("sha1").update(password).digest("hex").toUpperCase();
|
||||||
|
const prefix = hash.substring(0, 5);
|
||||||
|
const suffix = hash.substring(5);
|
||||||
|
|
||||||
|
// Query HIBP API with first 5 characters (k-anonymity)
|
||||||
|
const response = await axios.get(`https://api.pwnedpasswords.com/range/${prefix}`, {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if our hash suffix appears in the response
|
||||||
|
const hashes = response.data.split("\r\n");
|
||||||
|
for (const line of hashes) {
|
||||||
|
const [hashSuffix, count] = line.split(":");
|
||||||
|
if (hashSuffix === suffix) {
|
||||||
|
return { breached: true, count: parseInt(count, 10) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { breached: false, count: 0 };
|
||||||
|
} catch (error) {
|
||||||
|
// If HIBP is unavailable, don't block the password
|
||||||
|
console.warn("Failed to check password against HIBP:", error.message);
|
||||||
|
return { breached: false, count: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a password according to NIST SP 800-63B guidelines.
|
||||||
|
*
|
||||||
|
* NIST guidelines state:
|
||||||
|
* - Passwords should have a minimum length (8-12 characters recommended)
|
||||||
|
* - Composition rules (requiring specific character types) SHALL NOT be imposed
|
||||||
|
* - All printable ASCII characters and Unicode characters should be allowed
|
||||||
|
*
|
||||||
|
* This implementation enforces only minimum length, allowing all character compositions.
|
||||||
|
* @param {string} password - The password to validate
|
||||||
|
* @param {boolean} checkBreached - Whether to check against breach database (optional, default: false)
|
||||||
|
* @returns {Promise<{ ok: boolean, msg?: string, warning?: string }>} Validation result
|
||||||
|
*/
|
||||||
|
async function validatePassword(password, checkBreached = false) {
|
||||||
|
if (!password) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
msg: "Password cannot be empty"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
msg: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Check against breach database (non-blocking warning)
|
||||||
|
if (checkBreached) {
|
||||||
|
const breachResult = await checkPasswordBreached(password);
|
||||||
|
if (breachResult.breached) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
warning: {msg: "passwordFoundInDataBreach", meta: breachResult.count}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validatePassword,
|
||||||
|
checkPasswordBreached,
|
||||||
|
MIN_PASSWORD_LENGTH
|
||||||
|
};
|
||||||
@ -83,7 +83,7 @@ log.debug("server", "Importing http-graceful-shutdown");
|
|||||||
const gracefulShutdown = require("http-graceful-shutdown");
|
const gracefulShutdown = require("http-graceful-shutdown");
|
||||||
log.debug("server", "Importing prometheus-api-metrics");
|
log.debug("server", "Importing prometheus-api-metrics");
|
||||||
const prometheusAPIMetrics = require("prometheus-api-metrics");
|
const prometheusAPIMetrics = require("prometheus-api-metrics");
|
||||||
const { passwordStrength } = require("check-password-strength");
|
const { validatePassword } = require("./password-util");
|
||||||
const TranslatableError = require("./translatable-error");
|
const TranslatableError = require("./translatable-error");
|
||||||
|
|
||||||
log.debug("server", "Importing 2FA Modules");
|
log.debug("server", "Importing 2FA Modules");
|
||||||
@ -683,8 +683,9 @@ let needSetup = false;
|
|||||||
|
|
||||||
socket.on("setup", async (username, password, callback) => {
|
socket.on("setup", async (username, password, callback) => {
|
||||||
try {
|
try {
|
||||||
if (passwordStrength(password).value === "Too weak") {
|
const passwordValidation = await validatePassword(password, true);
|
||||||
throw new TranslatableError("passwordTooWeak");
|
if (!passwordValidation.ok) {
|
||||||
|
throw new TranslatableError("passwordTooShort");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((await R.knex("user").count("id as count").first()).count !== 0) {
|
if ((await R.knex("user").count("id as count").first()).count !== 0) {
|
||||||
@ -704,6 +705,7 @@ let needSetup = false;
|
|||||||
ok: true,
|
ok: true,
|
||||||
msg: "successAdded",
|
msg: "successAdded",
|
||||||
msgi18n: true,
|
msgi18n: true,
|
||||||
|
warning: passwordValidation.warning,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
callback({
|
callback({
|
||||||
@ -1416,8 +1418,9 @@ let needSetup = false;
|
|||||||
throw new Error("Invalid new password");
|
throw new Error("Invalid new password");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordStrength(password.newPassword).value === "Too weak") {
|
const passwordValidation = await validatePassword(password.newPassword, true);
|
||||||
throw new TranslatableError("passwordTooWeak");
|
if (!passwordValidation.ok) {
|
||||||
|
throw new TranslatableError("passwordTooShort");
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = await doubleCheckPassword(socket, password.currentPassword);
|
let user = await doubleCheckPassword(socket, password.currentPassword);
|
||||||
@ -1430,6 +1433,7 @@ let needSetup = false;
|
|||||||
token: User.createJWT(user, server.jwtSecret),
|
token: User.createJWT(user, server.jwtSecret),
|
||||||
msg: "successAuthChangePassword",
|
msg: "successAuthChangePassword",
|
||||||
msgi18n: true,
|
msgi18n: true,
|
||||||
|
warning: passwordValidation.warning,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
callback({
|
callback({
|
||||||
|
|||||||
113
src/components/PasswordStrengthMeter.vue
Normal file
113
src/components/PasswordStrengthMeter.vue
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="password && strength !== null" class="password-strength mt-2">
|
||||||
|
<div
|
||||||
|
class="strength-meter mx-auto"
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="strength"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="4"
|
||||||
|
:aria-label="strengthLabel"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="strength-meter-fill"
|
||||||
|
:class="strengthClass"
|
||||||
|
:style="{ width: strengthWidth }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small v-if="strength < 3" class="text-warning d-block mt-1">
|
||||||
|
{{ $t("passwordWeakWarning") }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import zxcvbn from "zxcvbn";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
strength() {
|
||||||
|
if (!this.password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInputs = this.username ? [ this.username ] : [];
|
||||||
|
const result = zxcvbn(this.password, userInputs);
|
||||||
|
return result.score;
|
||||||
|
},
|
||||||
|
strengthClass() {
|
||||||
|
if (this.strength === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const classes = [ "strength-very-weak", "strength-weak", "strength-fair", "strength-good", "strength-strong" ];
|
||||||
|
return classes[this.strength] || "";
|
||||||
|
},
|
||||||
|
strengthWidth() {
|
||||||
|
if (this.strength === null) {
|
||||||
|
return "0%";
|
||||||
|
}
|
||||||
|
return `${(this.strength + 1) * 20}%`;
|
||||||
|
},
|
||||||
|
strengthLabel() {
|
||||||
|
if (this.strength === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const labels = [ "Very weak", "Weak", "Fair", "Good", "Strong" ];
|
||||||
|
return `Password strength: ${labels[this.strength] || "unknown"}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../assets/vars.scss";
|
||||||
|
|
||||||
|
.password-strength {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strength-meter {
|
||||||
|
height: 8px;
|
||||||
|
width: 85%;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strength-meter-fill {
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease, background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color transitions using SCSS variables
|
||||||
|
.strength-very-weak {
|
||||||
|
background-color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strength-weak {
|
||||||
|
background-color: mix($danger, $warning, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strength-fair {
|
||||||
|
background-color: $warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strength-good {
|
||||||
|
background-color: mix($warning, $primary, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strength-strong {
|
||||||
|
background-color: $primary;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -42,6 +42,12 @@
|
|||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Password strength indicator -->
|
||||||
|
<PasswordStrengthMeter
|
||||||
|
:password="password.newPassword"
|
||||||
|
:username="$root.username"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -146,11 +152,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import Confirm from "../../components/Confirm.vue";
|
import Confirm from "../../components/Confirm.vue";
|
||||||
import TwoFADialog from "../../components/TwoFADialog.vue";
|
import TwoFADialog from "../../components/TwoFADialog.vue";
|
||||||
|
import PasswordStrengthMeter from "../../components/PasswordStrengthMeter.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Confirm,
|
Confirm,
|
||||||
TwoFADialog,
|
TwoFADialog,
|
||||||
|
PasswordStrengthMeter,
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
@ -193,6 +201,12 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.$root.getSocket().emit("changePassword", this.password, (res) => {
|
this.$root.getSocket().emit("changePassword", this.password, (res) => {
|
||||||
this.$root.toastRes(res);
|
this.$root.toastRes(res);
|
||||||
|
|
||||||
|
// Show warning toast if password was found in breach database
|
||||||
|
if (res.ok && res.warning) {
|
||||||
|
this.$root.toastWarning(res.warning);
|
||||||
|
}
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
this.password.currentPassword = "";
|
this.password.currentPassword = "";
|
||||||
this.password.newPassword = "";
|
this.password.newPassword = "";
|
||||||
|
|||||||
@ -1363,7 +1363,9 @@
|
|||||||
"Show this Maintenance Message on which Status Pages": "Show this Maintenance Message on which Status Pages",
|
"Show this Maintenance Message on which Status Pages": "Show this Maintenance Message on which Status Pages",
|
||||||
"Endpoint": "Endpoint",
|
"Endpoint": "Endpoint",
|
||||||
"Details": "Details",
|
"Details": "Details",
|
||||||
"passwordTooWeak": "Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.",
|
"passwordTooShort": "Password must be at least 12 characters long.",
|
||||||
|
"passwordWeakWarning": "This password may be easy to guess. Consider using a longer or more unique password.",
|
||||||
|
"passwordFoundInDataBreach": "The chosen password has appeared in a known data breach (via Have I Been Pwned). Please consider changing to a different password. | The chosen password has appeared {n} times in known data breaches (via Have I Been Pwned). Please consider changing to a different password.",
|
||||||
"TLS Alerts": "TLS Alerts",
|
"TLS Alerts": "TLS Alerts",
|
||||||
"Expected TLS Alert": "Expected TLS Alert",
|
"Expected TLS Alert": "Expected TLS Alert",
|
||||||
"None (Successful Connection)": "None (Successful Connection)",
|
"None (Successful Connection)": "None (Successful Connection)",
|
||||||
|
|||||||
@ -395,6 +395,16 @@ export default {
|
|||||||
toast.error(this.$t(msg));
|
toast.error(this.$t(msg));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a warning toast
|
||||||
|
* @param {string} msg Message to show
|
||||||
|
* @param {...any} args Additional parameters for translation interpolation
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
toastWarning(msg, ...args) {
|
||||||
|
toast.warning(this.$t(msg, ...args));
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback for login
|
* Callback for login
|
||||||
* @callback loginCB
|
* @callback loginCB
|
||||||
|
|||||||
@ -46,6 +46,9 @@
|
|||||||
<label for="floatingPassword">{{ $t("Password") }}</label>
|
<label for="floatingPassword">{{ $t("Password") }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Password strength indicator -->
|
||||||
|
<PasswordStrengthMeter :password="password" :username="username" />
|
||||||
|
|
||||||
<div class="form-floating mt-3">
|
<div class="form-floating mt-3">
|
||||||
<input
|
<input
|
||||||
id="repeat"
|
id="repeat"
|
||||||
@ -73,7 +76,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import PasswordStrengthMeter from "../components/PasswordStrengthMeter.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
PasswordStrengthMeter,
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
@ -110,6 +118,11 @@ export default {
|
|||||||
this.processing = false;
|
this.processing = false;
|
||||||
this.$root.toastRes(res);
|
this.$root.toastRes(res);
|
||||||
|
|
||||||
|
// Show warning toast if password was found in breach database
|
||||||
|
if (res.ok && res.warning) {
|
||||||
|
this.$root.toastWarning(res.warning.msg, res.warning.meta);
|
||||||
|
}
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
this.processing = true;
|
this.processing = true;
|
||||||
|
|
||||||
|
|||||||
132
test/backend-test/test-password-util.js
Normal file
132
test/backend-test/test-password-util.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
const { describe, test } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
|
||||||
|
const { validatePassword, checkPasswordBreached, MIN_PASSWORD_LENGTH } = require("../../server/password-util");
|
||||||
|
|
||||||
|
describe("Password Validation (NIST-aligned)", () => {
|
||||||
|
test("should reject empty password", async () => {
|
||||||
|
const result = await validatePassword("");
|
||||||
|
assert.strictEqual(result.ok, false);
|
||||||
|
assert.match(result.msg, /cannot be empty/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject null password", async () => {
|
||||||
|
const result = await validatePassword(null);
|
||||||
|
assert.strictEqual(result.ok, false);
|
||||||
|
assert.match(result.msg, /cannot be empty/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject undefined password", async () => {
|
||||||
|
const result = await validatePassword(undefined);
|
||||||
|
assert.strictEqual(result.ok, false);
|
||||||
|
assert.match(result.msg, /cannot be empty/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject password shorter than minimum length", async () => {
|
||||||
|
const result = await validatePassword("short");
|
||||||
|
assert.strictEqual(result.ok, false);
|
||||||
|
assert.match(result.msg, /at least \d+ characters/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept password exactly at minimum length", async () => {
|
||||||
|
const password = "a".repeat(MIN_PASSWORD_LENGTH);
|
||||||
|
const result = await validatePassword(password);
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept long password with only lowercase letters", async () => {
|
||||||
|
const result = await validatePassword("thisisaverylongpassword");
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept passphrase without numbers or special characters", async () => {
|
||||||
|
const result = await validatePassword("CorrectHorseBatteryStaple");
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept password with mixed case but no numbers", async () => {
|
||||||
|
const result = await validatePassword("MySecretPassword");
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept password with numbers and special characters", async () => {
|
||||||
|
const result = await validatePassword("MyP@ssw0rd123");
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept password with only numbers", async () => {
|
||||||
|
const result = await validatePassword("123456789012");
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept password with only special characters", async () => {
|
||||||
|
const result = await validatePassword("!@#$%^&*(){}");
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept password with spaces", async () => {
|
||||||
|
const result = await validatePassword("my secure password here");
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept very long password", async () => {
|
||||||
|
const result = await validatePassword("a".repeat(100));
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("minimum password length should be 12 characters", () => {
|
||||||
|
assert.strictEqual(MIN_PASSWORD_LENGTH, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept Unicode characters", async () => {
|
||||||
|
const result = await validatePassword("パスワード123456789");
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept emojis in password", async () => {
|
||||||
|
const result = await validatePassword("password🔒🔑123");
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.msg, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should warn about breached password when checking enabled", async () => {
|
||||||
|
// "password" is a well-known breached password
|
||||||
|
const result = await validatePassword("password123456", true);
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.ok(result.warning, "Should have a warning for breached password");
|
||||||
|
assert.strictEqual(result.warning.msg, "passwordFoundInDataBreach");
|
||||||
|
assert.ok(result.warning.meta > 0, "Should have breach count greater than 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accept non-breached password with no warning", async () => {
|
||||||
|
// Very unlikely to be breached (random strong password)
|
||||||
|
const result = await validatePassword("Xy9#mK2$pQ7!vN8&", true);
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.strictEqual(result.warning, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HIBP Breach Checking", () => {
|
||||||
|
test("checkPasswordBreached should detect common password", async () => {
|
||||||
|
const result = await checkPasswordBreached("password");
|
||||||
|
assert.strictEqual(result.breached, true);
|
||||||
|
assert.ok(result.count > 1000000, "Password should have been breached many times");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkPasswordBreached should not detect strong unique password", async () => {
|
||||||
|
// Very unlikely to be in breach database
|
||||||
|
const result = await checkPasswordBreached("Xy9#mK2$pQ7!vN8&zR4@");
|
||||||
|
assert.strictEqual(result.breached, false);
|
||||||
|
assert.strictEqual(result.count, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
test/e2e/specs/password-strength.spec.js
Normal file
101
test/e2e/specs/password-strength.spec.js
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { getSqliteDatabaseExists } from "../util-test";
|
||||||
|
|
||||||
|
test.describe("Password Strength Indicator", () => {
|
||||||
|
test.skip(() => !getSqliteDatabaseExists(), "Database must exist before running this test");
|
||||||
|
|
||||||
|
test("should show password strength indicator on setup page", async ({ page }) => {
|
||||||
|
// Navigate to setup page (requires database to exist but not be configured)
|
||||||
|
await page.goto("./setup");
|
||||||
|
|
||||||
|
// Fill username
|
||||||
|
await page.getByPlaceholder("Username").fill("testuser");
|
||||||
|
|
||||||
|
// Initially no strength indicator should be visible
|
||||||
|
const strengthMeter = page.locator(".password-strength");
|
||||||
|
await expect(strengthMeter).not.toBeVisible();
|
||||||
|
|
||||||
|
// Type a weak password
|
||||||
|
const passwordInput = page.getByPlaceholder("Password", { exact: true });
|
||||||
|
await passwordInput.fill("weak");
|
||||||
|
|
||||||
|
// Strength meter should appear
|
||||||
|
await expect(strengthMeter).toBeVisible();
|
||||||
|
|
||||||
|
// Type a stronger password
|
||||||
|
await passwordInput.fill("MyStrongPassword123!");
|
||||||
|
|
||||||
|
// Strength meter should still be visible
|
||||||
|
await expect(strengthMeter).toBeVisible();
|
||||||
|
|
||||||
|
// Check that the strength meter fill has the appropriate class
|
||||||
|
const strengthFill = page.locator(".strength-meter-fill");
|
||||||
|
await expect(strengthFill).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show warning for weak password", async ({ page }) => {
|
||||||
|
await page.goto("./setup");
|
||||||
|
|
||||||
|
await page.getByPlaceholder("Username").fill("admin");
|
||||||
|
|
||||||
|
// Fill with a password that has low zxcvbn score
|
||||||
|
await page.getByPlaceholder("Password", { exact: true }).fill("password1234");
|
||||||
|
|
||||||
|
// Check that warning message appears
|
||||||
|
const warningText = page.locator("text=This password may be easy to guess");
|
||||||
|
await expect(warningText).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not show warning for strong password", async ({ page }) => {
|
||||||
|
await page.goto("./setup");
|
||||||
|
|
||||||
|
await page.getByPlaceholder("Username").fill("admin");
|
||||||
|
|
||||||
|
// Fill with a strong password
|
||||||
|
await page.getByPlaceholder("Password", { exact: true }).fill("Xy9#mK2$pQ7!vN8&zR4@");
|
||||||
|
|
||||||
|
// Check that warning message does not appear
|
||||||
|
const warningText = page.locator("text=This password may be easy to guess");
|
||||||
|
await expect(warningText).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show breach warning toast for compromised password", async ({ page }) => {
|
||||||
|
await page.goto("./setup");
|
||||||
|
|
||||||
|
await page.getByPlaceholder("Username").fill("admin");
|
||||||
|
|
||||||
|
// Use a well-known breached password that's long enough (12+ chars)
|
||||||
|
const breachedPassword = "password1234567890";
|
||||||
|
await page.getByPlaceholder("Password", { exact: true }).fill(breachedPassword);
|
||||||
|
await page.getByPlaceholder("Repeat Password").fill(breachedPassword);
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
await page.getByRole("button", { name: "Create" }).click();
|
||||||
|
|
||||||
|
// Wait for and check toast warning appears
|
||||||
|
// Toast messages typically appear in the toast container
|
||||||
|
const toastWarning = page.locator(".toast-warning, .toast.warning, [role='alert']").filter({ hasText: /breach|data breach/i });
|
||||||
|
await expect(toastWarning).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update strength indicator as password changes", async ({ page }) => {
|
||||||
|
await page.goto("./setup");
|
||||||
|
|
||||||
|
await page.getByPlaceholder("Username").fill("testuser");
|
||||||
|
const passwordInput = page.getByPlaceholder("Password", { exact: true });
|
||||||
|
|
||||||
|
// Start with weak password
|
||||||
|
await passwordInput.fill("abc123456789");
|
||||||
|
const strengthFill = page.locator(".strength-meter-fill");
|
||||||
|
|
||||||
|
// Get initial class
|
||||||
|
const initialClass = await strengthFill.getAttribute("class");
|
||||||
|
|
||||||
|
// Change to stronger password
|
||||||
|
await passwordInput.fill("MyVeryStrongP@ssw0rd!");
|
||||||
|
|
||||||
|
// Class should change (indicating different strength)
|
||||||
|
const newClass = await strengthFill.getAttribute("class");
|
||||||
|
expect(initialClass).not.toBe(newClass);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user