feat: add admin web updates and verified username changes

Add isolated update jobs with database backups, progress logs and application rollback. Remove default account hints and prevent default admin recreation after renaming.

Enforce customer and order search scopes, secure session cookies, fix quotation rounding and amount wording, and update backend dependencies. Add API and deployment regression coverage.
This commit is contained in:
Drsakura
2026-10-22 17:24:19 +08:00
parent dc607e4ea3
commit 1ffcc1ea07
20 changed files with 905 additions and 585 deletions
+21 -2
View File
@@ -41,6 +41,22 @@ function verifyPassword(plain, stored) {
return crypto.timingSafeEqual(a, b);
}
/* 密码复核与登录共享失败次数和锁定,避免已登录会话无限猜密码。 */
function verifyCurrentPassword(req, password) {
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
const reject = (message, statusCode) => { throw Object.assign(new Error(message), { statusCode }); };
if (user.locked_until && Date.parse(user.locked_until) > Date.now()) reject('账号已锁定,请稍后再试', 429);
if (!password) reject('请输入当前密码', 400);
if (!verifyPassword(String(password), user.password_hash)) {
const failed = Number(user.failed_count || 0) + 1;
db.prepare('UPDATE users SET failed_count = ?, locked_until = ? WHERE id = ?')
.run(failed, failed >= MAX_FAILED ? new Date(Date.now() + LOCK_MS).toISOString() : null, user.id);
audit({ username: user.username, action: 'password_verify_failed', detail: '敏感操作密码验证失败', req, ok: 0 });
reject(failed >= MAX_FAILED ? '密码连续错误,账号已锁定 15 分钟' : '当前密码不正确', failed >= MAX_FAILED ? 429 : 400);
}
db.prepare('UPDATE users SET failed_count = 0, locked_until = NULL WHERE id = ?').run(user.id);
}
/* ---------------------------------------------------------------- cookie */
function parseCookies(header) {
@@ -62,12 +78,13 @@ function parseCookies(header) {
function setSessionCookie(res, id, maxAgeMs = SESSION_TTL_MS) {
res.append('Set-Cookie', [
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`
`${COOKIE_NAME}=${id}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`,
...(process.env.SECURE_COOKIE === '1' ? ['Secure'] : [])
].join('; '));
}
function clearSessionCookie(res) {
res.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
setSessionCookie(res, '', 0);
}
function clientIp(req) {
@@ -110,6 +127,7 @@ function sessionUser(req) {
return null;
}
if (!row.is_active) return null;
if (row.locked_until && Date.parse(row.locked_until) > Date.now()) return null;
return row;
}
@@ -210,6 +228,7 @@ module.exports = {
LOCK_MS,
hashPassword,
verifyPassword,
verifyCurrentPassword,
parseCookies,
setSessionCookie,
clearSessionCookie,