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,
|
||||
"color-hex-length": 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 { R } = require("redbean-node");
|
||||
const readline = require("readline");
|
||||
const { passwordStrength } = require("check-password-strength");
|
||||
const { validatePassword } = require("../server/password-util");
|
||||
const { initJWTSecret } = require("../server/util-server");
|
||||
const User = require("../server/model/user");
|
||||
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"
|
||||
);
|
||||
password = confirmPassword = args["new-password"] + "";
|
||||
if (passwordStrength(password).value === "Too weak") {
|
||||
throw new Error("Password is too weak, please use a stronger password.");
|
||||
const passwordValidation = await validatePassword(password, true);
|
||||
if (!passwordValidation.ok) {
|
||||
throw new Error(passwordValidation.msg);
|
||||
}
|
||||
if (passwordValidation.warning) {
|
||||
console.warn("\x1b[33m%s\x1b[0m",
|
||||
"Warning: " + passwordValidation.warning);
|
||||
}
|
||||
} else {
|
||||
password = await question("New Password: ");
|
||||
if (passwordStrength(password).value === "Too weak") {
|
||||
console.log("Password is too weak, please try again.");
|
||||
const passwordValidation = await validatePassword(password, true);
|
||||
if (!passwordValidation.ok) {
|
||||
console.log(passwordValidation.msg);
|
||||
continue;
|
||||
}
|
||||
if (passwordValidation.warning) {
|
||||
console.warn("\x1b[33m%s\x1b[0m", "Warning: " + passwordValidation.warning);
|
||||
}
|
||||
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",
|
||||
"bcryptjs": "~2.4.3",
|
||||
"chardet": "~1.4.0",
|
||||
"check-password-strength": "^2.0.5",
|
||||
"cheerio": "~1.0.0-rc.12",
|
||||
"chroma-js": "~2.4.2",
|
||||
"command-exists": "~1.2.9",
|
||||
@ -164,7 +163,8 @@
|
||||
"vue-toastification": "~2.0.0-rc.5",
|
||||
"vuedraggable": "~4.1.0",
|
||||
"wait-on": "^7.2.0",
|
||||
"whatwg-url": "~12.0.1"
|
||||
"whatwg-url": "~12.0.1",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.4.0"
|
||||
@ -5833,40 +5833,6 @@
|
||||
"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": {
|
||||
"version": "11.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.11.0.tgz",
|
||||
@ -5877,40 +5843,6 @@
|
||||
"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": {
|
||||
"version": "10.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@testcontainers/rabbitmq/-/rabbitmq-10.28.0.tgz",
|
||||
@ -8326,12 +8258,6 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz",
|
||||
@ -18365,27 +18291,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/testcontainers": {
|
||||
"version": "11.5.0",
|
||||
"resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.5.0.tgz",
|
||||
"integrity": "sha512-RPhuRqJ7OZR5e/uw9UEGbxuKjHGXruLlorRRcJvx429xzVapYammBNxmO2PNUW4M5lM/l6NryOY/AVECXaunSw==",
|
||||
"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.42",
|
||||
"@types/dockerode": "^3.3.47",
|
||||
"archiver": "^7.0.1",
|
||||
"async-lock": "^1.4.1",
|
||||
"byline": "^5.0.0",
|
||||
"debug": "^4.4.1",
|
||||
"docker-compose": "^1.2.0",
|
||||
"dockerode": "^4.0.7",
|
||||
"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.0",
|
||||
"tmp": "^0.2.3",
|
||||
"undici": "^7.12.0"
|
||||
"tar-fs": "^3.1.1",
|
||||
"tmp": "^0.2.5",
|
||||
"undici": "^7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/testcontainers/node_modules/undici": {
|
||||
@ -20409,6 +20335,13 @@
|
||||
"engines": {
|
||||
"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",
|
||||
"bcryptjs": "~2.4.3",
|
||||
"chardet": "~1.4.0",
|
||||
"check-password-strength": "^2.0.5",
|
||||
"cheerio": "~1.0.0-rc.12",
|
||||
"chroma-js": "~2.4.2",
|
||||
"command-exists": "~1.2.9",
|
||||
@ -226,6 +225,7 @@
|
||||
"vue-toastification": "~2.0.0-rc.5",
|
||||
"vuedraggable": "~4.1.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");
|
||||
log.debug("server", "Importing 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");
|
||||
|
||||
log.debug("server", "Importing 2FA Modules");
|
||||
@ -683,8 +683,9 @@ let needSetup = false;
|
||||
|
||||
socket.on("setup", async (username, password, callback) => {
|
||||
try {
|
||||
if (passwordStrength(password).value === "Too weak") {
|
||||
throw new TranslatableError("passwordTooWeak");
|
||||
const passwordValidation = await validatePassword(password, true);
|
||||
if (!passwordValidation.ok) {
|
||||
throw new TranslatableError("passwordTooShort");
|
||||
}
|
||||
|
||||
if ((await R.knex("user").count("id as count").first()).count !== 0) {
|
||||
@ -704,6 +705,7 @@ let needSetup = false;
|
||||
ok: true,
|
||||
msg: "successAdded",
|
||||
msgi18n: true,
|
||||
warning: passwordValidation.warning,
|
||||
});
|
||||
} catch (e) {
|
||||
callback({
|
||||
@ -1416,8 +1418,9 @@ let needSetup = false;
|
||||
throw new Error("Invalid new password");
|
||||
}
|
||||
|
||||
if (passwordStrength(password.newPassword).value === "Too weak") {
|
||||
throw new TranslatableError("passwordTooWeak");
|
||||
const passwordValidation = await validatePassword(password.newPassword, true);
|
||||
if (!passwordValidation.ok) {
|
||||
throw new TranslatableError("passwordTooShort");
|
||||
}
|
||||
|
||||
let user = await doubleCheckPassword(socket, password.currentPassword);
|
||||
@ -1430,6 +1433,7 @@ let needSetup = false;
|
||||
token: User.createJWT(user, server.jwtSecret),
|
||||
msg: "successAuthChangePassword",
|
||||
msgi18n: true,
|
||||
warning: passwordValidation.warning,
|
||||
});
|
||||
} catch (e) {
|
||||
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"
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Password strength indicator -->
|
||||
<PasswordStrengthMeter
|
||||
:password="password.newPassword"
|
||||
:username="$root.username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@ -146,11 +152,13 @@
|
||||
<script>
|
||||
import Confirm from "../../components/Confirm.vue";
|
||||
import TwoFADialog from "../../components/TwoFADialog.vue";
|
||||
import PasswordStrengthMeter from "../../components/PasswordStrengthMeter.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Confirm,
|
||||
TwoFADialog,
|
||||
PasswordStrengthMeter,
|
||||
},
|
||||
|
||||
data() {
|
||||
@ -193,6 +201,12 @@ export default {
|
||||
} else {
|
||||
this.$root.getSocket().emit("changePassword", this.password, (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) {
|
||||
this.password.currentPassword = "";
|
||||
this.password.newPassword = "";
|
||||
|
||||
@ -1363,7 +1363,9 @@
|
||||
"Show this Maintenance Message on which Status Pages": "Show this Maintenance Message on which Status Pages",
|
||||
"Endpoint": "Endpoint",
|
||||
"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",
|
||||
"Expected TLS Alert": "Expected TLS Alert",
|
||||
"None (Successful Connection)": "None (Successful Connection)",
|
||||
|
||||
@ -395,6 +395,16 @@ export default {
|
||||
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 loginCB
|
||||
|
||||
@ -46,6 +46,9 @@
|
||||
<label for="floatingPassword">{{ $t("Password") }}</label>
|
||||
</div>
|
||||
|
||||
<!-- Password strength indicator -->
|
||||
<PasswordStrengthMeter :password="password" :username="username" />
|
||||
|
||||
<div class="form-floating mt-3">
|
||||
<input
|
||||
id="repeat"
|
||||
@ -73,7 +76,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PasswordStrengthMeter from "../components/PasswordStrengthMeter.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PasswordStrengthMeter,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
@ -110,6 +118,11 @@ export default {
|
||||
this.processing = false;
|
||||
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) {
|
||||
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