chore: initial import
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
const logger = require('../utils/logger');
|
||||
const db = require('../db');
|
||||
|
||||
/**
|
||||
* 根据请求方法和 URL 获取操作说明
|
||||
*/
|
||||
const getActionDescription = (method, url) => {
|
||||
// 移除查询参数
|
||||
const path = url.split('?')[0];
|
||||
|
||||
// --- Auth ---
|
||||
if (path === '/api/auth/login' && method === 'POST') return '用户登录';
|
||||
if (path === '/api/auth/register' && method === 'POST') return '用户注册';
|
||||
if (path === '/api/auth/send-code' && method === 'POST') return '发送验证码';
|
||||
if (path === '/api/auth/reset-password' && method === 'POST') return '重置密码';
|
||||
|
||||
// --- Achievements ---
|
||||
if (path === '/api/achievements' && method === 'POST') return '上传成果';
|
||||
if (path === '/api/achievements' && method === 'GET') {
|
||||
if (url.includes('export_action=true')) return '导出用户成果数据';
|
||||
return '查看成果列表';
|
||||
}
|
||||
if (path === '/api/achievements/export/csv' && method === 'GET') return '导出成果(CSV)';
|
||||
if (path === '/api/achievements/export/zip' && method === 'GET') return '导出成果(ZIP)';
|
||||
if (path === '/api/achievements/validate' && method === 'GET') return '数据校验';
|
||||
|
||||
// 动态路径匹配
|
||||
if (path.match(/^\/api\/achievements\/audit\/\d+$/) && method === 'POST') return '审核成果';
|
||||
if (path.match(/^\/api\/achievements\/\d+\/attachments\/zip$/) && method === 'GET') return '下载成果附件包';
|
||||
if (path.match(/^\/api\/achievements\/attachments\/\d+\/download$/) && method === 'GET') return '下载单个附件';
|
||||
if (path.match(/^\/api\/achievements\/\d+$/)) {
|
||||
if (method === 'GET') return '查看成果详情';
|
||||
if (method === 'PUT') return '修改成果';
|
||||
if (method === 'DELETE') return '删除成果';
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
if (path === '/api/users' && method === 'POST') return '创建用户';
|
||||
if (path === '/api/users' && method === 'GET') return '查看用户列表';
|
||||
if (path === '/api/users/search' && method === 'GET') return '搜索用户';
|
||||
if (path === '/api/users/pending-list' && method === 'GET') return '查看待定用户';
|
||||
if (path.match(/^\/api\/users\/\d+$/)) {
|
||||
if (method === 'PUT') return '修改用户';
|
||||
if (method === 'DELETE') return '删除用户';
|
||||
}
|
||||
|
||||
// --- Dictionaries ---
|
||||
if (path === '/api/dictionaries/departments' && method === 'GET') return '查看部门列表(公开)';
|
||||
if (path.match(/^\/api\/dictionaries\/[^/]+$/)) {
|
||||
if (method === 'GET') return '查看字典列表';
|
||||
if (method === 'POST') return '添加字典项';
|
||||
}
|
||||
if (path.match(/^\/api\/dictionaries\/[^/]+\/\d+$/)) {
|
||||
if (method === 'PUT') return '修改字典项';
|
||||
if (method === 'DELETE') return '删除字典项';
|
||||
}
|
||||
|
||||
// --- Notifications ---
|
||||
if (path === '/api/notifications' && method === 'GET') return '查看通知列表';
|
||||
if (path === '/api/notifications' && method === 'POST') return '发布通知';
|
||||
if (path.match(/^\/api\/notifications\/attachments\/\d+\/download$/) && method === 'GET') return '下载通知附件';
|
||||
if (path.match(/^\/api\/notifications\/\d+$/)) {
|
||||
if (method === 'GET') return '查看通知详情';
|
||||
if (method === 'DELETE') return '删除通知';
|
||||
}
|
||||
|
||||
// --- Statistics ---
|
||||
if (path === '/api/statistics' && method === 'GET') return '查看统计数据';
|
||||
|
||||
// --- Logs ---
|
||||
if (path === '/api/logs' && method === 'GET') return '查看系统日志';
|
||||
|
||||
// 默认
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局审计日志中间件
|
||||
* 记录所有 API 请求的操作人、IP、方法、URL 和 结果状态
|
||||
*/
|
||||
const auditLogger = (req, res, next) => {
|
||||
// 记录请求开始时间
|
||||
const start = Date.now();
|
||||
|
||||
// 监听请求完成事件
|
||||
res.on('finish', async () => {
|
||||
const duration = Date.now() - start;
|
||||
const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
|
||||
const method = req.method;
|
||||
const url = req.originalUrl || req.url;
|
||||
const status = res.statusCode;
|
||||
|
||||
// 忽略非 API 请求 (虽然通常挂载在 /api 下,但双重保险)
|
||||
if (!url.startsWith('/api/')) return;
|
||||
|
||||
// 忽略 OPTIONS 预检请求,避免日志刷屏
|
||||
if (method === 'OPTIONS') return;
|
||||
|
||||
// URL 脱敏处理:隐藏 token, password, code 等敏感参数
|
||||
let safeUrl = url;
|
||||
try {
|
||||
safeUrl = safeUrl.replace(/([?&])(token|password|code)=([^&]+)/gi, '$1$2=***');
|
||||
} catch (e) {
|
||||
// 忽略正则错误,保持原样
|
||||
}
|
||||
|
||||
let userInfo = 'Guest';
|
||||
let userId = null;
|
||||
let username = null;
|
||||
let realName = null;
|
||||
|
||||
// 1. 尝试获取已登录用户信息
|
||||
// 注意:req.user 由 verifyToken 中间件挂载。
|
||||
// 由于我们在 res 'finish' 事件中读取,此时路由处理已完成,req.user 应该已存在(如果通过了认证)。
|
||||
if (req.user) {
|
||||
userInfo = `${req.user.real_name || req.user.username} (ID:${req.user.id})`;
|
||||
userId = req.user.id;
|
||||
username = req.user.username;
|
||||
realName = req.user.real_name;
|
||||
}
|
||||
// 2. 对于未登录的关键接口,尝试从 body 中提取身份信息
|
||||
else if (req.body) {
|
||||
if (url.includes('/login')) {
|
||||
userInfo = `[尝试登录: ${req.body.username || '未知'}]`;
|
||||
username = req.body.username;
|
||||
} else if (url.includes('/register')) {
|
||||
userInfo = `[尝试注册: ${req.body.username || '未知'}]`;
|
||||
username = req.body.username;
|
||||
} else if (url.includes('/send-code')) {
|
||||
userInfo = `[请求验证码: ${req.body.phoneNumber || '未知'}]`;
|
||||
username = req.body.phoneNumber;
|
||||
} else if (url.includes('/reset-password')) {
|
||||
userInfo = `[重置密码: ${req.body.phoneNumber || '未知'}]`;
|
||||
username = req.body.phoneNumber;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取操作说明
|
||||
const description = getActionDescription(method, url);
|
||||
|
||||
// 构建日志消息
|
||||
const logMessage = `[AUDIT] IP:${clientIp} | User:${userInfo} | ${method} ${safeUrl} | Action:${description || 'Unknown'} | Status:${status} | Time:${duration}ms`;
|
||||
|
||||
// 根据状态码记录不同级别的日志
|
||||
if (status >= 500) {
|
||||
logger.error(logMessage);
|
||||
} else if (status >= 400) {
|
||||
logger.warn(logMessage);
|
||||
} else {
|
||||
logger.info(logMessage);
|
||||
}
|
||||
|
||||
// 写入数据库
|
||||
try {
|
||||
await db.query(
|
||||
'INSERT INTO audit_logs (user_id, username, real_name, ip_address, method, url, description, status, duration) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
|
||||
[userId, username, realName, clientIp, method, safeUrl, description, status, duration]
|
||||
);
|
||||
} catch (err) {
|
||||
// 数据库写入失败不应影响主流程,仅记录错误
|
||||
console.error('审计日志写入数据库失败:', err);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = auditLogger;
|
||||
@@ -0,0 +1,74 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
require('dotenv').config();
|
||||
|
||||
const verifyToken = async (req, res, next) => {
|
||||
// 优先从 Header 获取,如果没有则从 Query String 获取(用于下载接口)
|
||||
const token = req.headers['authorization']?.split(' ')[1] || req.query.token;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: '未提供认证令牌' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// 单点登录检查:比对 Token 中的版本号与数据库中的版本号
|
||||
// 如果 Token 中没有 version (旧 Token),视为 0
|
||||
const tokenVersion = decoded.token_version || 0;
|
||||
|
||||
const result = await db.query('SELECT token_version FROM users WHERE id = $1', [decoded.id]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ message: '用户不存在' });
|
||||
}
|
||||
|
||||
const dbVersion = result.rows[0].token_version || 0;
|
||||
|
||||
if (tokenVersion !== dbVersion) {
|
||||
return res.status(401).json({ message: '您的账号已在其他设备登录,请重新登录' });
|
||||
}
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ message: '登录已过期,请重新登录' });
|
||||
}
|
||||
return res.status(401).json({ message: '无效的令牌' });
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmin = (req, res, next) => {
|
||||
if (req.user && (req.user.role === 'admin' || req.user.role === 'super_admin')) {
|
||||
next();
|
||||
} else {
|
||||
return res.status(403).json({ message: '需要管理员权限' });
|
||||
}
|
||||
};
|
||||
|
||||
const isSuperAdmin = (req, res, next) => {
|
||||
if (req.user && req.user.role === 'super_admin') {
|
||||
next();
|
||||
} else {
|
||||
return res.status(403).json({ message: '需要超级管理员权限' });
|
||||
}
|
||||
};
|
||||
|
||||
const isMaintainer = (req, res, next) => {
|
||||
if (req.user && req.user.role === 'maintainer') {
|
||||
next();
|
||||
} else {
|
||||
return res.status(403).json({ message: '需要系统维护员权限' });
|
||||
}
|
||||
};
|
||||
|
||||
// 系统管理员权限:超级管理员 OR 系统维护员
|
||||
const isSystemAdmin = (req, res, next) => {
|
||||
if (req.user && (req.user.role === 'super_admin' || req.user.role === 'maintainer')) {
|
||||
next();
|
||||
} else {
|
||||
return res.status(403).json({ message: '需要系统管理权限' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { verifyToken, isAdmin, isSuperAdmin, isMaintainer, isSystemAdmin };
|
||||
Reference in New Issue
Block a user