Files
happy-export/backend/routes/auth.js
T
Drsakura 1ffcc1ea07 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.
2026-10-22 17:24:19 +08:00

210 lines
8.9 KiB
JavaScript

/**
* 账号与个人资料。
* 挂载点:/api(路由内部自带 /auth/* 与 /me* 前缀)
*
* 免登录白名单在 server.js 的 /api 登录门里声明(health / auth\/ / system\/),
* 所以这里 /auth/login、/auth/logout、/auth/me 都可以在没有会话时访问。
*/
const express = require('express');
const db = require('../db/db');
const auth = require('../lib/auth');
const { ROLES } = require('../lib/permissions');
const router = express.Router();
const ROLE_VALUES = ROLES.map(item => item.value);
function badRequest(message) {
const error = new Error(message);
error.statusCode = 400;
return error;
}
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode
|| (/必须|不能为空|不正确|不存在|不一致|格式|至少|已停用|已锁定/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
/* ------------------------------------------------------------ 登录相关 */
/** POST /api/auth/login */
router.post('/auth/login', (req, res) => {
try {
const username = String(req.body?.username || '').trim();
const password = String(req.body?.password || '');
if (!username) throw badRequest('请输入用户名');
if (!password) throw badRequest('请输入密码');
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) {
auth.audit({ username, action: 'login_failed', detail: '用户名不存在', req, ok: 0 });
throw badRequest('用户名或密码不正确');
}
if (user.locked_until && new Date(user.locked_until).getTime() > Date.now()) {
const minutes = Math.max(1, Math.ceil((new Date(user.locked_until).getTime() - Date.now()) / 60000));
auth.audit({ username, action: 'login_blocked', detail: '账号锁定中', req, ok: 0 });
throw badRequest(`账号已锁定,请 ${minutes} 分钟后再试`);
}
if (!user.is_active) {
auth.audit({ username, action: 'login_blocked', detail: '账号已停用', req, ok: 0 });
throw badRequest('账号已停用,请联系管理员');
}
if (!auth.verifyPassword(password, user.password_hash)) {
const failed = Number(user.failed_count || 0) + 1;
const lockedUntil = failed >= auth.MAX_FAILED
? new Date(Date.now() + auth.LOCK_MS).toISOString()
: null;
db.prepare('UPDATE users SET failed_count = ?, locked_until = ? WHERE id = ?').run(failed, lockedUntil, user.id);
auth.audit({ username, action: 'login_failed', detail: `密码错误(第 ${failed} 次)`, req, ok: 0 });
throw badRequest(lockedUntil
? `密码连续错误 ${failed} 次,账号已锁定 15 分钟`
: '用户名或密码不正确');
}
const now = new Date().toISOString();
db.prepare('UPDATE users SET failed_count = 0, locked_until = NULL, last_login_at = ? WHERE id = ?').run(now, user.id);
const sessionId = auth.createSession(user.id, req);
auth.setSessionCookie(res, sessionId);
auth.audit({ username: user.username, action: 'login', detail: '登录成功', req });
res.json({ user: auth.publicUser({ ...user, last_login_at: now }) });
} catch (error) {
fail(res, error);
}
});
/** POST /api/auth/logout */
router.post('/auth/logout', (req, res) => {
try {
if (req.user) {
auth.destroySession(req.user.sid);
auth.audit({ username: req.user.username, action: 'logout', detail: '退出登录', req });
}
auth.clearSessionCookie(res);
res.json({ ok: true });
} catch (error) {
fail(res, error);
}
});
/** GET /api/auth/me —— 前端启动时用它判断是否已登录 */
router.get('/auth/me', (req, res) => {
if (!req.user) return res.status(401).json({ error: '请先登录' });
res.json({ user: auth.publicUser(req.user) });
});
/* ------------------------------------------------------------ 个人资料 */
const PROFILE_TEXT_FIELDS = [
['display_name', '显示名'],
['email', '联系邮箱'],
['phone', '电话'],
['address', '地址'],
['position', '职位'],
['avatar', '头像']
];
/** PATCH /api/me —— 保存个人资料 */
router.patch('/me', (req, res) => {
try {
if (!req.user) return res.status(401).json({ error: '请先登录' });
const fields = [];
const values = [];
for (const [key, label] of PROFILE_TEXT_FIELDS) {
if (req.body?.[key] === undefined) continue;
const value = String(req.body[key] ?? '').trim();
if (key === 'display_name' && !value) throw badRequest('显示名不能为空');
if (key === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw badRequest('联系邮箱格式不正确');
}
fields.push(`${key} = ?`);
values.push(value || null);
}
if (req.body?.role !== undefined || req.body?.org_id !== undefined) {
if (!auth.permissionsOfRole(req.user.role).includes('user.manage')) {
return res.status(403).json({ error: '角色与组织请联系管理员修改' });
}
}
if (req.body?.role !== undefined) {
const role = String(req.body.role || '').trim();
if (!ROLE_VALUES.includes(role)) throw badRequest('角色取值不在允许范围内');
fields.push('role = ?');
values.push(role);
}
if (req.body?.org_id !== undefined) {
const orgId = Number(req.body.org_id);
if (!Number.isInteger(orgId) || !db.prepare('SELECT id FROM organizations WHERE id = ?').get(orgId)) {
throw badRequest('所属组织不存在');
}
fields.push('org_id = ?');
values.push(orgId);
}
if (!fields.length) throw badRequest('没有需要保存的内容');
fields.push('updated_at = ?');
values.push(new Date().toISOString());
values.push(req.user.id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
auth.audit({ username: updated.username, action: 'profile_update', detail: '更新个人资料', req });
res.json({ user: auth.publicUser(updated), message: '个人资料已保存' });
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/me/username —— 账号绑定用户 id,改名不改变业务数据归属。 */
router.patch('/me/username', auth.requireAuth, (req, res) => {
try {
const username = String(req.body?.username || '').trim();
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.@-]{2,49}$/.test(username)) {
throw badRequest('用户名需为 3–50 位字母、数字或 _ . @ -,以字母、数字或下划线开头');
}
auth.verifyCurrentPassword(req, req.body?.current_password);
if (username === req.user.username) throw badRequest('新用户名与当前用户名相同');
const change = db.transaction(() => {
if (db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?').get(username, req.user.id)) {
const error = badRequest('该用户名已被使用');
error.statusCode = 409;
throw error;
}
db.prepare('UPDATE users SET username = ?, updated_at = ? WHERE id = ?')
.run(username, new Date().toISOString(), req.user.id);
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(req.user.id, req.user.sid);
});
change();
auth.audit({ username, action: 'username_change', detail: `用户名由 ${req.user.username} 修改为 ${username}`, req });
res.json({ user: auth.publicUser(db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id)), message: '用户名已修改,其它设备需重新登录' });
} catch (error) { fail(res, error); }
});
/** PATCH /api/me/password —— 改密码:三段校验全过才落库 */
router.patch('/me/password', (req, res) => {
try {
if (!req.user) return res.status(401).json({ error: '请先登录' });
const current = String(req.body?.current_password || '');
const next = String(req.body?.new_password || '');
const confirm = String(req.body?.confirm_password || '');
if (!current) throw badRequest('请输入当前密码');
if (!next) throw badRequest('请输入新密码');
if (next.length < 6) throw badRequest('新密码至少 6 位');
if (next !== confirm) throw badRequest('两次输入的新密码不一致');
if (next === current) throw badRequest('新密码不能与当前密码相同');
auth.verifyCurrentPassword(req, current);
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?')
.run(auth.hashPassword(next), new Date().toISOString(), req.user.id);
/* 保住当前会话,踢掉其它设备上已登录的会话 */
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(req.user.id, req.user.sid);
auth.audit({ username: req.user.username, action: 'password_change', detail: '修改密码成功', req });
res.json({ ok: true, message: '密码已修改' });
} catch (error) {
fail(res, error);
}
});
module.exports = router;