Add admin password reset flow

This commit is contained in:
Zimeng Xiong
2026-02-06 14:11:13 -08:00
parent e4941ad77f
commit 1e617025df
23 changed files with 4205 additions and 698 deletions
+70
View File
@@ -29,6 +29,7 @@
"helmet": "^8.1.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ms": "^2.1.3",
"multer": "^2.0.2",
"prisma": "^5.22.0",
@@ -3150,6 +3151,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -3371,6 +3378,48 @@
"npm": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -3434,6 +3483,15 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
@@ -3880,6 +3938,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -4393,6 +4457,12 @@
"node": ">= 18"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+2
View File
@@ -6,6 +6,7 @@
"scripts": {
"predev": "node scripts/predev-migrate.cjs",
"dev": "nodemon src/index.ts",
"admin:recover": "node scripts/admin-recover.cjs",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
@@ -35,6 +36,7 @@
"helmet": "^8.1.0",
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ms": "^2.1.3",
"multer": "^2.0.2",
"prisma": "^5.22.0",
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitEnabled" BOOLEAN NOT NULL DEFAULT 1;
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitWindowMs" INTEGER NOT NULL DEFAULT 900000;
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitMax" INTEGER NOT NULL DEFAULT 20;
+8 -5
View File
@@ -31,11 +31,14 @@ model User {
}
model SystemConfig {
id String @id @default("default")
authEnabled Boolean @default(false)
registrationEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default("default")
authEnabled Boolean @default(false)
registrationEnabled Boolean @default(false)
authLoginRateLimitEnabled Boolean @default(true)
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
authLoginRateLimitMax Int @default(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Collection {
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* CLI admin password recovery for ExcaliDash.
*
* Examples:
* node scripts/admin-recover.cjs --identifier admin@example.com --password "NewStrongPassword!"
* node scripts/admin-recover.cjs --identifier admin@example.com --generate
*
* Notes:
* - Works with SQLite DATABASE_URL (default: file:./prisma/dev.db).
* - Sets the password hash and clears mustResetPassword by default.
* - If there are no active admins, this script can promote the target user to ADMIN.
*/
require("dotenv").config();
const path = require("path");
process.env.DATABASE_URL =
process.env.DATABASE_URL ||
`file:${path.resolve(__dirname, "../prisma/dev.db")}`;
const { PrismaClient } = require("../src/generated/client");
const bcrypt = require("bcrypt");
const parseArgs = (argv) => {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith("--")) continue;
const key = token.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
} else {
args[key] = next;
i += 1;
}
}
return args;
};
const generatePassword = () => {
// 24 chars base64url-ish
const buf = require("crypto").randomBytes(18);
return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24);
};
const main = async () => {
const args = parseArgs(process.argv.slice(2));
const identifier = typeof args.identifier === "string" ? args.identifier.trim() : "";
const providedPassword = typeof args.password === "string" ? args.password : null;
const generate = Boolean(args.generate);
const setMustReset = Boolean(args["must-reset"]);
const activate = Boolean(args.activate);
const promote = Boolean(args.promote);
const disableLoginRateLimit = Boolean(args["disable-login-rate-limit"]);
if (!identifier) {
console.error("Missing --identifier (email or username).");
process.exitCode = 2;
return;
}
let newPassword = providedPassword;
if (!newPassword) {
if (!generate) {
console.error('Provide --password "<new password>" or pass --generate.');
process.exitCode = 2;
return;
}
newPassword = generatePassword();
}
if (newPassword.length < 8) {
console.error("Password must be at least 8 characters.");
process.exitCode = 2;
return;
}
const prisma = new PrismaClient();
try {
const activeAdminCount = await prisma.user.count({
where: { role: "ADMIN", isActive: true },
});
const trimmed = identifier.toLowerCase();
const user = await prisma.user.findFirst({
where: {
OR: [{ email: trimmed }, { username: identifier }],
},
select: {
id: true,
email: true,
username: true,
role: true,
isActive: true,
mustResetPassword: true,
},
});
if (!user) {
console.error("User not found:", identifier);
process.exitCode = 1;
return;
}
const shouldPromote = promote || activeAdminCount === 0;
if (user.role !== "ADMIN" && !shouldPromote) {
console.error("Target user is not an ADMIN. Refusing to reset password for non-admin user.");
console.error("Tip: pass --promote to promote this user to ADMIN, or use it only when there are 0 active admins.");
process.exitCode = 1;
return;
}
const saltRounds = 10;
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
if (disableLoginRateLimit) {
await prisma.systemConfig.upsert({
where: { id: "default" },
update: { authLoginRateLimitEnabled: false },
create: {
id: "default",
authEnabled: true,
registrationEnabled: false,
authLoginRateLimitEnabled: false,
authLoginRateLimitWindowMs: 15 * 60 * 1000,
authLoginRateLimitMax: 20,
},
});
}
const updated = await prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
mustResetPassword: setMustReset ? true : false,
isActive: activate ? true : user.isActive,
role: shouldPromote ? "ADMIN" : user.role,
},
select: {
id: true,
email: true,
username: true,
role: true,
isActive: true,
mustResetPassword: true,
},
});
console.log("Updated admin account:");
console.log(`- id: ${updated.id}`);
console.log(`- email: ${updated.email}`);
console.log(`- username: ${updated.username || ""}`);
console.log(`- isActive: ${updated.isActive}`);
console.log(`- mustResetPassword: ${updated.mustResetPassword}`);
console.log(`- role: ${updated.role}`);
if (disableLoginRateLimit) {
console.log("");
console.log("Login rate limiting: DISABLED (SystemConfig.authLoginRateLimitEnabled=false).");
console.log("Remember to re-enable it from the Admin dashboard after you regain access.");
}
if (generate || !providedPassword) {
console.log("");
console.log("New password:");
console.log(newPassword);
} else {
console.log("");
console.log("Password updated.");
}
} finally {
await prisma.$disconnect().catch(() => {});
}
};
main().catch((err) => {
console.error("Admin recovery failed:", err);
process.exitCode = 1;
});
+1020 -28
View File
File diff suppressed because it is too large Load Diff
+730 -205
View File
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -89,6 +89,7 @@ declare global {
name: string;
role: string;
mustResetPassword?: boolean;
impersonatorId?: string;
};
}
}
@@ -98,6 +99,7 @@ interface JwtPayload {
userId: string;
email: string;
type: "access" | "refresh";
impersonatorId?: string;
}
/**
@@ -108,10 +110,13 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
return false;
}
const payload = decoded as Record<string, unknown>;
const impersonatorOk =
typeof payload.impersonatorId === "undefined" || typeof payload.impersonatorId === "string";
return (
typeof payload.userId === "string" &&
typeof payload.email === "string" &&
(payload.type === "access" || payload.type === "refresh")
(payload.type === "access" || payload.type === "refresh") &&
impersonatorOk
);
};
@@ -148,6 +153,23 @@ const verifyToken = (token: string): JwtPayload | null => {
}
};
const normalizeRequestPath = (req: Request): string => {
const raw = (req.originalUrl || req.url || "").split("?")[0] || "";
// In some deployments the backend may see a /api prefix.
return raw.replace(/^\/api(?=\/)/, "");
};
const isAllowedWhileMustResetPassword = (req: Request): boolean => {
const path = normalizeRequestPath(req);
// Permit fetching current user and changing password.
if (req.method === "GET" && path === "/auth/me") return true;
if (req.method === "POST" && path === "/auth/change-password") return true;
if (req.method === "POST" && path === "/auth/must-reset-password") return true;
return false;
};
/**
* Require authentication middleware
* Protects routes that require a valid JWT token
@@ -224,6 +246,15 @@ export const requireAuth = async (
return;
}
if (user.mustResetPassword && !isAllowedWhileMustResetPassword(req)) {
res.status(403).json({
error: "Forbidden",
code: "MUST_RESET_PASSWORD",
message: "You must reset your password before using the app",
});
return;
}
// Attach user to request
req.user = {
id: user.id,
@@ -232,6 +263,7 @@ export const requireAuth = async (
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
impersonatorId: payload.impersonatorId,
};
next();
@@ -297,6 +329,7 @@ export const optionalAuth = async (
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
impersonatorId: payload.impersonatorId,
};
}
} catch (error) {