新增账号,超管体系

This commit is contained in:
eafonyang
2026-06-18 17:37:08 +08:00
parent 481261b334
commit 476da9b459
27 changed files with 1439 additions and 84 deletions
+2
View File
@@ -9,6 +9,7 @@ const sequelize = require('./config/database');
const logger = require('./config/logger');
const routes = require('./routes');
const { bodyLimit } = require('./middleware/bodyLimit');
const { ensureBootstrapSuperAdmin } = require('./services/userBootstrap');
const app = express();
@@ -42,6 +43,7 @@ async function start() {
try {
logger.info('Creating database tables...');
await sequelize.sync();
await ensureBootstrapSuperAdmin();
logger.info('Database tables created successfully');
} catch (e) {
logger.error(`Warning: Could not create tables: ${e.message}`);
+13 -2
View File
@@ -11,7 +11,11 @@ function authMiddleware(req, res, next) {
if (!payload.sub) {
return res.status(401).json({ detail: '无效凭证' });
}
req.user = payload.sub;
req.user = {
id: payload.sub,
username: payload.username || '',
is_super_admin: !!payload.is_super_admin,
};
next();
} catch (e) {
if (e.name === 'TokenExpiredError') {
@@ -21,4 +25,11 @@ function authMiddleware(req, res, next) {
}
}
module.exports = { authMiddleware };
function requireSuperAdmin(req, res, next) {
if (!req.user?.is_super_admin) {
return res.status(403).json({ detail: '需要超级管理员权限' });
}
next();
}
module.exports = { authMiddleware, requireSuperAdmin };
+57
View File
@@ -0,0 +1,57 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const DashboardUser = sequelize.define('DashboardUser', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '主键',
},
username: {
type: DataTypes.STRING(64),
allowNull: false,
unique: true,
comment: '登录账号',
},
password_hash: {
type: DataTypes.STRING(255),
allowNull: false,
comment: '密码哈希(bcrypt',
},
is_super_admin: {
type: DataTypes.TINYINT,
allowNull: false,
defaultValue: 0,
comment: '是否超级管理员:1=是',
},
status: {
type: DataTypes.TINYINT,
allowNull: false,
defaultValue: 1,
comment: '状态:1=启用,0=禁用',
},
last_login_at: {
type: DataTypes.DATE,
allowNull: true,
comment: '最近登录时间',
},
create_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: '创建时间',
},
update_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
comment: '更新时间',
},
}, {
tableName: 'dashboard_user',
comment: 'Dashboard 后台账号',
});
module.exports = DashboardUser;
+2 -1
View File
@@ -2,5 +2,6 @@ const Brand = require('./Brand');
const Model = require('./Model');
const Ota = require('./Ota');
const ShareCodeLog = require('./ShareCodeLog');
const DashboardUser = require('./DashboardUser');
module.exports = { Brand, Model, Ota, ShareCodeLog };
module.exports = { Brand, Model, Ota, ShareCodeLog, DashboardUser };
+88 -10
View File
@@ -1,28 +1,61 @@
/**
* 认证路由 — 对应 Python: routes/auth.py
* 认证路由
*/
const router = require('express').Router();
const DashboardUser = require('../models/DashboardUser');
const logger = require('../config/logger');
const { ApiResponse } = require('../utils/response');
const { createAccessToken, verifyCredentials } = require('../utils/jwt');
const { createAccessToken, getTokenTtlSeconds } = require('../utils/jwt');
const { verifyPassword, hashPassword } = require('../utils/password');
const { authMiddleware } = require('../middleware/auth');
router.post('/api/auth/login', (req, res) => {
function userToDict(user) {
return {
id: user.id,
username: user.username,
is_super_admin: !!user.is_super_admin,
status: user.status,
last_login_at: user.last_login_at ? user.last_login_at.toISOString() : null,
create_at: user.create_at ? user.create_at.toISOString() : null,
update_at: user.update_at ? user.update_at.toISOString() : null,
};
}
router.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body || {};
if (!username || !password) {
const trimmedUsername = (username || '').trim();
if (!trimmedUsername || !password) {
return res.json(ApiResponse.error('用户名或密码不能为空'));
}
if (!verifyCredentials(username.trim(), password)) {
logger.warn(`Login failed for username=${username}`);
const user = await DashboardUser.findOne({
where: { username: trimmedUsername },
});
if (!user || user.status !== 1) {
logger.warn(`Login failed for username=${trimmedUsername}`);
return res.json(ApiResponse.error('用户名或密码错误'));
}
const token = createAccessToken();
const ttlSeconds = 12 * 60 * 60;
logger.info(`User ${username.trim()} logged in`);
const ok = await verifyPassword(password, user.password_hash);
if (!ok) {
logger.warn(`Login failed for username=${trimmedUsername}`);
return res.json(ApiResponse.error('用户名或密码错误'));
}
user.last_login_at = new Date();
await user.save();
const token = createAccessToken(user);
logger.info(`User ${trimmedUsername} logged in`);
return res.json(ApiResponse.success({
access_token: token,
token_type: 'bearer',
expires_in: ttlSeconds,
expires_in: getTokenTtlSeconds(),
user: userToDict(user),
}));
} catch (e) {
logger.error(`Login error: ${e.message}`);
@@ -30,4 +63,49 @@ router.post('/api/auth/login', (req, res) => {
}
});
router.get('/api/auth/me', authMiddleware, async (req, res) => {
try {
const user = await DashboardUser.findByPk(req.user.id);
if (!user || user.status !== 1) {
return res.status(401).json({ detail: '账号不存在或已禁用' });
}
return res.json(ApiResponse.success(userToDict(user)));
} catch (e) {
logger.error(`Get current user error: ${e.message}`);
return res.json(ApiResponse.error('获取用户信息失败'));
}
});
router.put('/api/auth/password', authMiddleware, async (req, res) => {
try {
const { old_password: oldPassword, new_password: newPassword } = req.body || {};
if (!oldPassword || !newPassword) {
return res.json(ApiResponse.error('请填写原密码和新密码'));
}
if (String(newPassword).length < 6) {
return res.json(ApiResponse.error('新密码至少 6 位'));
}
const user = await DashboardUser.findByPk(req.user.id);
if (!user || user.status !== 1) {
return res.status(401).json({ detail: '账号不存在或已禁用' });
}
const ok = await verifyPassword(oldPassword, user.password_hash);
if (!ok) {
return res.json(ApiResponse.error('原密码错误'));
}
user.password_hash = await hashPassword(newPassword);
await user.save();
logger.info(`User ${user.username} changed password`);
return res.json(ApiResponse.success(null, '密码已修改'));
} catch (e) {
logger.error(`Change password error: ${e.message}`);
return res.json(ApiResponse.error('修改密码失败'));
}
});
module.exports = router;
+2 -1
View File
@@ -6,5 +6,6 @@ const brandsRouter = require('./brands');
const modelsRouter = require('./models');
const otaRouter = require('./ota');
const shareCodeLogsRouter = require('./shareCodeLogs');
const usersRouter = require('./users');
module.exports = [authRouter, brandsRouter, modelsRouter, otaRouter, shareCodeLogsRouter];
module.exports = [authRouter, brandsRouter, modelsRouter, otaRouter, shareCodeLogsRouter, usersRouter];
+2 -2
View File
@@ -42,8 +42,8 @@ function convertTxtToCsv(buffer) {
for (const line of dataLines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('*')) continue;
const parts = trimmed.split(/\s+/);
if (parts.length >= 2) {
const parts = trimmed.split(/[;\s]+/);
if (parts.length >= 2 && /^[-+]?\d/.test(parts[0])) {
csvRows.push(`${parts[0]},${parts[1]}`);
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* 账号管理路由(超级管理员)
*/
const router = require('express').Router();
const { Op } = require('sequelize');
const DashboardUser = require('../models/DashboardUser');
const logger = require('../config/logger');
const { ApiResponse } = require('../utils/response');
const { authMiddleware, requireSuperAdmin } = require('../middleware/auth');
const { hashPassword } = require('../utils/password');
router.use(authMiddleware);
router.use(requireSuperAdmin);
function userToDict(user) {
return {
id: user.id,
username: user.username,
is_super_admin: !!user.is_super_admin,
status: user.status,
last_login_at: user.last_login_at ? user.last_login_at.toISOString() : null,
create_at: user.create_at ? user.create_at.toISOString() : null,
update_at: user.update_at ? user.update_at.toISOString() : null,
};
}
async function countSuperAdmins(excludeId = null) {
const where = { is_super_admin: 1, status: 1 };
if (excludeId != null) {
where.id = { [Op.ne]: excludeId };
}
return DashboardUser.count({ where });
}
// GET /api/users/
router.get('/api/users/', async (req, res) => {
try {
const skip = parseInt(req.query.skip || '0', 10);
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
const username = (req.query.username || '').trim();
const where = username ? { username: { [Op.like]: `%${username}%` } } : {};
const total = await DashboardUser.count({ where });
const rows = await DashboardUser.findAll({
where,
offset: skip,
limit,
order: [['id', 'ASC']],
});
if (!rows.length) {
return res.json(ApiResponse.noData('empty'));
}
const items = rows.map(userToDict);
return res.json(ApiResponse.success({ items, total, skip, limit }));
} catch (e) {
logger.error(`Error getting users: ${e.message}`);
return res.json(ApiResponse.error('error'));
}
});
// POST /api/users/
router.post('/api/users/', async (req, res) => {
try {
const username = (req.body.username || '').trim();
const password = req.body.password || '';
const isSuperAdmin = !!req.body.is_super_admin;
if (!username) {
return res.json(ApiResponse.error('用户名不能为空'));
}
if (username.length > 64) {
return res.json(ApiResponse.error('用户名不能超过 64 个字符'));
}
if (!password || password.length < 6) {
return res.json(ApiResponse.error('密码不能少于 6 位'));
}
const existing = await DashboardUser.findOne({ where: { username } });
if (existing) {
return res.json(ApiResponse.error('用户名已存在'));
}
const user = await DashboardUser.create({
username,
password_hash: await hashPassword(password),
is_super_admin: isSuperAdmin ? 1 : 0,
status: 1,
});
logger.info(`User created: id=${user.id}, username=${user.username}, by=${req.user.id}`);
return res.json(ApiResponse.success(userToDict(user), '账号创建成功'));
} catch (e) {
logger.error(`Error creating user: ${e.message}`);
return res.json(ApiResponse.error(`创建失败:${e.message}`));
}
});
// PUT /api/users/:user_id
router.put('/api/users/:user_id', async (req, res) => {
try {
const userId = parseInt(req.params.user_id, 10);
const dbUser = await DashboardUser.findByPk(userId);
if (!dbUser) {
return res.json(ApiResponse.noData('账号不存在'));
}
const { status, is_super_admin: isSuperAdmin, password } = req.body || {};
const nextStatus = status !== undefined && status !== null ? parseInt(status, 10) : dbUser.status;
const nextIsSuperAdmin = isSuperAdmin !== undefined && isSuperAdmin !== null
? (isSuperAdmin ? 1 : 0)
: dbUser.is_super_admin;
if (![0, 1].includes(nextStatus)) {
return res.json(ApiResponse.error('状态值无效'));
}
const wasSuperAdmin = !!dbUser.is_super_admin;
const willBeSuperAdmin = !!nextIsSuperAdmin;
const willBeEnabled = nextStatus === 1;
if (wasSuperAdmin && (!willBeSuperAdmin || !willBeEnabled)) {
const otherSuperAdmins = await countSuperAdmins(userId);
if (otherSuperAdmins === 0) {
return res.json(ApiResponse.error('不能禁用或降级最后一个超级管理员'));
}
}
if (password !== undefined && password !== null && String(password).length > 0) {
if (String(password).length < 6) {
return res.json(ApiResponse.error('密码不能少于 6 位'));
}
dbUser.password_hash = await hashPassword(password);
}
dbUser.status = nextStatus;
dbUser.is_super_admin = nextIsSuperAdmin ? 1 : 0;
await dbUser.save();
logger.info(`User updated: id=${userId}, by=${req.user.id}`);
return res.json(ApiResponse.success(userToDict(dbUser), '账号更新成功'));
} catch (e) {
logger.error(`Error updating user ${req.params.user_id}: ${e.message}`);
return res.json(ApiResponse.error(`更新失败:${e.message}`));
}
});
// DELETE /api/users/:user_id
router.delete('/api/users/:user_id', async (req, res) => {
try {
const userId = parseInt(req.params.user_id, 10);
if (userId === req.user.id) {
return res.json(ApiResponse.error('不能删除当前登录账号'));
}
const dbUser = await DashboardUser.findByPk(userId);
if (!dbUser) {
return res.json(ApiResponse.noData('账号不存在'));
}
if (dbUser.is_super_admin) {
const otherSuperAdmins = await countSuperAdmins(userId);
if (otherSuperAdmins === 0) {
return res.json(ApiResponse.error('不能删除最后一个超级管理员'));
}
}
await dbUser.destroy();
logger.info(`User deleted: id=${userId}, by=${req.user.id}`);
return res.json(ApiResponse.success(null, '删除成功'));
} catch (e) {
logger.error(`Error deleting user ${req.params.user_id}: ${e.message}`);
return res.json(ApiResponse.error(`删除失败:${e.message}`));
}
});
module.exports = router;
+27
View File
@@ -0,0 +1,27 @@
const { DashboardUser } = require('../models');
const logger = require('../config/logger');
const { hashPassword } = require('../utils/password');
async function ensureBootstrapSuperAdmin() {
const count = await DashboardUser.count();
if (count > 0) return;
const username = (process.env.DASHBOARD_ADMIN_USERNAME || 'admin').trim();
const password = process.env.DASHBOARD_ADMIN_PASSWORD || 'Eafon123';
if (!username) {
logger.warn('dashboard_user is empty and DASHBOARD_ADMIN_USERNAME is not set; skip bootstrap');
return;
}
await DashboardUser.create({
username,
password_hash: await hashPassword(password),
is_super_admin: 1,
status: 1,
});
logger.info(`Bootstrap super admin created: username=${username}`);
}
module.exports = { ensureBootstrapSuperAdmin };
+7 -8
View File
@@ -4,10 +4,12 @@ const JWT_SECRET = process.env.JWT_SECRET || 'dev-only-change-me-for-production'
const JWT_ALGORITHM = 'HS256';
const TOKEN_TTL_HOURS = 12;
function createAccessToken() {
function createAccessToken(user) {
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: process.env.DASHBOARD_ADMIN_USERNAME || 'admin',
sub: user.id,
username: user.username,
is_super_admin: !!user.is_super_admin,
iat: now,
exp: now + TOKEN_TTL_HOURS * 3600,
};
@@ -18,11 +20,8 @@ function decodeToken(token) {
return jwt.verify(token, JWT_SECRET, { algorithms: [JWT_ALGORITHM] });
}
function verifyCredentials(username, password) {
const adminUsername = process.env.DASHBOARD_ADMIN_USERNAME || 'admin';
const adminPassword = process.env.DASHBOARD_ADMIN_PASSWORD || 'Eafon123';
if (username !== adminUsername) return false;
return password === adminPassword;
function getTokenTtlSeconds() {
return TOKEN_TTL_HOURS * 3600;
}
module.exports = { createAccessToken, decodeToken, verifyCredentials };
module.exports = { createAccessToken, decodeToken, getTokenTtlSeconds, TOKEN_TTL_HOURS };
+36
View File
@@ -0,0 +1,36 @@
const crypto = require('crypto');
const { promisify } = require('util');
const pbkdf2Async = promisify(crypto.pbkdf2);
const ITERATIONS = 310000;
const KEYLEN = 32;
const DIGEST = 'sha256';
async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = await pbkdf2Async(password, salt, ITERATIONS, KEYLEN, DIGEST);
return `pbkdf2:${DIGEST}:${ITERATIONS}:${salt}:${hash.toString('hex')}`;
}
async function verifyPassword(password, stored) {
if (!password || !stored || typeof stored !== 'string') return false;
const parts = stored.split(':');
if (parts.length !== 5 || parts[0] !== 'pbkdf2') return false;
const digest = parts[1];
const iterations = parseInt(parts[2], 10);
const salt = parts[3];
const expectedHex = parts[4];
if (!digest || !Number.isFinite(iterations) || !salt || !expectedHex) return false;
const expected = Buffer.from(expectedHex, 'hex');
const actual = await pbkdf2Async(password, salt, iterations, expected.length, digest);
if (expected.length !== actual.length) return false;
return crypto.timingSafeEqual(expected, actual);
}
module.exports = { hashPassword, verifyPassword };