better password shit

This commit is contained in:
KingKDot
2026-07-21 13:44:33 -05:00
parent d602de396e
commit ff337bc36b
13 changed files with 360 additions and 17 deletions
@@ -1,9 +1,12 @@
import { createPasswordPolicyChecklist, loadPasswordPolicy } from "./password-policy.js";
const form = document.getElementById("change-password-form"); const form = document.getElementById("change-password-form");
const currentPass = document.getElementById("current-pass"); const currentPass = document.getElementById("current-pass");
const newPass = document.getElementById("new-pass"); const newPass = document.getElementById("new-pass");
const confirmPass = document.getElementById("confirm-pass"); const confirmPass = document.getElementById("confirm-pass");
const errEl = document.getElementById("error"); const errEl = document.getElementById("error");
const successEl = document.getElementById("success"); const successEl = document.getElementById("success");
const policyEl = document.getElementById("password-policy");
const tempToken = sessionStorage.getItem("temp_token"); const tempToken = sessionStorage.getItem("temp_token");
const tempUser = sessionStorage.getItem("temp_user"); const tempUser = sessionStorage.getItem("temp_user");
@@ -14,6 +17,14 @@ if (!tempToken || !tempUser) {
const user = JSON.parse(tempUser); const user = JSON.parse(tempUser);
const passwordChecklist = createPasswordPolicyChecklist({
passwordInput: newPass,
confirmInput: confirmPass,
container: policyEl,
});
loadPasswordPolicy().then((policy) => passwordChecklist.setPolicy(policy));
if (typeof anime !== "undefined") { if (typeof anime !== "undefined") {
const particles = document.querySelectorAll(".particle"); const particles = document.querySelectorAll(".particle");
particles.forEach((particle, i) => { particles.forEach((particle, i) => {
@@ -131,15 +142,12 @@ form?.addEventListener("submit", async (e) => {
return; return;
} }
if (newPass.value.length < 6) { const policyError = passwordChecklist.firstError();
errEl.textContent = "New password must be at least 6 characters"; if (policyError) {
errEl.textContent = policyError;
return; return;
} }
if (currentPass.value === newPass.value) {
errEl.textContent = "New password must be different from current password";
return;
}
try { try {
const res = await fetch(`/api/users/${user.id}/password`, { const res = await fetch(`/api/users/${user.id}/password`, {
+57
View File
@@ -1195,6 +1195,63 @@ article.card[data-bookmarked="true"] {
color: #6b7a98; color: #6b7a98;
} }
.password-policy-card {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: var(--radius-md);
background: rgba(15, 23, 42, 0.48);
}
.password-policy-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: #cbd5e1;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.password-policy-title i {
color: #93c5fd;
}
.password-policy-list {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.password-policy-item {
display: flex;
align-items: center;
gap: 8px;
color: #94a3b8;
font-size: 12px;
line-height: 1.35;
transition: color 160ms ease;
}
.password-policy-item i {
width: 14px;
color: #64748b;
font-size: 11px;
}
.password-policy-item.is-met {
color: #86efac;
}
.password-policy-item.is-met i {
color: #22c55e;
}
.button.primary { .button.primary {
background: linear-gradient(120deg, #4f6bff, #715dff); background: linear-gradient(120deg, #4f6bff, #715dff);
border-color: rgba(129, 140, 248, 0.55); border-color: rgba(129, 140, 248, 0.55);
@@ -0,0 +1,123 @@
const DEFAULT_POLICY = {
minLength: 6,
requireUppercase: false,
requireLowercase: false,
requireNumber: false,
requireSymbol: false,
};
function normalizePolicy(raw) {
const source = raw && typeof raw === "object" ? raw : {};
return {
minLength: Math.min(128, Math.max(6, Number(source.minLength) || DEFAULT_POLICY.minLength)),
requireUppercase: Boolean(source.requireUppercase),
requireLowercase: Boolean(source.requireLowercase),
requireNumber: Boolean(source.requireNumber),
requireSymbol: Boolean(source.requireSymbol),
};
}
export async function loadPasswordPolicy() {
try {
const res = await fetch("/api/registration/status", { credentials: "include" });
if (!res.ok) return DEFAULT_POLICY;
const data = await res.json().catch(() => ({}));
return normalizePolicy(data.passwordPolicy);
} catch {
return DEFAULT_POLICY;
}
}
function buildRules(password, confirmPassword, policy, includeMatch) {
const rules = [
{
id: "minLength",
label: `At least ${policy.minLength} characters`,
met: password.length >= policy.minLength,
},
];
if (policy.requireUppercase) {
rules.push({ id: "uppercase", label: "One uppercase letter", met: /[A-Z]/.test(password) });
}
if (policy.requireLowercase) {
rules.push({ id: "lowercase", label: "One lowercase letter", met: /[a-z]/.test(password) });
}
if (policy.requireNumber) {
rules.push({ id: "number", label: "One number", met: /[0-9]/.test(password) });
}
if (policy.requireSymbol) {
rules.push({ id: "symbol", label: "One symbol", met: /[^A-Za-z0-9]/.test(password) });
}
if (includeMatch) {
rules.push({
id: "match",
label: "Passwords match",
met: password.length > 0 && confirmPassword.length > 0 && password === confirmPassword,
});
}
return rules;
}
function render(container, rules) {
container.innerHTML = `
<div class="password-policy-title">
<i class="fa-solid fa-shield-halved"></i>
<span>Password requirements</span>
</div>
<ul class="password-policy-list">
${rules
.map(
(rule) => `
<li class="password-policy-item ${rule.met ? "is-met" : "is-unmet"}" data-password-rule="${rule.id}">
<i class="fa-solid ${rule.met ? "fa-circle-check" : "fa-circle"}"></i>
<span>${rule.label}</span>
</li>
`,
)
.join("")}
</ul>
`;
}
export function createPasswordPolicyChecklist({ passwordInput, confirmInput, container }) {
let policy = DEFAULT_POLICY;
const includeMatch = Boolean(confirmInput);
function currentRules() {
return buildRules(passwordInput?.value || "", confirmInput?.value || "", policy, includeMatch);
}
function update() {
if (!container) return;
render(container, currentRules());
}
function firstError() {
const failed = currentRules().find((rule) => !rule.met);
return failed ? failed.label : null;
}
function isSatisfied() {
return firstError() === null;
}
function setPolicy(nextPolicy) {
policy = normalizePolicy(nextPolicy);
if (passwordInput) {
passwordInput.minLength = policy.minLength;
passwordInput.placeholder = `At least ${policy.minLength} characters`;
}
if (confirmInput) {
confirmInput.minLength = policy.minLength;
}
update();
}
passwordInput?.addEventListener("input", update);
confirmInput?.addEventListener("input", update);
update();
return { firstError, isSatisfied, setPolicy, update };
}
+19 -4
View File
@@ -1,8 +1,22 @@
import { createPasswordPolicyChecklist, loadPasswordPolicy } from "./password-policy.js";
const form = document.getElementById("register-form"); const form = document.getElementById("register-form");
const errorEl = document.getElementById("error"); const errorEl = document.getElementById("error");
const successEl = document.getElementById("success"); const successEl = document.getElementById("success");
const keyGroup = document.getElementById("key-group"); const keyGroup = document.getElementById("key-group");
const keyInput = document.getElementById("reg-key"); const keyInput = document.getElementById("reg-key");
const passwordInput = document.getElementById("reg-pass");
const confirmPasswordInput = document.getElementById("reg-pass-confirm");
const policyEl = document.getElementById("password-policy");
const passwordChecklist = createPasswordPolicyChecklist({
passwordInput,
confirmInput: confirmPasswordInput,
container: policyEl,
});
loadPasswordPolicy().then((policy) => passwordChecklist.setPolicy(policy));
async function checkStatus() { async function checkStatus() {
try { try {
@@ -87,16 +101,17 @@ form.addEventListener("submit", async (e) => {
successEl.style.display = "none"; successEl.style.display = "none";
const username = document.getElementById("reg-user").value.trim(); const username = document.getElementById("reg-user").value.trim();
const password = document.getElementById("reg-pass").value; const password = passwordInput.value;
const confirmPassword = document.getElementById("reg-pass-confirm").value; const confirmPassword = confirmPasswordInput.value;
const key = keyInput.value.trim(); const key = keyInput.value.trim();
if (password !== confirmPassword) { if (password !== confirmPassword) {
errorEl.textContent = "Passwords do not match."; errorEl.textContent = "Passwords do not match.";
return; return;
} }
if (password.length < 6) { const policyError = passwordChecklist.firstError();
errorEl.textContent = "Password must be at least 6 characters."; if (policyError) {
errorEl.textContent = policyError;
return; return;
} }
+1 -1
View File
@@ -1007,7 +1007,7 @@
<i class="fa-solid fa-file-signature text-blue-400"></i> Startup File Name <i class="fa-solid fa-file-signature text-blue-400"></i> Startup File Name
</div> </div>
<input type="text" id="startup-name" name="startup-name" maxlength="32" <input type="text" id="startup-name" name="startup-name" maxlength="32"
pattern="[A-Za-z0-9_.-]+" pattern="[A-Za-z0-9_.\x2d]+"
placeholder="ovd (default)" placeholder="ovd (default)"
class="w-full px-3 py-2 bg-slate-800/80 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-orange-500 transition-colors placeholder:text-slate-600" /> class="w-full px-3 py-2 bg-slate-800/80 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-orange-500 transition-colors placeholder:text-slate-600" />
<p class="text-xs text-slate-500 mt-1">Custom name for the installed executable. On Windows: used as filename (<code>svchost</code> → <code>svchost.exe</code>). On macOS: also used as the LaunchAgent label — <span id="startup-name-macos-hint" class="hidden text-orange-400 font-medium">must start with <code>com.</code> (e.g. <code>com.apple.updater</code>).</span><span id="startup-name-default-hint">Leave blank for a default randomized name.</span></p> <p class="text-xs text-slate-500 mt-1">Custom name for the installed executable. On Windows: used as filename (<code>svchost</code> → <code>svchost.exe</code>). On macOS: also used as the LaunchAgent label — <span id="startup-name-macos-hint" class="hidden text-orange-400 font-medium">must start with <code>com.</code> (e.g. <code>com.apple.updater</code>).</span><span id="startup-name-default-hint">Leave blank for a default randomized name.</span></p>
@@ -77,6 +77,8 @@
</div> </div>
</div> </div>
<div id="password-policy" class="password-policy-card" aria-live="polite"></div>
<button type="submit" class="button primary full login-btn"> <button type="submit" class="button primary full login-btn">
<span class="btn-text">Update password</span> <span class="btn-text">Update password</span>
<i class="fa-solid fa-arrow-right btn-icon"></i> <i class="fa-solid fa-arrow-right btn-icon"></i>
+3 -2
View File
@@ -41,7 +41,7 @@
required required
minlength="3" minlength="3"
maxlength="32" maxlength="32"
pattern="[a-zA-Z0-9_\-]+" pattern="[a-zA-Z0-9_\x2d]+"
placeholder="Choose a username" placeholder="Choose a username"
/> />
</div> </div>
@@ -75,6 +75,7 @@
/> />
</div> </div>
</div> </div>
<div id="password-policy" class="password-policy-card" aria-live="polite"></div>
<div class="form-group" id="key-group" style="display: none"> <div class="form-group" id="key-group" style="display: none">
<label class="label">Registration Key</label> <label class="label">Registration Key</label>
<div class="input-row input-animated"> <div class="input-row input-animated">
@@ -101,6 +102,6 @@
> >
</div> </div>
</div> </div>
<script src="/assets/register.js"></script> <script type="module" src="/assets/register.js"></script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -149,7 +149,7 @@
id="username" id="username"
name="username" name="username"
required required
pattern="[a-zA-Z0-9_-]+" pattern="[a-zA-Z0-9_\x2d]+"
minlength="3" minlength="3"
maxlength="32" maxlength="32"
class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" class="w-full px-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
@@ -187,6 +187,7 @@
</div> </div>
<p id="generated-password-hint" class="hidden text-xs text-emerald-400 mt-1">Generated password filled. Copy it before saving.</p> <p id="generated-password-hint" class="hidden text-xs text-emerald-400 mt-1">Generated password filled. Copy it before saving.</p>
<p class="text-xs text-slate-500 mt-1">Minimum 6 characters</p> <p class="text-xs text-slate-500 mt-1">Minimum 6 characters</p>
<p class="text-xs text-amber-300/80 mt-1">New users are prompted to confirm or change this password on first login.</p>
</div> </div>
<div> <div>
@@ -61,6 +61,17 @@ setInterval(() => {
}, 60_000); }, 60_000);
function getPublicPasswordPolicy() {
const security = getConfig().security;
return {
minLength: Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6)),
requireUppercase: Boolean(security.passwordRequireUppercase),
requireLowercase: Boolean(security.passwordRequireLowercase),
requireNumber: Boolean(security.passwordRequireNumber),
requireSymbol: Boolean(security.passwordRequireSymbol),
};
}
type RegistrationRouteDeps = { type RegistrationRouteDeps = {
requestIP?: (req: Request) => { address?: string } | null | undefined; requestIP?: (req: Request) => { address?: string } | null | undefined;
}; };
@@ -80,6 +91,7 @@ export async function handleRegistrationRoutes(
return Response.json({ return Response.json({
enabled: mode !== "off", enabled: mode !== "off",
mode: mode === "off" ? "off" : mode, mode: mode === "off" ? "off" : mode,
passwordPolicy: getPublicPasswordPolicy(),
}); });
} }
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, test } from "bun:test";
import { generateToken } from "../../auth";
import { createUser, deleteUser, getUserById } from "../../users";
import { handleUsersRoutes } from "./users-routes";
const PASSWORD = "Aa1!MustChangeRouteTestPass_2026";
const createdUserIds: number[] = [];
const mockServer = {
requestIP: () => ({ address: "127.0.0.1" }),
};
async function createAuthedAdmin() {
const username = `mcp_admin_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
const result = await createUser(username, PASSWORD, "admin", "test");
expect(result.success).toBe(true);
expect(typeof result.userId).toBe("number");
createdUserIds.push(result.userId!);
const user = getUserById(result.userId!);
expect(user).not.toBeNull();
const token = await generateToken(user!);
return { user: user!, token };
}
afterEach(() => {
while (createdUserIds.length > 0) {
const id = createdUserIds.pop();
if (typeof id === "number") deleteUser(id);
}
});
describe("admin-created users must change password", () => {
test("POST /api/users marks new users for password change", async () => {
const admin = await createAuthedAdmin();
const username = `mcp_user_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
const url = new URL("https://localhost/api/users");
const res = await handleUsersRoutes(
new Request(url, {
method: "POST",
headers: {
Authorization: `Bearer ${admin.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password: PASSWORD, role: "viewer" }),
}),
url,
mockServer,
);
expect(res).not.toBeNull();
expect(res!.status).toBe(200);
const body = await res!.json();
expect(typeof body.userId).toBe("number");
createdUserIds.push(body.userId);
const created = getUserById(body.userId);
expect(created?.must_change_password).toBe(1);
});
test("self password update may reuse the initial password and clears the prompt", async () => {
const admin = await createAuthedAdmin();
const username = `mcp_same_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
const createUrl = new URL("https://localhost/api/users");
const createRes = await handleUsersRoutes(
new Request(createUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${admin.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password: PASSWORD, role: "operator" }),
}),
createUrl,
mockServer,
);
expect(createRes).not.toBeNull();
expect(createRes!.status).toBe(200);
const createBody = await createRes!.json();
createdUserIds.push(createBody.userId);
const created = getUserById(createBody.userId);
expect(created?.must_change_password).toBe(1);
expect(created).not.toBeNull();
const userToken = await generateToken(created!);
const updateUrl = new URL(`https://localhost/api/users/${createBody.userId}/password`);
const updateRes = await handleUsersRoutes(
new Request(updateUrl, {
method: "PUT",
headers: {
Authorization: `Bearer ${userToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ currentPassword: PASSWORD, newPassword: PASSWORD }),
}),
updateUrl,
mockServer,
);
expect(updateRes).not.toBeNull();
expect(updateRes!.status).toBe(200);
expect(getUserById(createBody.userId)?.must_change_password).toBe(0);
});
});
@@ -94,7 +94,7 @@ export async function handleUsersRoutes(
return Response.json({ error: "Invalid role" }, { status: 400 }); return Response.json({ error: "Invalid role" }, { status: 400 });
} }
const result = await createUser(username, password, role, authedUser.username); const result = await createUser(username, password, role, authedUser.username, true);
if (result.success) { if (result.success) {
const ip = server.requestIP(req)?.address || "unknown"; const ip = server.requestIP(req)?.address || "unknown";
+3 -2
View File
@@ -667,6 +667,7 @@ export async function createUser(
password: string, password: string,
role: UserRole, role: UserRole,
createdBy: string, createdBy: string,
mustChangePassword = false,
): Promise<{ success: boolean; error?: string; userId?: number }> { ): Promise<{ success: boolean; error?: string; userId?: number }> {
if (!username || username.length < 3 || username.length > 32) { if (!username || username.length < 3 || username.length > 32) {
return { return {
@@ -701,9 +702,9 @@ export async function createUser(
const result = db const result = db
.prepare( .prepare(
"INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope, plugin_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope, plugin_scope, can_build, can_upload_files) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none", role === "admin" ? "all" : "none", role === "admin" || role === "operator" ? 1 : 0, role === "admin" ? 1 : 0); .run(username, passwordHash, role, Date.now(), createdBy, mustChangePassword ? 1 : 0, role === "admin" ? "all" : "none", role === "admin" ? "all" : "none", role === "admin" || role === "operator" ? 1 : 0, role === "admin" ? 1 : 0);
invalidateNotificationDeliveryCache(); invalidateNotificationDeliveryCache();
+20
View File
@@ -88,6 +88,23 @@ test.describe("authenticated browser smoke tests", () => {
await expect(page.locator("#modal-img")).toHaveAttribute("src", /data:image\/gif/); await expect(page.locator("#modal-img")).toHaveAttribute("src", /data:image\/gif/);
}); });
test("change password page shows live password requirements", async ({ page }) => {
await page.goto("/login.html");
await page.evaluate(() => {
sessionStorage.setItem("temp_token", "e2e-temp-token");
sessionStorage.setItem("temp_user", JSON.stringify({ id: 1, username: "e2e-user" }));
});
await page.goto("/change-password.html");
await expect(page.locator("#password-policy")).toContainText("At least 6 characters");
await expect(page.locator('[data-password-rule="minLength"]')).toHaveClass(/is-unmet/);
await page.locator("#new-pass").fill("abcdef");
await expect(page.locator('[data-password-rule="minLength"]')).toHaveClass(/is-met/);
await expect(page.locator('[data-password-rule="match"]')).toHaveClass(/is-unmet/);
await page.locator("#confirm-pass").fill("abcdef");
await expect(page.locator('[data-password-rule="match"]')).toHaveClass(/is-met/);
});
test("password inputs expose browser-safe form semantics", async ({ page }) => { test("password inputs expose browser-safe form semantics", async ({ page }) => {
const issues = collectBrowserIssues(page); const issues = collectBrowserIssues(page);
await login(page); await login(page);
@@ -100,6 +117,9 @@ test.describe("authenticated browser smoke tests", () => {
await page.goto("/users"); await page.goto("/users");
await expect(page.locator("#password")).toHaveAttribute("autocomplete", "new-password"); await expect(page.locator("#password")).toHaveAttribute("autocomplete", "new-password");
await page.goto("/register.html");
await expect(page.locator("#reg-user")).toHaveAttribute("pattern", "[a-zA-Z0-9_\\x2d]+");
expect(issues).toEqual([]); expect(issues).toEqual([]);
}); });
}); });