新增账号,超管体系
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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]}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user