Compare commits

...

33 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
692be2b476 Fix backend tests and linting errors
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 19:02:00 +00:00
Frank Elsinga
87df009d66
Apply suggestion from @CommanderStorm 2026-01-18 19:51:07 +01:00
Frank Elsinga
049603f71b
Apply suggestion from @CommanderStorm 2026-01-18 19:45:50 +01:00
copilot-swe-agent[bot]
392c182735 Update package-lock.json
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 17:36:30 +00:00
Frank Elsinga
95b97f8c95
Merge branch 'master' into copilot/remove-password-complexity-requirements 2026-01-18 18:32:08 +01:00
Frank Elsinga
eb01bba963
Apply suggestion from @CommanderStorm 2026-01-18 18:30:19 +01:00
Frank Elsinga
991aa1c037
Apply suggestion from @CommanderStorm 2026-01-18 18:29:47 +01:00
Frank Elsinga
e8b4907dfb
Apply suggestions from code review 2026-01-18 17:46:53 +01:00
Frank Elsinga
28b40df577
Apply suggestion from @CommanderStorm 2026-01-18 17:36:57 +01:00
Frank Elsinga
b11a1d7765
Apply suggestion from @CommanderStorm 2026-01-18 17:35:53 +01:00
copilot-swe-agent[bot]
b621a452a2 Add toastWarning method, fix translation key, move zxcvbn to dependencies, and add accessibility attributes
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 15:54:48 +00:00
Frank Elsinga
36d0bbdfea
Update test/e2e/specs/password-strength.spec.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-18 16:50:15 +01:00
Frank Elsinga
dec5146c58
Update test/e2e/specs/password-strength.spec.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-18 16:49:59 +01:00
Frank Elsinga
07c2f75be5
Update extra/reset-password.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-18 15:59:45 +01:00
Frank Elsinga
3b34a3acf4
Update src/lang/en.json 2026-01-18 15:52:14 +01:00
Frank Elsinga
984f268af4
Update server/server.js 2026-01-18 15:52:03 +01:00
Frank Elsinga
a5dd2ad75a
Update src/components/settings/Security.vue 2026-01-18 15:51:53 +01:00
Frank Elsinga
d0bdcc575a
Update server/server.js 2026-01-18 15:51:43 +01:00
copilot-swe-agent[bot]
e5f629041f Move password strength calculation into PasswordStrengthMeter component
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 14:22:28 +00:00
copilot-swe-agent[bot]
3a872f78d0 Extract password strength meter into reusable component with SCSS color variables
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 13:58:44 +00:00
Frank Elsinga
854c54925a
Apply suggestion from @CommanderStorm 2026-01-18 14:37:19 +01:00
copilot-swe-agent[bot]
20f86cdbdd Update package-lock.json for zxcvbn dependency
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 13:24:39 +00:00
Frank Elsinga
6b505447cc
Apply suggestion from @CommanderStorm 2026-01-18 14:10:21 +01:00
Frank Elsinga
230b85edb7
Apply suggestion from @CommanderStorm 2026-01-18 14:09:27 +01:00
Frank Elsinga
87bf2247b3
Apply suggestion from @CommanderStorm 2026-01-18 14:08:32 +01:00
copilot-swe-agent[bot]
3b1e9c01c7 Add breach warning toasts and Playwright tests for password strength indicator
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 11:57:20 +00:00
copilot-swe-agent[bot]
a308c802af Add HIBP breach checking and zxcvbn password strength indicator
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 11:48:27 +00:00
Frank Elsinga
1a5379baec
Apply suggestion from @CommanderStorm 2026-01-18 12:33:53 +01:00
copilot-swe-agent[bot]
9c31da1756 Add comprehensive tests for NIST-aligned password validation
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 11:32:13 +00:00
Frank Elsinga
125fb1ccf4
Apply suggestion from @CommanderStorm 2026-01-18 12:32:07 +01:00
Frank Elsinga
0a85868c1e
Apply suggestion from @CommanderStorm 2026-01-18 12:31:45 +01:00
copilot-swe-agent[bot]
5cd781bd51 Replace check-password-strength with NIST-aligned password validation
Co-authored-by: CommanderStorm <26258709+CommanderStorm@users.noreply.github.com>
2026-01-18 11:28:04 +00:00
copilot-swe-agent[bot]
1b886f8573 Initial plan 2026-01-18 11:23:08 +00:00
13 changed files with 528 additions and 100 deletions

View File

@ -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
} }
} }

View File

@ -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
View File

@ -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"
} }
} }
} }

View File

@ -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
View 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
};

View File

@ -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({

View 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>

View File

@ -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 = "";

View File

@ -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)",

View File

@ -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

View File

@ -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;

View 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);
});
});

View 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);
});
});