better password shit
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { createPasswordPolicyChecklist, loadPasswordPolicy } from "./password-policy.js";
|
||||
|
||||
const form = document.getElementById("change-password-form");
|
||||
const currentPass = document.getElementById("current-pass");
|
||||
const newPass = document.getElementById("new-pass");
|
||||
const confirmPass = document.getElementById("confirm-pass");
|
||||
const errEl = document.getElementById("error");
|
||||
const successEl = document.getElementById("success");
|
||||
const policyEl = document.getElementById("password-policy");
|
||||
|
||||
const tempToken = sessionStorage.getItem("temp_token");
|
||||
const tempUser = sessionStorage.getItem("temp_user");
|
||||
@@ -14,6 +17,14 @@ if (!tempToken || !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") {
|
||||
const particles = document.querySelectorAll(".particle");
|
||||
particles.forEach((particle, i) => {
|
||||
@@ -131,15 +142,12 @@ form?.addEventListener("submit", async (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPass.value.length < 6) {
|
||||
errEl.textContent = "New password must be at least 6 characters";
|
||||
const policyError = passwordChecklist.firstError();
|
||||
if (policyError) {
|
||||
errEl.textContent = policyError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPass.value === newPass.value) {
|
||||
errEl.textContent = "New password must be different from current password";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user.id}/password`, {
|
||||
|
||||
@@ -1195,6 +1195,63 @@ article.card[data-bookmarked="true"] {
|
||||
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 {
|
||||
background: linear-gradient(120deg, #4f6bff, #715dff);
|
||||
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 };
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import { createPasswordPolicyChecklist, loadPasswordPolicy } from "./password-policy.js";
|
||||
|
||||
const form = document.getElementById("register-form");
|
||||
const errorEl = document.getElementById("error");
|
||||
const successEl = document.getElementById("success");
|
||||
const keyGroup = document.getElementById("key-group");
|
||||
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() {
|
||||
try {
|
||||
@@ -87,16 +101,17 @@ form.addEventListener("submit", async (e) => {
|
||||
successEl.style.display = "none";
|
||||
|
||||
const username = document.getElementById("reg-user").value.trim();
|
||||
const password = document.getElementById("reg-pass").value;
|
||||
const confirmPassword = document.getElementById("reg-pass-confirm").value;
|
||||
const password = passwordInput.value;
|
||||
const confirmPassword = confirmPasswordInput.value;
|
||||
const key = keyInput.value.trim();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errorEl.textContent = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
errorEl.textContent = "Password must be at least 6 characters.";
|
||||
const policyError = passwordChecklist.firstError();
|
||||
if (policyError) {
|
||||
errorEl.textContent = policyError;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1007,7 +1007,7 @@
|
||||
<i class="fa-solid fa-file-signature text-blue-400"></i> Startup File Name
|
||||
</div>
|
||||
<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)"
|
||||
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>
|
||||
|
||||
@@ -77,6 +77,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="password-policy" class="password-policy-card" aria-live="polite"></div>
|
||||
|
||||
<button type="submit" class="button primary full login-btn">
|
||||
<span class="btn-text">Update password</span>
|
||||
<i class="fa-solid fa-arrow-right btn-icon"></i>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="32"
|
||||
pattern="[a-zA-Z0-9_\-]+"
|
||||
pattern="[a-zA-Z0-9_\x2d]+"
|
||||
placeholder="Choose a username"
|
||||
/>
|
||||
</div>
|
||||
@@ -75,6 +75,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="password-policy" class="password-policy-card" aria-live="polite"></div>
|
||||
<div class="form-group" id="key-group" style="display: none">
|
||||
<label class="label">Registration Key</label>
|
||||
<div class="input-row input-animated">
|
||||
@@ -101,6 +102,6 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/register.js"></script>
|
||||
<script type="module" src="/assets/register.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
pattern="[a-zA-Z0-9_-]+"
|
||||
pattern="[a-zA-Z0-9_\x2d]+"
|
||||
minlength="3"
|
||||
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"
|
||||
@@ -187,6 +187,7 @@
|
||||
</div>
|
||||
<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-amber-300/80 mt-1">New users are prompted to confirm or change this password on first login.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -61,6 +61,17 @@ setInterval(() => {
|
||||
}, 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 = {
|
||||
requestIP?: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
@@ -80,6 +91,7 @@ export async function handleRegistrationRoutes(
|
||||
return Response.json({
|
||||
enabled: mode !== "off",
|
||||
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 });
|
||||
}
|
||||
|
||||
const result = await createUser(username, password, role, authedUser.username);
|
||||
const result = await createUser(username, password, role, authedUser.username, true);
|
||||
|
||||
if (result.success) {
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
|
||||
@@ -667,6 +667,7 @@ export async function createUser(
|
||||
password: string,
|
||||
role: UserRole,
|
||||
createdBy: string,
|
||||
mustChangePassword = false,
|
||||
): Promise<{ success: boolean; error?: string; userId?: number }> {
|
||||
if (!username || username.length < 3 || username.length > 32) {
|
||||
return {
|
||||
@@ -701,9 +702,9 @@ export async function createUser(
|
||||
|
||||
const result = db
|
||||
.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();
|
||||
|
||||
|
||||
@@ -88,6 +88,23 @@ test.describe("authenticated browser smoke tests", () => {
|
||||
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 }) => {
|
||||
const issues = collectBrowserIssues(page);
|
||||
await login(page);
|
||||
@@ -100,6 +117,9 @@ test.describe("authenticated browser smoke tests", () => {
|
||||
|
||||
await page.goto("/users");
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user