2515 lines
66 KiB
Markdown
2515 lines
66 KiB
Markdown
|
|
# 解班客安全和权限管理文档
|
|||
|
|
|
|||
|
|
## 📋 概述
|
|||
|
|
|
|||
|
|
本文档详细描述解班客项目的安全架构、权限管理体系、安全防护措施和安全最佳实践。通过多层次的安全防护,确保系统和用户数据的安全性。
|
|||
|
|
|
|||
|
|
## 🎯 安全目标
|
|||
|
|
|
|||
|
|
### 核心安全原则
|
|||
|
|
- **最小权限原则**: 用户和系统组件仅获得完成任务所需的最小权限
|
|||
|
|
- **深度防御**: 多层安全防护,避免单点失效
|
|||
|
|
- **零信任架构**: 不信任任何内部或外部实体,持续验证
|
|||
|
|
- **数据保护**: 全生命周期数据安全保护
|
|||
|
|
- **合规性**: 符合相关法律法规和行业标准
|
|||
|
|
|
|||
|
|
### 安全目标
|
|||
|
|
- **身份认证**: 确保用户身份的真实性和唯一性
|
|||
|
|
- **访问控制**: 基于角色和权限的精细化访问控制
|
|||
|
|
- **数据安全**: 敏感数据加密存储和传输
|
|||
|
|
- **系统安全**: 防范各类网络攻击和安全威胁
|
|||
|
|
- **审计追踪**: 完整的操作日志和安全审计
|
|||
|
|
|
|||
|
|
## 🏗️ 安全架构
|
|||
|
|
|
|||
|
|
### 整体安全架构
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TB
|
|||
|
|
subgraph "外部防护层"
|
|||
|
|
A[CDN/WAF] --> B[负载均衡器]
|
|||
|
|
B --> C[反向代理]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph "应用层安全"
|
|||
|
|
C --> D[API网关]
|
|||
|
|
D --> E[身份认证]
|
|||
|
|
E --> F[权限控制]
|
|||
|
|
F --> G[业务逻辑]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph "数据层安全"
|
|||
|
|
G --> H[数据加密]
|
|||
|
|
H --> I[数据库]
|
|||
|
|
I --> J[备份系统]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
subgraph "监控层"
|
|||
|
|
K[安全监控] --> L[日志分析]
|
|||
|
|
L --> M[告警系统]
|
|||
|
|
M --> N[事件响应]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
style A fill:#ff9999
|
|||
|
|
style E fill:#99ccff
|
|||
|
|
style H fill:#99ff99
|
|||
|
|
style K fill:#ffcc99
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 安全分层
|
|||
|
|
|
|||
|
|
#### 1. 网络安全层
|
|||
|
|
- **防火墙配置**: 端口访问控制和流量过滤
|
|||
|
|
- **DDoS防护**: 分布式拒绝服务攻击防护
|
|||
|
|
- **SSL/TLS加密**: 数据传输加密
|
|||
|
|
- **VPN访问**: 管理员远程安全访问
|
|||
|
|
|
|||
|
|
#### 2. 应用安全层
|
|||
|
|
- **身份认证**: JWT令牌和多因素认证
|
|||
|
|
- **权限控制**: RBAC基于角色的访问控制
|
|||
|
|
- **输入验证**: 防止注入攻击
|
|||
|
|
- **会话管理**: 安全的会话处理
|
|||
|
|
|
|||
|
|
#### 3. 数据安全层
|
|||
|
|
- **数据加密**: 敏感数据加密存储
|
|||
|
|
- **数据脱敏**: 测试环境数据脱敏
|
|||
|
|
- **备份安全**: 加密备份和异地存储
|
|||
|
|
- **数据销毁**: 安全的数据删除
|
|||
|
|
|
|||
|
|
## 🔐 身份认证系统
|
|||
|
|
|
|||
|
|
### JWT认证机制
|
|||
|
|
|
|||
|
|
#### Token结构
|
|||
|
|
```javascript
|
|||
|
|
// JWT Token结构
|
|||
|
|
{
|
|||
|
|
"header": {
|
|||
|
|
"alg": "HS256",
|
|||
|
|
"typ": "JWT"
|
|||
|
|
},
|
|||
|
|
"payload": {
|
|||
|
|
"user_id": 12345,
|
|||
|
|
"username": "user@example.com",
|
|||
|
|
"role": "user",
|
|||
|
|
"permissions": ["read:animals", "create:adoption"],
|
|||
|
|
"iat": 1640995200,
|
|||
|
|
"exp": 1641081600,
|
|||
|
|
"jti": "unique-token-id"
|
|||
|
|
},
|
|||
|
|
"signature": "encrypted-signature"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 认证流程
|
|||
|
|
```javascript
|
|||
|
|
// 用户登录认证
|
|||
|
|
async function authenticateUser(credentials) {
|
|||
|
|
try {
|
|||
|
|
// 1. 验证用户凭据
|
|||
|
|
const user = await validateCredentials(credentials)
|
|||
|
|
if (!user) {
|
|||
|
|
throw new Error('用户名或密码错误')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 检查账户状态
|
|||
|
|
if (user.status !== 'active') {
|
|||
|
|
throw new Error('账户已被禁用')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 记录登录日志
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'LOGIN_SUCCESS',
|
|||
|
|
user_id: user.id,
|
|||
|
|
ip_address: credentials.ip,
|
|||
|
|
user_agent: credentials.userAgent,
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 4. 生成访问令牌
|
|||
|
|
const accessToken = generateAccessToken(user)
|
|||
|
|
const refreshToken = generateRefreshToken(user)
|
|||
|
|
|
|||
|
|
// 5. 存储刷新令牌
|
|||
|
|
await storeRefreshToken(user.id, refreshToken)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
access_token: accessToken,
|
|||
|
|
refresh_token: refreshToken,
|
|||
|
|
expires_in: 3600,
|
|||
|
|
user: {
|
|||
|
|
id: user.id,
|
|||
|
|
username: user.username,
|
|||
|
|
role: user.role,
|
|||
|
|
permissions: user.permissions
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
// 记录失败日志
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'LOGIN_FAILED',
|
|||
|
|
username: credentials.username,
|
|||
|
|
ip_address: credentials.ip,
|
|||
|
|
error: error.message,
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成访问令牌
|
|||
|
|
function generateAccessToken(user) {
|
|||
|
|
const payload = {
|
|||
|
|
user_id: user.id,
|
|||
|
|
username: user.username,
|
|||
|
|
role: user.role,
|
|||
|
|
permissions: user.permissions,
|
|||
|
|
iat: Math.floor(Date.now() / 1000),
|
|||
|
|
exp: Math.floor(Date.now() / 1000) + 3600, // 1小时过期
|
|||
|
|
jti: generateUniqueId()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return jwt.sign(payload, process.env.JWT_SECRET, {
|
|||
|
|
algorithm: 'HS256'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 令牌验证中间件
|
|||
|
|
function verifyToken(req, res, next) {
|
|||
|
|
try {
|
|||
|
|
const token = extractTokenFromHeader(req.headers.authorization)
|
|||
|
|
if (!token) {
|
|||
|
|
return res.status(401).json({ error: '缺少访问令牌' })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证令牌
|
|||
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
|||
|
|
|
|||
|
|
// 检查令牌是否在黑名单中
|
|||
|
|
if (await isTokenBlacklisted(decoded.jti)) {
|
|||
|
|
return res.status(401).json({ error: '令牌已失效' })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 将用户信息添加到请求对象
|
|||
|
|
req.user = {
|
|||
|
|
id: decoded.user_id,
|
|||
|
|
username: decoded.username,
|
|||
|
|
role: decoded.role,
|
|||
|
|
permissions: decoded.permissions
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
next()
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error.name === 'TokenExpiredError') {
|
|||
|
|
return res.status(401).json({ error: '令牌已过期' })
|
|||
|
|
} else if (error.name === 'JsonWebTokenError') {
|
|||
|
|
return res.status(401).json({ error: '无效的令牌' })
|
|||
|
|
}
|
|||
|
|
return res.status(500).json({ error: '令牌验证失败' })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 多因素认证 (MFA)
|
|||
|
|
|
|||
|
|
#### 短信验证码
|
|||
|
|
```javascript
|
|||
|
|
// 发送短信验证码
|
|||
|
|
async function sendSMSCode(phoneNumber, purpose) {
|
|||
|
|
try {
|
|||
|
|
// 1. 生成6位数字验证码
|
|||
|
|
const code = Math.floor(100000 + Math.random() * 900000).toString()
|
|||
|
|
|
|||
|
|
// 2. 设置过期时间(5分钟)
|
|||
|
|
const expiresAt = new Date(Date.now() + 5 * 60 * 1000)
|
|||
|
|
|
|||
|
|
// 3. 存储验证码
|
|||
|
|
await redis.setex(
|
|||
|
|
`sms_code:${phoneNumber}:${purpose}`,
|
|||
|
|
300, // 5分钟过期
|
|||
|
|
JSON.stringify({
|
|||
|
|
code: await bcrypt.hash(code, 10), // 加密存储
|
|||
|
|
attempts: 0,
|
|||
|
|
created_at: new Date()
|
|||
|
|
})
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 4. 发送短信
|
|||
|
|
await smsService.send({
|
|||
|
|
to: phoneNumber,
|
|||
|
|
message: `【解班客】您的验证码是:${code},5分钟内有效,请勿泄露。`
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 5. 记录发送日志
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'SMS_CODE_SENT',
|
|||
|
|
phone_number: phoneNumber,
|
|||
|
|
purpose: purpose,
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return { success: true, message: '验证码已发送' }
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('发送短信验证码失败:', error)
|
|||
|
|
throw new Error('发送验证码失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证短信验证码
|
|||
|
|
async function verifySMSCode(phoneNumber, code, purpose) {
|
|||
|
|
try {
|
|||
|
|
const key = `sms_code:${phoneNumber}:${purpose}`
|
|||
|
|
const storedData = await redis.get(key)
|
|||
|
|
|
|||
|
|
if (!storedData) {
|
|||
|
|
throw new Error('验证码已过期或不存在')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { code: hashedCode, attempts } = JSON.parse(storedData)
|
|||
|
|
|
|||
|
|
// 检查尝试次数
|
|||
|
|
if (attempts >= 3) {
|
|||
|
|
await redis.del(key)
|
|||
|
|
throw new Error('验证码尝试次数过多,请重新获取')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证验证码
|
|||
|
|
const isValid = await bcrypt.compare(code, hashedCode)
|
|||
|
|
|
|||
|
|
if (!isValid) {
|
|||
|
|
// 增加尝试次数
|
|||
|
|
await redis.setex(
|
|||
|
|
key,
|
|||
|
|
await redis.ttl(key),
|
|||
|
|
JSON.stringify({
|
|||
|
|
code: hashedCode,
|
|||
|
|
attempts: attempts + 1,
|
|||
|
|
created_at: new Date()
|
|||
|
|
})
|
|||
|
|
)
|
|||
|
|
throw new Error('验证码错误')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证成功,删除验证码
|
|||
|
|
await redis.del(key)
|
|||
|
|
|
|||
|
|
// 记录验证日志
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'SMS_CODE_VERIFIED',
|
|||
|
|
phone_number: phoneNumber,
|
|||
|
|
purpose: purpose,
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return { success: true, message: '验证码验证成功' }
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('验证短信验证码失败:', error)
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### TOTP认证器
|
|||
|
|
```javascript
|
|||
|
|
// TOTP (Time-based One-Time Password) 实现
|
|||
|
|
const speakeasy = require('speakeasy')
|
|||
|
|
const QRCode = require('qrcode')
|
|||
|
|
|
|||
|
|
// 生成TOTP密钥
|
|||
|
|
async function generateTOTPSecret(userId) {
|
|||
|
|
const secret = speakeasy.generateSecret({
|
|||
|
|
name: `解班客 (${userId})`,
|
|||
|
|
issuer: '解班客',
|
|||
|
|
length: 32
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 存储密钥到数据库
|
|||
|
|
await UserSecurity.create({
|
|||
|
|
user_id: userId,
|
|||
|
|
totp_secret: encrypt(secret.base32),
|
|||
|
|
totp_enabled: false,
|
|||
|
|
created_at: new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 生成二维码
|
|||
|
|
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
secret: secret.base32,
|
|||
|
|
qr_code: qrCodeUrl,
|
|||
|
|
manual_entry_key: secret.base32
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证TOTP令牌
|
|||
|
|
async function verifyTOTPToken(userId, token) {
|
|||
|
|
try {
|
|||
|
|
const userSecurity = await UserSecurity.findOne({
|
|||
|
|
where: { user_id: userId, totp_enabled: true }
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (!userSecurity) {
|
|||
|
|
throw new Error('TOTP未启用')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const secret = decrypt(userSecurity.totp_secret)
|
|||
|
|
|
|||
|
|
const verified = speakeasy.totp.verify({
|
|||
|
|
secret: secret,
|
|||
|
|
encoding: 'base32',
|
|||
|
|
token: token,
|
|||
|
|
window: 2 // 允许时间窗口偏差
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (!verified) {
|
|||
|
|
// 记录失败尝试
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'TOTP_VERIFICATION_FAILED',
|
|||
|
|
user_id: userId,
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
throw new Error('TOTP令牌无效')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 记录成功验证
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'TOTP_VERIFICATION_SUCCESS',
|
|||
|
|
user_id: userId,
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return { success: true }
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('TOTP验证失败:', error)
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 👥 权限管理系统
|
|||
|
|
|
|||
|
|
### RBAC权限模型
|
|||
|
|
|
|||
|
|
#### 权限数据模型
|
|||
|
|
```sql
|
|||
|
|
-- 角色表
|
|||
|
|
CREATE TABLE roles (
|
|||
|
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
|||
|
|
name VARCHAR(50) NOT NULL UNIQUE,
|
|||
|
|
display_name VARCHAR(100) NOT NULL,
|
|||
|
|
description TEXT,
|
|||
|
|
is_system BOOLEAN DEFAULT FALSE,
|
|||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 权限表
|
|||
|
|
CREATE TABLE permissions (
|
|||
|
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
|||
|
|
name VARCHAR(100) NOT NULL UNIQUE,
|
|||
|
|
display_name VARCHAR(100) NOT NULL,
|
|||
|
|
description TEXT,
|
|||
|
|
resource VARCHAR(50) NOT NULL,
|
|||
|
|
action VARCHAR(50) NOT NULL,
|
|||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 角色权限关联表
|
|||
|
|
CREATE TABLE role_permissions (
|
|||
|
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
|||
|
|
role_id INT NOT NULL,
|
|||
|
|
permission_id INT NOT NULL,
|
|||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
|||
|
|
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE,
|
|||
|
|
UNIQUE KEY unique_role_permission (role_id, permission_id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 用户角色关联表
|
|||
|
|
CREATE TABLE user_roles (
|
|||
|
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
|||
|
|
user_id INT NOT NULL,
|
|||
|
|
role_id INT NOT NULL,
|
|||
|
|
assigned_by INT,
|
|||
|
|
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
expires_at TIMESTAMP NULL,
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|||
|
|
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
|||
|
|
FOREIGN KEY (assigned_by) REFERENCES users(id),
|
|||
|
|
UNIQUE KEY unique_user_role (user_id, role_id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 用户直接权限表(特殊权限)
|
|||
|
|
CREATE TABLE user_permissions (
|
|||
|
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
|||
|
|
user_id INT NOT NULL,
|
|||
|
|
permission_id INT NOT NULL,
|
|||
|
|
granted_by INT,
|
|||
|
|
granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
expires_at TIMESTAMP NULL,
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|||
|
|
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE,
|
|||
|
|
FOREIGN KEY (granted_by) REFERENCES users(id),
|
|||
|
|
UNIQUE KEY unique_user_permission (user_id, permission_id)
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 权限检查中间件
|
|||
|
|
```javascript
|
|||
|
|
// 权限检查中间件
|
|||
|
|
function requirePermission(resource, action) {
|
|||
|
|
return async (req, res, next) => {
|
|||
|
|
try {
|
|||
|
|
const userId = req.user.id
|
|||
|
|
const hasPermission = await checkUserPermission(userId, resource, action)
|
|||
|
|
|
|||
|
|
if (!hasPermission) {
|
|||
|
|
// 记录权限拒绝日志
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'PERMISSION_DENIED',
|
|||
|
|
user_id: userId,
|
|||
|
|
resource: resource,
|
|||
|
|
action: action,
|
|||
|
|
ip_address: req.ip,
|
|||
|
|
user_agent: req.get('User-Agent'),
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return res.status(403).json({
|
|||
|
|
error: '权限不足',
|
|||
|
|
message: `您没有执行 ${action} 操作 ${resource} 的权限`
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
next()
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('权限检查失败:', error)
|
|||
|
|
return res.status(500).json({ error: '权限检查失败' })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查用户权限
|
|||
|
|
async function checkUserPermission(userId, resource, action) {
|
|||
|
|
try {
|
|||
|
|
// 1. 检查用户直接权限
|
|||
|
|
const directPermission = await UserPermission.findOne({
|
|||
|
|
include: [{
|
|||
|
|
model: Permission,
|
|||
|
|
where: { resource, action }
|
|||
|
|
}],
|
|||
|
|
where: {
|
|||
|
|
user_id: userId,
|
|||
|
|
[Op.or]: [
|
|||
|
|
{ expires_at: null },
|
|||
|
|
{ expires_at: { [Op.gt]: new Date() } }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (directPermission) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 检查角色权限
|
|||
|
|
const rolePermissions = await UserRole.findAll({
|
|||
|
|
include: [{
|
|||
|
|
model: Role,
|
|||
|
|
include: [{
|
|||
|
|
model: Permission,
|
|||
|
|
where: { resource, action },
|
|||
|
|
through: { attributes: [] }
|
|||
|
|
}]
|
|||
|
|
}],
|
|||
|
|
where: {
|
|||
|
|
user_id: userId,
|
|||
|
|
[Op.or]: [
|
|||
|
|
{ expires_at: null },
|
|||
|
|
{ expires_at: { [Op.gt]: new Date() } }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return rolePermissions.length > 0
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('权限检查错误:', error)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取用户所有权限
|
|||
|
|
async function getUserPermissions(userId) {
|
|||
|
|
try {
|
|||
|
|
const permissions = new Set()
|
|||
|
|
|
|||
|
|
// 1. 获取直接权限
|
|||
|
|
const directPermissions = await UserPermission.findAll({
|
|||
|
|
include: [Permission],
|
|||
|
|
where: {
|
|||
|
|
user_id: userId,
|
|||
|
|
[Op.or]: [
|
|||
|
|
{ expires_at: null },
|
|||
|
|
{ expires_at: { [Op.gt]: new Date() } }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
directPermissions.forEach(up => {
|
|||
|
|
permissions.add(`${up.Permission.resource}:${up.Permission.action}`)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 2. 获取角色权限
|
|||
|
|
const rolePermissions = await UserRole.findAll({
|
|||
|
|
include: [{
|
|||
|
|
model: Role,
|
|||
|
|
include: [{
|
|||
|
|
model: Permission,
|
|||
|
|
through: { attributes: [] }
|
|||
|
|
}]
|
|||
|
|
}],
|
|||
|
|
where: {
|
|||
|
|
user_id: userId,
|
|||
|
|
[Op.or]: [
|
|||
|
|
{ expires_at: null },
|
|||
|
|
{ expires_at: { [Op.gt]: new Date() } }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
rolePermissions.forEach(ur => {
|
|||
|
|
ur.Role.Permissions.forEach(permission => {
|
|||
|
|
permissions.add(`${permission.resource}:${permission.action}`)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return Array.from(permissions)
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('获取用户权限失败:', error)
|
|||
|
|
return []
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 角色管理
|
|||
|
|
|
|||
|
|
#### 预定义角色
|
|||
|
|
```javascript
|
|||
|
|
// 系统预定义角色
|
|||
|
|
const SYSTEM_ROLES = {
|
|||
|
|
SUPER_ADMIN: {
|
|||
|
|
name: 'super_admin',
|
|||
|
|
display_name: '超级管理员',
|
|||
|
|
description: '拥有系统所有权限',
|
|||
|
|
permissions: ['*:*'] // 通配符表示所有权限
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
ADMIN: {
|
|||
|
|
name: 'admin',
|
|||
|
|
display_name: '管理员',
|
|||
|
|
description: '系统管理员,可管理用户和内容',
|
|||
|
|
permissions: [
|
|||
|
|
'users:read', 'users:create', 'users:update', 'users:delete',
|
|||
|
|
'animals:read', 'animals:create', 'animals:update', 'animals:delete',
|
|||
|
|
'adoptions:read', 'adoptions:update', 'adoptions:approve',
|
|||
|
|
'content:read', 'content:create', 'content:update', 'content:delete',
|
|||
|
|
'reports:read', 'system:monitor'
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
MODERATOR: {
|
|||
|
|
name: 'moderator',
|
|||
|
|
display_name: '内容审核员',
|
|||
|
|
description: '负责内容审核和动物信息管理',
|
|||
|
|
permissions: [
|
|||
|
|
'animals:read', 'animals:create', 'animals:update',
|
|||
|
|
'adoptions:read', 'adoptions:update',
|
|||
|
|
'content:read', 'content:update',
|
|||
|
|
'reports:read'
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
USER: {
|
|||
|
|
name: 'user',
|
|||
|
|
display_name: '普通用户',
|
|||
|
|
description: '普通用户,可浏览和申请认领',
|
|||
|
|
permissions: [
|
|||
|
|
'animals:read',
|
|||
|
|
'adoptions:create', 'adoptions:read_own',
|
|||
|
|
'profile:read', 'profile:update'
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
VOLUNTEER: {
|
|||
|
|
name: 'volunteer',
|
|||
|
|
display_name: '志愿者',
|
|||
|
|
description: '志愿者,可协助动物信息维护',
|
|||
|
|
permissions: [
|
|||
|
|
'animals:read', 'animals:update',
|
|||
|
|
'adoptions:read',
|
|||
|
|
'content:read',
|
|||
|
|
'profile:read', 'profile:update'
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 初始化系统角色
|
|||
|
|
async function initializeSystemRoles() {
|
|||
|
|
try {
|
|||
|
|
for (const [key, roleData] of Object.entries(SYSTEM_ROLES)) {
|
|||
|
|
// 创建或更新角色
|
|||
|
|
const [role] = await Role.findOrCreate({
|
|||
|
|
where: { name: roleData.name },
|
|||
|
|
defaults: {
|
|||
|
|
display_name: roleData.display_name,
|
|||
|
|
description: roleData.description,
|
|||
|
|
is_system: true
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 处理权限
|
|||
|
|
if (roleData.permissions.includes('*:*')) {
|
|||
|
|
// 超级管理员拥有所有权限
|
|||
|
|
const allPermissions = await Permission.findAll()
|
|||
|
|
await role.setPermissions(allPermissions)
|
|||
|
|
} else {
|
|||
|
|
// 设置指定权限
|
|||
|
|
const permissions = await Permission.findAll({
|
|||
|
|
where: {
|
|||
|
|
name: { [Op.in]: roleData.permissions }
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
await role.setPermissions(permissions)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.info('系统角色初始化完成')
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('系统角色初始化失败:', error)
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 动态权限管理
|
|||
|
|
```javascript
|
|||
|
|
// 权限管理服务
|
|||
|
|
class PermissionService {
|
|||
|
|
// 创建权限
|
|||
|
|
static async createPermission(permissionData) {
|
|||
|
|
try {
|
|||
|
|
const permission = await Permission.create({
|
|||
|
|
name: `${permissionData.resource}:${permissionData.action}`,
|
|||
|
|
display_name: permissionData.display_name,
|
|||
|
|
description: permissionData.description,
|
|||
|
|
resource: permissionData.resource,
|
|||
|
|
action: permissionData.action
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
logger.info(`权限创建成功: ${permission.name}`)
|
|||
|
|
return permission
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('权限创建失败:', error)
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 分配角色给用户
|
|||
|
|
static async assignRoleToUser(userId, roleId, assignedBy, expiresAt = null) {
|
|||
|
|
try {
|
|||
|
|
const userRole = await UserRole.create({
|
|||
|
|
user_id: userId,
|
|||
|
|
role_id: roleId,
|
|||
|
|
assigned_by: assignedBy,
|
|||
|
|
expires_at: expiresAt
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 记录权限变更日志
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'ROLE_ASSIGNED',
|
|||
|
|
user_id: userId,
|
|||
|
|
role_id: roleId,
|
|||
|
|
assigned_by: assignedBy,
|
|||
|
|
expires_at: expiresAt,
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 清除用户权限缓存
|
|||
|
|
await this.clearUserPermissionCache(userId)
|
|||
|
|
|
|||
|
|
return userRole
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('角色分配失败:', error)
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 撤销用户角色
|
|||
|
|
static async revokeRoleFromUser(userId, roleId, revokedBy) {
|
|||
|
|
try {
|
|||
|
|
const result = await UserRole.destroy({
|
|||
|
|
where: { user_id: userId, role_id: roleId }
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (result > 0) {
|
|||
|
|
// 记录权限变更日志
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'ROLE_REVOKED',
|
|||
|
|
user_id: userId,
|
|||
|
|
role_id: roleId,
|
|||
|
|
revoked_by: revokedBy,
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 清除用户权限缓存
|
|||
|
|
await this.clearUserPermissionCache(userId)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result > 0
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('角色撤销失败:', error)
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 授予用户直接权限
|
|||
|
|
static async grantPermissionToUser(userId, permissionId, grantedBy, expiresAt = null) {
|
|||
|
|
try {
|
|||
|
|
const userPermission = await UserPermission.create({
|
|||
|
|
user_id: userId,
|
|||
|
|
permission_id: permissionId,
|
|||
|
|
granted_by: grantedBy,
|
|||
|
|
expires_at: expiresAt
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 记录权限变更日志
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'PERMISSION_GRANTED',
|
|||
|
|
user_id: userId,
|
|||
|
|
permission_id: permissionId,
|
|||
|
|
granted_by: grantedBy,
|
|||
|
|
expires_at: expiresAt,
|
|||
|
|
timestamp: new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 清除用户权限缓存
|
|||
|
|
await this.clearUserPermissionCache(userId)
|
|||
|
|
|
|||
|
|
return userPermission
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('权限授予失败:', error)
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除用户权限缓存
|
|||
|
|
static async clearUserPermissionCache(userId) {
|
|||
|
|
try {
|
|||
|
|
await redis.del(`user_permissions:${userId}`)
|
|||
|
|
logger.info(`用户权限缓存已清除: ${userId}`)
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('清除权限缓存失败:', error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取用户权限(带缓存)
|
|||
|
|
static async getUserPermissionsWithCache(userId) {
|
|||
|
|
try {
|
|||
|
|
const cacheKey = `user_permissions:${userId}`
|
|||
|
|
let permissions = await redis.get(cacheKey)
|
|||
|
|
|
|||
|
|
if (permissions) {
|
|||
|
|
return JSON.parse(permissions)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
permissions = await getUserPermissions(userId)
|
|||
|
|
|
|||
|
|
// 缓存权限信息(5分钟)
|
|||
|
|
await redis.setex(cacheKey, 300, JSON.stringify(permissions))
|
|||
|
|
|
|||
|
|
return permissions
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('获取用户权限失败:', error)
|
|||
|
|
return []
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 🛡️ 安全防护措施
|
|||
|
|
|
|||
|
|
### 输入验证和过滤
|
|||
|
|
|
|||
|
|
#### SQL注入防护
|
|||
|
|
```javascript
|
|||
|
|
// 使用参数化查询防止SQL注入
|
|||
|
|
const { QueryTypes } = require('sequelize')
|
|||
|
|
|
|||
|
|
// 错误示例 - 容易受到SQL注入攻击
|
|||
|
|
async function searchAnimalsUnsafe(keyword) {
|
|||
|
|
const query = `SELECT * FROM animals WHERE name LIKE '%${keyword}%'`
|
|||
|
|
return await sequelize.query(query, { type: QueryTypes.SELECT })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 正确示例 - 使用参数化查询
|
|||
|
|
async function searchAnimalsSafe(keyword) {
|
|||
|
|
const query = `
|
|||
|
|
SELECT * FROM animals
|
|||
|
|
WHERE name LIKE :keyword
|
|||
|
|
OR description LIKE :keyword
|
|||
|
|
`
|
|||
|
|
return await sequelize.query(query, {
|
|||
|
|
replacements: { keyword: `%${keyword}%` },
|
|||
|
|
type: QueryTypes.SELECT
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 使用ORM的安全查询
|
|||
|
|
async function searchAnimalsORM(keyword) {
|
|||
|
|
return await Animal.findAll({
|
|||
|
|
where: {
|
|||
|
|
[Op.or]: [
|
|||
|
|
{ name: { [Op.like]: `%${keyword}%` } },
|
|||
|
|
{ description: { [Op.like]: `%${keyword}%` } }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### XSS防护
|
|||
|
|
```javascript
|
|||
|
|
const DOMPurify = require('isomorphic-dompurify')
|
|||
|
|
const validator = require('validator')
|
|||
|
|
|
|||
|
|
// XSS过滤中间件
|
|||
|
|
function xssProtection(req, res, next) {
|
|||
|
|
// 递归清理对象中的所有字符串
|
|||
|
|
function sanitizeObject(obj) {
|
|||
|
|
if (typeof obj === 'string') {
|
|||
|
|
return DOMPurify.sanitize(obj)
|
|||
|
|
} else if (Array.isArray(obj)) {
|
|||
|
|
return obj.map(sanitizeObject)
|
|||
|
|
} else if (obj && typeof obj === 'object') {
|
|||
|
|
const sanitized = {}
|
|||
|
|
for (const [key, value] of Object.entries(obj)) {
|
|||
|
|
sanitized[key] = sanitizeObject(value)
|
|||
|
|
}
|
|||
|
|
return sanitized
|
|||
|
|
}
|
|||
|
|
return obj
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清理请求体
|
|||
|
|
if (req.body) {
|
|||
|
|
req.body = sanitizeObject(req.body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清理查询参数
|
|||
|
|
if (req.query) {
|
|||
|
|
req.query = sanitizeObject(req.query)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
next()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 输入验证函数
|
|||
|
|
function validateInput(data, rules) {
|
|||
|
|
const errors = []
|
|||
|
|
|
|||
|
|
for (const [field, rule] of Object.entries(rules)) {
|
|||
|
|
const value = data[field]
|
|||
|
|
|
|||
|
|
// 必填验证
|
|||
|
|
if (rule.required && (!value || value.trim() === '')) {
|
|||
|
|
errors.push(`${field} 是必填字段`)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (value) {
|
|||
|
|
// 长度验证
|
|||
|
|
if (rule.minLength && value.length < rule.minLength) {
|
|||
|
|
errors.push(`${field} 长度不能少于 ${rule.minLength} 个字符`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (rule.maxLength && value.length > rule.maxLength) {
|
|||
|
|
errors.push(`${field} 长度不能超过 ${rule.maxLength} 个字符`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 格式验证
|
|||
|
|
if (rule.type === 'email' && !validator.isEmail(value)) {
|
|||
|
|
errors.push(`${field} 格式不正确`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (rule.type === 'phone' && !validator.isMobilePhone(value, 'zh-CN')) {
|
|||
|
|
errors.push(`${field} 手机号格式不正确`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (rule.type === 'url' && !validator.isURL(value)) {
|
|||
|
|
errors.push(`${field} URL格式不正确`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 自定义正则验证
|
|||
|
|
if (rule.pattern && !rule.pattern.test(value)) {
|
|||
|
|
errors.push(`${field} 格式不符合要求`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 危险字符检测
|
|||
|
|
if (containsDangerousChars(value)) {
|
|||
|
|
errors.push(`${field} 包含非法字符`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return errors
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检测危险字符
|
|||
|
|
function containsDangerousChars(input) {
|
|||
|
|
const dangerousPatterns = [
|
|||
|
|
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
|||
|
|
/javascript:/gi,
|
|||
|
|
/on\w+\s*=/gi,
|
|||
|
|
/eval\s*\(/gi,
|
|||
|
|
/expression\s*\(/gi
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
return dangerousPatterns.some(pattern => pattern.test(input))
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### CSRF防护
|
|||
|
|
```javascript
|
|||
|
|
const csrf = require('csurf')
|
|||
|
|
const cookieParser = require('cookie-parser')
|
|||
|
|
|
|||
|
|
// CSRF保护中间件配置
|
|||
|
|
const csrfProtection = csrf({
|
|||
|
|
cookie: {
|
|||
|
|
httpOnly: true,
|
|||
|
|
secure: process.env.NODE_ENV === 'production',
|
|||
|
|
sameSite: 'strict'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 为前端提供CSRF令牌
|
|||
|
|
app.get('/api/csrf-token', csrfProtection, (req, res) => {
|
|||
|
|
res.json({ csrfToken: req.csrfToken() })
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 应用CSRF保护到需要的路由
|
|||
|
|
app.use('/api/admin', csrfProtection)
|
|||
|
|
app.use('/api/user/profile', csrfProtection)
|
|||
|
|
|
|||
|
|
// 自定义CSRF错误处理
|
|||
|
|
app.use((err, req, res, next) => {
|
|||
|
|
if (err.code === 'EBADCSRFTOKEN') {
|
|||
|
|
return res.status(403).json({
|
|||
|
|
error: 'CSRF令牌无效',
|
|||
|
|
message: '请刷新页面后重试'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
next(err)
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 速率限制
|
|||
|
|
|
|||
|
|
#### API速率限制
|
|||
|
|
```javascript
|
|||
|
|
const rateLimit = require('express-rate-limit')
|
|||
|
|
const RedisStore = require('rate-limit-redis')
|
|||
|
|
const redis = require('redis')
|
|||
|
|
|
|||
|
|
// Redis客户端
|
|||
|
|
const redisClient = redis.createClient({
|
|||
|
|
host: process.env.REDIS_HOST,
|
|||
|
|
port: process.env.REDIS_PORT
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 通用速率限制
|
|||
|
|
const generalLimiter = rateLimit({
|
|||
|
|
store: new RedisStore({
|
|||
|
|
client: redisClient,
|
|||
|
|
prefix: 'rl:general:'
|
|||
|
|
}),
|
|||
|
|
windowMs: 15 * 60 * 1000, // 15分钟
|
|||
|
|
max: 100, // 每个IP最多100个请求
|
|||
|
|
message: {
|
|||
|
|
error: '请求过于频繁',
|
|||
|
|
message: '请稍后再试'
|
|||
|
|
},
|
|||
|
|
standardHeaders: true,
|
|||
|
|
legacyHeaders: false
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 登录速率限制
|
|||
|
|
const loginLimiter = rateLimit({
|
|||
|
|
store: new RedisStore({
|
|||
|
|
client: redisClient,
|
|||
|
|
prefix: 'rl:login:'
|
|||
|
|
}),
|
|||
|
|
windowMs: 15 * 60 * 1000, // 15分钟
|
|||
|
|
max: 5, // 每个IP最多5次登录尝试
|
|||
|
|
skipSuccessfulRequests: true,
|
|||
|
|
message: {
|
|||
|
|
error: '登录尝试过于频繁',
|
|||
|
|
message: '请15分钟后再试'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 注册速率限制
|
|||
|
|
const registerLimiter = rateLimit({
|
|||
|
|
store: new RedisStore({
|
|||
|
|
client: redisClient,
|
|||
|
|
prefix: 'rl:register:'
|
|||
|
|
}),
|
|||
|
|
windowMs: 60 * 60 * 1000, // 1小时
|
|||
|
|
max: 3, // 每个IP每小时最多3次注册
|
|||
|
|
message: {
|
|||
|
|
error: '注册过于频繁',
|
|||
|
|
message: '请1小时后再试'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 短信验证码速率限制
|
|||
|
|
const smsLimiter = rateLimit({
|
|||
|
|
store: new RedisStore({
|
|||
|
|
client: redisClient,
|
|||
|
|
prefix: 'rl:sms:'
|
|||
|
|
}),
|
|||
|
|
windowMs: 60 * 1000, // 1分钟
|
|||
|
|
max: 1, // 每分钟最多1条短信
|
|||
|
|
keyGenerator: (req) => {
|
|||
|
|
return req.body.phone_number || req.ip
|
|||
|
|
},
|
|||
|
|
message: {
|
|||
|
|
error: '短信发送过于频繁',
|
|||
|
|
message: '请1分钟后再试'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 应用速率限制
|
|||
|
|
app.use('/api', generalLimiter)
|
|||
|
|
app.use('/api/auth/login', loginLimiter)
|
|||
|
|
app.use('/api/auth/register', registerLimiter)
|
|||
|
|
app.use('/api/auth/send-sms', smsLimiter)
|
|||
|
|
|
|||
|
|
// 自定义速率限制器
|
|||
|
|
class CustomRateLimiter {
|
|||
|
|
constructor(options) {
|
|||
|
|
this.windowMs = options.windowMs
|
|||
|
|
this.max = options.max
|
|||
|
|
this.keyGenerator = options.keyGenerator || ((req) => req.ip)
|
|||
|
|
this.store = options.store || new Map()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
middleware() {
|
|||
|
|
return async (req, res, next) => {
|
|||
|
|
try {
|
|||
|
|
const key = this.keyGenerator(req)
|
|||
|
|
const now = Date.now()
|
|||
|
|
const windowStart = now - this.windowMs
|
|||
|
|
|
|||
|
|
// 获取当前窗口内的请求记录
|
|||
|
|
const requests = await this.getRequests(key, windowStart)
|
|||
|
|
|
|||
|
|
if (requests.length >= this.max) {
|
|||
|
|
return res.status(429).json({
|
|||
|
|
error: '请求过于频繁',
|
|||
|
|
retryAfter: Math.ceil((requests[0].timestamp + this.windowMs - now) / 1000)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 记录当前请求
|
|||
|
|
await this.recordRequest(key, now)
|
|||
|
|
|
|||
|
|
next()
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('速率限制检查失败:', error)
|
|||
|
|
next()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async getRequests(key, windowStart) {
|
|||
|
|
// 从Redis获取请求记录
|
|||
|
|
const data = await redis.get(`rate_limit:${key}`)
|
|||
|
|
if (!data) return []
|
|||
|
|
|
|||
|
|
const requests = JSON.parse(data)
|
|||
|
|
return requests.filter(req => req.timestamp > windowStart)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async recordRequest(key, timestamp) {
|
|||
|
|
const requests = await this.getRequests(key, 0)
|
|||
|
|
requests.push({ timestamp })
|
|||
|
|
|
|||
|
|
// 只保留窗口内的请求
|
|||
|
|
const windowStart = timestamp - this.windowMs
|
|||
|
|
const validRequests = requests.filter(req => req.timestamp > windowStart)
|
|||
|
|
|
|||
|
|
await redis.setex(
|
|||
|
|
`rate_limit:${key}`,
|
|||
|
|
Math.ceil(this.windowMs / 1000),
|
|||
|
|
JSON.stringify(validRequests)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 数据加密
|
|||
|
|
|
|||
|
|
#### 敏感数据加密
|
|||
|
|
```javascript
|
|||
|
|
const crypto = require('crypto')
|
|||
|
|
const bcrypt = require('bcrypt')
|
|||
|
|
|
|||
|
|
// 加密配置
|
|||
|
|
const ENCRYPTION_CONFIG = {
|
|||
|
|
algorithm: 'aes-256-gcm',
|
|||
|
|
keyLength: 32,
|
|||
|
|
ivLength: 16,
|
|||
|
|
tagLength: 16,
|
|||
|
|
saltRounds: 12
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成加密密钥
|
|||
|
|
function generateEncryptionKey() {
|
|||
|
|
return crypto.randomBytes(ENCRYPTION_CONFIG.keyLength)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 对称加密
|
|||
|
|
function encrypt(text, key = process.env.ENCRYPTION_KEY) {
|
|||
|
|
try {
|
|||
|
|
const iv = crypto.randomBytes(ENCRYPTION_CONFIG.ivLength)
|
|||
|
|
const cipher = crypto.createCipher(ENCRYPTION_CONFIG.algorithm, key)
|
|||
|
|
cipher.setAAD(Buffer.from('additional-data'))
|
|||
|
|
|
|||
|
|
let encrypted = cipher.update(text, 'utf8', 'hex')
|
|||
|
|
encrypted += cipher.final('hex')
|
|||
|
|
|
|||
|
|
const tag = cipher.getAuthTag()
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
encrypted,
|
|||
|
|
iv: iv.toString('hex'),
|
|||
|
|
tag: tag.toString('hex')
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('加密失败:', error)
|
|||
|
|
throw new Error('数据加密失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 对称解密
|
|||
|
|
function decrypt(encryptedData, key = process.env.ENCRYPTION_KEY) {
|
|||
|
|
try {
|
|||
|
|
const { encrypted, iv, tag } = encryptedData
|
|||
|
|
const decipher = crypto.createDecipher(ENCRYPTION_CONFIG.algorithm, key)
|
|||
|
|
|
|||
|
|
decipher.setAuthTag(Buffer.from(tag, 'hex'))
|
|||
|
|
decipher.setAAD(Buffer.from('additional-data'))
|
|||
|
|
|
|||
|
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
|||
|
|
decrypted += decipher.final('utf8')
|
|||
|
|
|
|||
|
|
return decrypted
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('解密失败:', error)
|
|||
|
|
throw new Error('数据解密失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 密码哈希
|
|||
|
|
async function hashPassword(password) {
|
|||
|
|
try {
|
|||
|
|
const salt = await bcrypt.genSalt(ENCRYPTION_CONFIG.saltRounds)
|
|||
|
|
return await bcrypt.hash(password, salt)
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('密码哈希失败:', error)
|
|||
|
|
throw new Error('密码处理失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 密码验证
|
|||
|
|
async function verifyPassword(password, hashedPassword) {
|
|||
|
|
try {
|
|||
|
|
return await bcrypt.compare(password, hashedPassword)
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('密码验证失败:', error)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 敏感字段加密模型
|
|||
|
|
class EncryptedField {
|
|||
|
|
constructor(value) {
|
|||
|
|
this.value = value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加密存储
|
|||
|
|
encrypt() {
|
|||
|
|
if (!this.value) return null
|
|||
|
|
return JSON.stringify(encrypt(this.value))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 解密读取
|
|||
|
|
static decrypt(encryptedValue) {
|
|||
|
|
if (!encryptedValue) return null
|
|||
|
|
try {
|
|||
|
|
const encryptedData = JSON.parse(encryptedValue)
|
|||
|
|
return decrypt(encryptedData)
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('字段解密失败:', error)
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 数据库模型中使用加密字段
|
|||
|
|
const User = sequelize.define('User', {
|
|||
|
|
username: DataTypes.STRING,
|
|||
|
|
email: DataTypes.STRING,
|
|||
|
|
phone: {
|
|||
|
|
type: DataTypes.TEXT,
|
|||
|
|
set(value) {
|
|||
|
|
if (value) {
|
|||
|
|
const encrypted = new EncryptedField(value)
|
|||
|
|
this.setDataValue('phone', encrypted.encrypt())
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
get() {
|
|||
|
|
const encryptedValue = this.getDataValue('phone')
|
|||
|
|
return EncryptedField.decrypt(encryptedValue)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
id_card: {
|
|||
|
|
type: DataTypes.TEXT,
|
|||
|
|
set(value) {
|
|||
|
|
if (value) {
|
|||
|
|
const encrypted = new EncryptedField(value)
|
|||
|
|
this.setDataValue('id_card', encrypted.encrypt())
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
get() {
|
|||
|
|
const encryptedValue = this.getDataValue('id_card')
|
|||
|
|
return EncryptedField.decrypt(encryptedValue)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 📊 安全监控和审计
|
|||
|
|
|
|||
|
|
### 安全事件日志
|
|||
|
|
|
|||
|
|
#### 日志记录系统
|
|||
|
|
```javascript
|
|||
|
|
// 安全事件类型
|
|||
|
|
const SECURITY_EVENT_TYPES = {
|
|||
|
|
// 认证相关
|
|||
|
|
LOGIN_SUCCESS: 'login_success',
|
|||
|
|
LOGIN_FAILED: 'login_failed',
|
|||
|
|
LOGOUT: 'logout',
|
|||
|
|
PASSWORD_CHANGED: 'password_changed',
|
|||
|
|
|
|||
|
|
// 权限相关
|
|||
|
|
PERMISSION_DENIED: 'permission_denied',
|
|||
|
|
ROLE_ASSIGNED: 'role_assigned',
|
|||
|
|
ROLE_REVOKED: 'role_revoked',
|
|||
|
|
|
|||
|
|
// 安全威胁
|
|||
|
|
SUSPICIOUS_ACTIVITY: 'suspicious_activity',
|
|||
|
|
BRUTE_FORCE_ATTEMPT: 'brute_force_attempt',
|
|||
|
|
SQL_INJECTION_ATTEMPT: 'sql_injection_attempt',
|
|||
|
|
XSS_ATTEMPT: 'xss_attempt',
|
|||
|
|
|
|||
|
|
// 数据操作
|
|||
|
|
DATA_ACCESS: 'data_access',
|
|||
|
|
DATA_MODIFICATION: 'data_modification',
|
|||
|
|
DATA_DELETION: 'data_deletion',
|
|||
|
|
|
|||
|
|
// 系统事件
|
|||
|
|
SYSTEM_ERROR: 'system_error',
|
|||
|
|
CONFIGURATION_CHANGED: 'configuration_changed'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 安全事件日志模型
|
|||
|
|
const SecurityLog = sequelize.define('SecurityLog', {
|
|||
|
|
id: {
|
|||
|
|
type: DataTypes.UUID,
|
|||
|
|
defaultValue: DataTypes.UUIDV4,
|
|||
|
|
primaryKey: true
|
|||
|
|
},
|
|||
|
|
event_type: {
|
|||
|
|
type: DataTypes.STRING,
|
|||
|
|
allowNull: false
|
|||
|
|
},
|
|||
|
|
user_id: {
|
|||
|
|
type: DataTypes.INTEGER,
|
|||
|
|
allowNull: true
|
|||
|
|
},
|
|||
|
|
ip_address: {
|
|||
|
|
type: DataTypes.STRING,
|
|||
|
|
allowNull: true
|
|||
|
|
},
|
|||
|
|
user_agent: {
|
|||
|
|
type: DataTypes.TEXT,
|
|||
|
|
allowNull: true
|
|||
|
|
},
|
|||
|
|
resource: {
|
|||
|
|
type: DataTypes.STRING,
|
|||
|
|
allowNull: true
|
|||
|
|
},
|
|||
|
|
action: {
|
|||
|
|
type: DataTypes.STRING,
|
|||
|
|
allowNull: true
|
|||
|
|
},
|
|||
|
|
details: {
|
|||
|
|
type: DataTypes.JSON,
|
|||
|
|
allowNull: true
|
|||
|
|
},
|
|||
|
|
risk_level: {
|
|||
|
|
type: DataTypes.ENUM('low', 'medium', 'high', 'critical'),
|
|||
|
|
defaultValue: 'low'
|
|||
|
|
},
|
|||
|
|
timestamp: {
|
|||
|
|
type: DataTypes.DATE,
|
|||
|
|
defaultValue: DataTypes.NOW
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 记录安全事件
|
|||
|
|
async function logSecurityEvent(eventData) {
|
|||
|
|
try {
|
|||
|
|
const logEntry = await SecurityLog.create({
|
|||
|
|
event_type: eventData.type,
|
|||
|
|
user_id: eventData.user_id,
|
|||
|
|
ip_address: eventData.ip_address,
|
|||
|
|
user_agent: eventData.user_agent,
|
|||
|
|
resource: eventData.resource,
|
|||
|
|
action: eventData.action,
|
|||
|
|
details: eventData.details,
|
|||
|
|
risk_level: eventData.risk_level || 'low',
|
|||
|
|
timestamp: eventData.timestamp || new Date()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 高风险事件立即告警
|
|||
|
|
if (eventData.risk_level === 'high' || eventData.risk_level === 'critical') {
|
|||
|
|
await sendSecurityAlert(logEntry)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return logEntry
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('安全事件记录失败:', error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 安全事件分析
|
|||
|
|
class SecurityAnalyzer {
|
|||
|
|
// 检测暴力破解攻击
|
|||
|
|
static async detectBruteForceAttack(ip, timeWindow = 15 * 60 * 1000) {
|
|||
|
|
const since = new Date(Date.now() - timeWindow)
|
|||
|
|
|
|||
|
|
const failedAttempts = await SecurityLog.count({
|
|||
|
|
where: {
|
|||
|
|
event_type: SECURITY_EVENT_TYPES.LOGIN_FAILED,
|
|||
|
|
ip_address: ip,
|
|||
|
|
timestamp: { [Op.gte]: since }
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (failedAttempts >= 5) {
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: SECURITY_EVENT_TYPES.BRUTE_FORCE_ATTEMPT,
|
|||
|
|
ip_address: ip,
|
|||
|
|
details: { failed_attempts: failedAttempts },
|
|||
|
|
risk_level: 'high'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 临时封禁IP
|
|||
|
|
await this.blockIP(ip, 60 * 60 * 1000) // 1小时
|
|||
|
|
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检测异常登录
|
|||
|
|
static async detectAnomalousLogin(userId, currentIP, userAgent) {
|
|||
|
|
// 获取用户历史登录记录
|
|||
|
|
const recentLogins = await SecurityLog.findAll({
|
|||
|
|
where: {
|
|||
|
|
event_type: SECURITY_EVENT_TYPES.LOGIN_SUCCESS,
|
|||
|
|
user_id: userId,
|
|||
|
|
timestamp: { [Op.gte]: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
|
|||
|
|
},
|
|||
|
|
order: [['timestamp', 'DESC']],
|
|||
|
|
limit: 10
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 检查IP地址异常
|
|||
|
|
const knownIPs = recentLogins.map(log => log.ip_address)
|
|||
|
|
const isNewIP = !knownIPs.includes(currentIP)
|
|||
|
|
|
|||
|
|
// 检查设备异常
|
|||
|
|
const knownUserAgents = recentLogins.map(log => log.user_agent)
|
|||
|
|
const isNewDevice = !knownUserAgents.includes(userAgent)
|
|||
|
|
|
|||
|
|
if (isNewIP && isNewDevice) {
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: SECURITY_EVENT_TYPES.SUSPICIOUS_ACTIVITY,
|
|||
|
|
user_id: userId,
|
|||
|
|
ip_address: currentIP,
|
|||
|
|
user_agent: userAgent,
|
|||
|
|
details: {
|
|||
|
|
reason: 'new_ip_and_device',
|
|||
|
|
known_ips: knownIPs.slice(0, 3),
|
|||
|
|
known_devices: knownUserAgents.slice(0, 3)
|
|||
|
|
},
|
|||
|
|
risk_level: 'medium'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检测权限滥用
|
|||
|
|
static async detectPrivilegeAbuse(userId, timeWindow = 60 * 60 * 1000) {
|
|||
|
|
const since = new Date(Date.now() - timeWindow)
|
|||
|
|
|
|||
|
|
const privilegedActions = await SecurityLog.count({
|
|||
|
|
where: {
|
|||
|
|
event_type: {
|
|||
|
|
[Op.in]: [
|
|||
|
|
SECURITY_EVENT_TYPES.ROLE_ASSIGNED,
|
|||
|
|
SECURITY_EVENT_TYPES.DATA_MODIFICATION,
|
|||
|
|
SECURITY_EVENT_TYPES.DATA_DELETION
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
user_id: userId,
|
|||
|
|
timestamp: { [Op.gte]: since }
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 如果1小时内特权操作超过阈值
|
|||
|
|
if (privilegedActions > 20) {
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: SECURITY_EVENT_TYPES.SUSPICIOUS_ACTIVITY,
|
|||
|
|
user_id: userId,
|
|||
|
|
details: {
|
|||
|
|
reason: 'excessive_privileged_actions',
|
|||
|
|
action_count: privilegedActions
|
|||
|
|
},
|
|||
|
|
risk_level: 'high'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// IP封禁
|
|||
|
|
static async blockIP(ip, duration) {
|
|||
|
|
const expiresAt = new Date(Date.now() + duration)
|
|||
|
|
|
|||
|
|
await redis.setex(
|
|||
|
|
`blocked_ip:${ip}`,
|
|||
|
|
Math.ceil(duration / 1000),
|
|||
|
|
JSON.stringify({
|
|||
|
|
blocked_at: new Date(),
|
|||
|
|
expires_at: expiresAt,
|
|||
|
|
reason: 'security_violation'
|
|||
|
|
})
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
logger.warn(`IP已被封禁: ${ip}, 到期时间: ${expiresAt}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查IP是否被封禁
|
|||
|
|
static async isIPBlocked(ip) {
|
|||
|
|
const blockData = await redis.get(`blocked_ip:${ip}`)
|
|||
|
|
return !!blockData
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// IP封禁检查中间件
|
|||
|
|
function checkIPBlock(req, res, next) {
|
|||
|
|
return async (req, res, next) => {
|
|||
|
|
try {
|
|||
|
|
const isBlocked = await SecurityAnalyzer.isIPBlocked(req.ip)
|
|||
|
|
|
|||
|
|
if (isBlocked) {
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: SECURITY_EVENT_TYPES.SUSPICIOUS_ACTIVITY,
|
|||
|
|
ip_address: req.ip,
|
|||
|
|
details: { reason: 'blocked_ip_access_attempt' },
|
|||
|
|
risk_level: 'medium'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return res.status(403).json({
|
|||
|
|
error: '访问被拒绝',
|
|||
|
|
message: '您的IP地址已被临时封禁'
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
next()
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('IP封禁检查失败:', error)
|
|||
|
|
next()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 实时监控和告警
|
|||
|
|
|
|||
|
|
#### 安全告警系统
|
|||
|
|
```javascript
|
|||
|
|
// 告警配置
|
|||
|
|
const ALERT_CONFIG = {
|
|||
|
|
channels: {
|
|||
|
|
email: {
|
|||
|
|
enabled: true,
|
|||
|
|
recipients: ['security@jiebanke.com', 'admin@jiebanke.com']
|
|||
|
|
},
|
|||
|
|
sms: {
|
|||
|
|
enabled: true,
|
|||
|
|
recipients: ['+8613800138000']
|
|||
|
|
},
|
|||
|
|
webhook: {
|
|||
|
|
enabled: true,
|
|||
|
|
url: 'https://hooks.slack.com/services/xxx'
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
thresholds: {
|
|||
|
|
failed_logins: 10,
|
|||
|
|
permission_denials: 20,
|
|||
|
|
suspicious_activities: 5
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 告警服务
|
|||
|
|
class SecurityAlertService {
|
|||
|
|
// 发送安全告警
|
|||
|
|
static async sendSecurityAlert(logEntry) {
|
|||
|
|
try {
|
|||
|
|
const alertData = {
|
|||
|
|
title: `安全告警 - ${this.getEventTypeName(logEntry.event_type)}`,
|
|||
|
|
message: this.formatAlertMessage(logEntry),
|
|||
|
|
severity: logEntry.risk_level,
|
|||
|
|
timestamp: logEntry.timestamp,
|
|||
|
|
details: logEntry
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 发送邮件告警
|
|||
|
|
if (ALERT_CONFIG.channels.email.enabled) {
|
|||
|
|
await this.sendEmailAlert(alertData)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 发送短信告警(仅高危事件)
|
|||
|
|
if (ALERT_CONFIG.channels.sms.enabled &&
|
|||
|
|
['high', 'critical'].includes(logEntry.risk_level)) {
|
|||
|
|
await this.sendSMSAlert(alertData)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 发送Webhook告警
|
|||
|
|
if (ALERT_CONFIG.channels.webhook.enabled) {
|
|||
|
|
await this.sendWebhookAlert(alertData)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.info(`安全告警已发送: ${logEntry.event_type}`)
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('发送安全告警失败:', error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 格式化告警消息
|
|||
|
|
static formatAlertMessage(logEntry) {
|
|||
|
|
const messages = {
|
|||
|
|
[SECURITY_EVENT_TYPES.BRUTE_FORCE_ATTEMPT]:
|
|||
|
|
`检测到暴力破解攻击,IP: ${logEntry.ip_address}`,
|
|||
|
|
[SECURITY_EVENT_TYPES.SUSPICIOUS_ACTIVITY]:
|
|||
|
|
`检测到可疑活动,用户: ${logEntry.user_id}, IP: ${logEntry.ip_address}`,
|
|||
|
|
[SECURITY_EVENT_TYPES.SQL_INJECTION_ATTEMPT]:
|
|||
|
|
`检测到SQL注入攻击尝试,IP: ${logEntry.ip_address}`,
|
|||
|
|
[SECURITY_EVENT_TYPES.XSS_ATTEMPT]:
|
|||
|
|
`检测到XSS攻击尝试,IP: ${logEntry.ip_address}`,
|
|||
|
|
[SECURITY_EVENT_TYPES.PERMISSION_DENIED]:
|
|||
|
|
`权限拒绝事件,用户: ${logEntry.user_id}, 资源: ${logEntry.resource}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return messages[logEntry.event_type] || `安全事件: ${logEntry.event_type}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 发送邮件告警
|
|||
|
|
static async sendEmailAlert(alertData) {
|
|||
|
|
const emailContent = `
|
|||
|
|
<h2>🚨 ${alertData.title}</h2>
|
|||
|
|
<p><strong>时间:</strong> ${alertData.timestamp}</p>
|
|||
|
|
<p><strong>严重程度:</strong> ${alertData.severity}</p>
|
|||
|
|
<p><strong>描述:</strong> ${alertData.message}</p>
|
|||
|
|
|
|||
|
|
<h3>详细信息:</h3>
|
|||
|
|
<pre>${JSON.stringify(alertData.details, null, 2)}</pre>
|
|||
|
|
|
|||
|
|
<p>请立即检查系统安全状况。</p>
|
|||
|
|
`
|
|||
|
|
|
|||
|
|
await emailService.send({
|
|||
|
|
to: ALERT_CONFIG.channels.email.recipients,
|
|||
|
|
subject: `[解班客安全告警] ${alertData.title}`,
|
|||
|
|
html: emailContent
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 发送短信告警
|
|||
|
|
static async sendSMSAlert(alertData) {
|
|||
|
|
const message = `【解班客安全告警】${alertData.message},请立即处理。时间:${alertData.timestamp}`
|
|||
|
|
|
|||
|
|
for (const recipient of ALERT_CONFIG.channels.sms.recipients) {
|
|||
|
|
await smsService.send({
|
|||
|
|
to: recipient,
|
|||
|
|
message: message
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 发送Webhook告警
|
|||
|
|
static async sendWebhookAlert(alertData) {
|
|||
|
|
const payload = {
|
|||
|
|
text: `🚨 ${alertData.title}`,
|
|||
|
|
attachments: [{
|
|||
|
|
color: this.getSeverityColor(alertData.severity),
|
|||
|
|
fields: [
|
|||
|
|
{ title: '时间', value: alertData.timestamp, short: true },
|
|||
|
|
{ title: '严重程度', value: alertData.severity, short: true },
|
|||
|
|
{ title: '描述', value: alertData.message, short: false }
|
|||
|
|
]
|
|||
|
|
}]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await axios.post(ALERT_CONFIG.channels.webhook.url, payload)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取严重程度颜色
|
|||
|
|
static getSeverityColor(severity) {
|
|||
|
|
const colors = {
|
|||
|
|
low: '#36a64f',
|
|||
|
|
medium: '#ff9500',
|
|||
|
|
high: '#ff0000',
|
|||
|
|
critical: '#8b0000'
|
|||
|
|
}
|
|||
|
|
return colors[severity] || '#cccccc'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 批量告警检查
|
|||
|
|
static async checkBatchAlerts() {
|
|||
|
|
const timeWindow = 5 * 60 * 1000 // 5分钟
|
|||
|
|
const since = new Date(Date.now() - timeWindow)
|
|||
|
|
|
|||
|
|
// 检查失败登录次数
|
|||
|
|
const failedLogins = await SecurityLog.count({
|
|||
|
|
where: {
|
|||
|
|
event_type: SECURITY_EVENT_TYPES.LOGIN_FAILED,
|
|||
|
|
timestamp: { [Op.gte]: since }
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (failedLogins >= ALERT_CONFIG.thresholds.failed_logins) {
|
|||
|
|
await this.sendBatchAlert('大量登录失败', {
|
|||
|
|
count: failedLogins,
|
|||
|
|
timeWindow: '5分钟',
|
|||
|
|
threshold: ALERT_CONFIG.thresholds.failed_logins
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查权限拒绝次数
|
|||
|
|
const permissionDenials = await SecurityLog.count({
|
|||
|
|
where: {
|
|||
|
|
event_type: SECURITY_EVENT_TYPES.PERMISSION_DENIED,
|
|||
|
|
timestamp: { [Op.gte]: since }
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (permissionDenials >= ALERT_CONFIG.thresholds.permission_denials) {
|
|||
|
|
await this.sendBatchAlert('大量权限拒绝', {
|
|||
|
|
count: permissionDenials,
|
|||
|
|
timeWindow: '5分钟',
|
|||
|
|
threshold: ALERT_CONFIG.thresholds.permission_denials
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 发送批量告警
|
|||
|
|
static async sendBatchAlert(title, data) {
|
|||
|
|
const alertData = {
|
|||
|
|
title: `批量安全事件 - ${title}`,
|
|||
|
|
message: `在${data.timeWindow}内检测到${data.count}次${title}事件,超过阈值${data.threshold}`,
|
|||
|
|
severity: 'high',
|
|||
|
|
timestamp: new Date(),
|
|||
|
|
details: data
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await this.sendSecurityAlert({ ...alertData, risk_level: 'high' })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 定时检查批量告警
|
|||
|
|
setInterval(async () => {
|
|||
|
|
try {
|
|||
|
|
await SecurityAlertService.checkBatchAlerts()
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('批量告警检查失败:', error)
|
|||
|
|
}
|
|||
|
|
}, 5 * 60 * 1000) // 每5分钟检查一次
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 🔒 数据隐私保护
|
|||
|
|
|
|||
|
|
### 数据脱敏
|
|||
|
|
|
|||
|
|
#### 敏感数据脱敏
|
|||
|
|
```javascript
|
|||
|
|
// 数据脱敏工具
|
|||
|
|
class DataMasking {
|
|||
|
|
// 手机号脱敏
|
|||
|
|
static maskPhone(phone) {
|
|||
|
|
if (!phone || phone.length < 11) return phone
|
|||
|
|
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 身份证号脱敏
|
|||
|
|
static maskIDCard(idCard) {
|
|||
|
|
if (!idCard || idCard.length < 15) return idCard
|
|||
|
|
return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 邮箱脱敏
|
|||
|
|
static maskEmail(email) {
|
|||
|
|
if (!email || !email.includes('@')) return email
|
|||
|
|
const [username, domain] = email.split('@')
|
|||
|
|
if (username.length <= 2) return email
|
|||
|
|
const maskedUsername = username.charAt(0) + '*'.repeat(username.length - 2) + username.charAt(username.length - 1)
|
|||
|
|
return `${maskedUsername}@${domain}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 姓名脱敏
|
|||
|
|
static maskName(name) {
|
|||
|
|
if (!name || name.length <= 1) return name
|
|||
|
|
if (name.length === 2) {
|
|||
|
|
return name.charAt(0) + '*'
|
|||
|
|
}
|
|||
|
|
return name.charAt(0) + '*'.repeat(name.length - 2) + name.charAt(name.length - 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 地址脱敏
|
|||
|
|
static maskAddress(address) {
|
|||
|
|
if (!address || address.length <= 10) return address
|
|||
|
|
return address.substring(0, 6) + '****' + address.substring(address.length - 4)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 银行卡号脱敏
|
|||
|
|
static maskBankCard(cardNumber) {
|
|||
|
|
if (!cardNumber || cardNumber.length < 16) return cardNumber
|
|||
|
|
return cardNumber.replace(/(\d{4})\d{8}(\d{4})/, '$1********$2')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 通用脱敏方法
|
|||
|
|
static maskSensitiveData(data, fields) {
|
|||
|
|
const masked = { ...data }
|
|||
|
|
|
|||
|
|
for (const field of fields) {
|
|||
|
|
if (masked[field]) {
|
|||
|
|
switch (field) {
|
|||
|
|
case 'phone':
|
|||
|
|
case 'mobile':
|
|||
|
|
masked[field] = this.maskPhone(masked[field])
|
|||
|
|
break
|
|||
|
|
case 'id_card':
|
|||
|
|
case 'idCard':
|
|||
|
|
masked[field] = this.maskIDCard(masked[field])
|
|||
|
|
break
|
|||
|
|
case 'email':
|
|||
|
|
masked[field] = this.maskEmail(masked[field])
|
|||
|
|
break
|
|||
|
|
case 'name':
|
|||
|
|
case 'real_name':
|
|||
|
|
masked[field] = this.maskName(masked[field])
|
|||
|
|
break
|
|||
|
|
case 'address':
|
|||
|
|
masked[field] = this.maskAddress(masked[field])
|
|||
|
|
break
|
|||
|
|
case 'bank_card':
|
|||
|
|
masked[field] = this.maskBankCard(masked[field])
|
|||
|
|
break
|
|||
|
|
default:
|
|||
|
|
// 默认脱敏:显示前2位和后2位
|
|||
|
|
if (typeof masked[field] === 'string' && masked[field].length > 4) {
|
|||
|
|
masked[field] = masked[field].substring(0, 2) +
|
|||
|
|
'*'.repeat(masked[field].length - 4) +
|
|||
|
|
masked[field].substring(masked[field].length - 2)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return masked
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// API响应脱敏中间件
|
|||
|
|
function maskSensitiveResponse(sensitiveFields = []) {
|
|||
|
|
return (req, res, next) => {
|
|||
|
|
const originalJson = res.json
|
|||
|
|
|
|||
|
|
res.json = function(data) {
|
|||
|
|
if (data && typeof data === 'object') {
|
|||
|
|
// 递归脱敏处理
|
|||
|
|
const maskedData = maskDataRecursively(data, sensitiveFields)
|
|||
|
|
return originalJson.call(this, maskedData)
|
|||
|
|
}
|
|||
|
|
return originalJson.call(this, data)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
next()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 递归脱敏处理
|
|||
|
|
function maskDataRecursively(data, sensitiveFields) {
|
|||
|
|
if (Array.isArray(data)) {
|
|||
|
|
return data.map(item => maskDataRecursively(item, sensitiveFields))
|
|||
|
|
} else if (data && typeof data === 'object') {
|
|||
|
|
return DataMasking.maskSensitiveData(data, sensitiveFields)
|
|||
|
|
}
|
|||
|
|
return data
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 数据备份和恢复
|
|||
|
|
|
|||
|
|
#### 安全备份策略
|
|||
|
|
```javascript
|
|||
|
|
// 备份配置
|
|||
|
|
const BACKUP_CONFIG = {
|
|||
|
|
schedule: {
|
|||
|
|
full: '0 2 * * 0', // 每周日凌晨2点全量备份
|
|||
|
|
incremental: '0 2 * * 1-6', // 周一到周六增量备份
|
|||
|
|
log: '0 */6 * * *' // 每6小时备份日志
|
|||
|
|
},
|
|||
|
|
retention: {
|
|||
|
|
full: 30, // 保留30天
|
|||
|
|
incremental: 7, // 保留7天
|
|||
|
|
log: 3 // 保留3天
|
|||
|
|
},
|
|||
|
|
encryption: {
|
|||
|
|
enabled: true,
|
|||
|
|
algorithm: 'aes-256-cbc',
|
|||
|
|
keyRotation: 90 // 90天轮换密钥
|
|||
|
|
},
|
|||
|
|
storage: {
|
|||
|
|
local: '/backup/local',
|
|||
|
|
remote: 's3://jiebanke-backup',
|
|||
|
|
offsite: 'backup-server-2'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 备份服务
|
|||
|
|
class BackupService {
|
|||
|
|
// 数据库全量备份
|
|||
|
|
static async createFullBackup() {
|
|||
|
|
try {
|
|||
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|||
|
|
const backupName = `full-backup-${timestamp}`
|
|||
|
|
|
|||
|
|
logger.info(`开始全量备份: ${backupName}`)
|
|||
|
|
|
|||
|
|
// 1. 创建数据库备份
|
|||
|
|
const dbBackupPath = await this.backupDatabase(backupName)
|
|||
|
|
|
|||
|
|
// 2. 备份文件存储
|
|||
|
|
const filesBackupPath = await this.backupFiles(backupName)
|
|||
|
|
|
|||
|
|
// 3. 备份配置文件
|
|||
|
|
const configBackupPath = await this.backupConfigs(backupName)
|
|||
|
|
|
|||
|
|
// 4. 创建备份清单
|
|||
|
|
const manifest = {
|
|||
|
|
backup_name: backupName,
|
|||
|
|
backup_type: 'full',
|
|||
|
|
created_at: new Date(),
|
|||
|
|
database: dbBackupPath,
|
|||
|
|
files: filesBackupPath,
|
|||
|
|
configs: configBackupPath,
|
|||
|
|
checksum: await this.calculateChecksum([dbBackupPath, filesBackupPath, configBackupPath])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 加密备份
|
|||
|
|
if (BACKUP_CONFIG.encryption.enabled) {
|
|||
|
|
await this.encryptBackup(manifest)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 6. 上传到远程存储
|
|||
|
|
await this.uploadToRemoteStorage(manifest)
|
|||
|
|
|
|||
|
|
// 7. 记录备份日志
|
|||
|
|
await this.logBackupEvent('FULL_BACKUP_SUCCESS', manifest)
|
|||
|
|
|
|||
|
|
logger.info(`全量备份完成: ${backupName}`)
|
|||
|
|
return manifest
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('全量备份失败:', error)
|
|||
|
|
await this.logBackupEvent('FULL_BACKUP_FAILED', { error: error.message })
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 数据库备份
|
|||
|
|
static async backupDatabase(backupName) {
|
|||
|
|
const backupPath = path.join(BACKUP_CONFIG.storage.local, `${backupName}-db.sql`)
|
|||
|
|
|
|||
|
|
const command = `mysqldump -h ${process.env.DB_HOST} -u ${process.env.DB_USER} -p${process.env.DB_PASSWORD} ${process.env.DB_NAME} > ${backupPath}`
|
|||
|
|
|
|||
|
|
await execAsync(command)
|
|||
|
|
|
|||
|
|
// 验证备份文件
|
|||
|
|
const stats = await fs.stat(backupPath)
|
|||
|
|
if (stats.size === 0) {
|
|||
|
|
throw new Error('数据库备份文件为空')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return backupPath
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 文件备份
|
|||
|
|
static async backupFiles(backupName) {
|
|||
|
|
const backupPath = path.join(BACKUP_CONFIG.storage.local, `${backupName}-files.tar.gz`)
|
|||
|
|
|
|||
|
|
const command = `tar -czf ${backupPath} ${process.env.UPLOAD_DIR} ${process.env.STATIC_DIR}`
|
|||
|
|
|
|||
|
|
await execAsync(command)
|
|||
|
|
return backupPath
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 配置文件备份
|
|||
|
|
static async backupConfigs(backupName) {
|
|||
|
|
const backupPath = path.join(BACKUP_CONFIG.storage.local, `${backupName}-configs.tar.gz`)
|
|||
|
|
|
|||
|
|
const configDirs = [
|
|||
|
|
'./config',
|
|||
|
|
'./docker-compose.yml',
|
|||
|
|
'./package.json',
|
|||
|
|
'./.env.example'
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const command = `tar -czf ${backupPath} ${configDirs.join(' ')}`
|
|||
|
|
|
|||
|
|
await execAsync(command)
|
|||
|
|
return backupPath
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 增量备份
|
|||
|
|
static async createIncrementalBackup() {
|
|||
|
|
try {
|
|||
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|||
|
|
const backupName = `incremental-backup-${timestamp}`
|
|||
|
|
|
|||
|
|
logger.info(`开始增量备份: ${backupName}`)
|
|||
|
|
|
|||
|
|
// 获取上次备份时间
|
|||
|
|
const lastBackup = await this.getLastBackupTime()
|
|||
|
|
|
|||
|
|
// 1. 增量数据库备份
|
|||
|
|
const dbBackupPath = await this.backupDatabaseIncremental(backupName, lastBackup)
|
|||
|
|
|
|||
|
|
// 2. 增量文件备份
|
|||
|
|
const filesBackupPath = await this.backupFilesIncremental(backupName, lastBackup)
|
|||
|
|
|
|||
|
|
// 3. 创建备份清单
|
|||
|
|
const manifest = {
|
|||
|
|
backup_name: backupName,
|
|||
|
|
backup_type: 'incremental',
|
|||
|
|
created_at: new Date(),
|
|||
|
|
since: lastBackup,
|
|||
|
|
database: dbBackupPath,
|
|||
|
|
files: filesBackupPath,
|
|||
|
|
checksum: await this.calculateChecksum([dbBackupPath, filesBackupPath])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 加密和上传
|
|||
|
|
if (BACKUP_CONFIG.encryption.enabled) {
|
|||
|
|
await this.encryptBackup(manifest)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await this.uploadToRemoteStorage(manifest)
|
|||
|
|
await this.logBackupEvent('INCREMENTAL_BACKUP_SUCCESS', manifest)
|
|||
|
|
|
|||
|
|
logger.info(`增量备份完成: ${backupName}`)
|
|||
|
|
return manifest
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('增量备份失败:', error)
|
|||
|
|
await this.logBackupEvent('INCREMENTAL_BACKUP_FAILED', { error: error.message })
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 备份恢复
|
|||
|
|
static async restoreBackup(backupName, options = {}) {
|
|||
|
|
try {
|
|||
|
|
logger.info(`开始恢复备份: ${backupName}`)
|
|||
|
|
|
|||
|
|
// 1. 下载备份文件
|
|||
|
|
const manifest = await this.downloadBackup(backupName)
|
|||
|
|
|
|||
|
|
// 2. 解密备份
|
|||
|
|
if (BACKUP_CONFIG.encryption.enabled) {
|
|||
|
|
await this.decryptBackup(manifest)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 验证备份完整性
|
|||
|
|
const isValid = await this.verifyBackupIntegrity(manifest)
|
|||
|
|
if (!isValid) {
|
|||
|
|
throw new Error('备份文件完整性验证失败')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 停止服务(如果需要)
|
|||
|
|
if (options.stopServices) {
|
|||
|
|
await this.stopServices()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 恢复数据库
|
|||
|
|
if (options.restoreDatabase !== false) {
|
|||
|
|
await this.restoreDatabase(manifest.database)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 6. 恢复文件
|
|||
|
|
if (options.restoreFiles !== false) {
|
|||
|
|
await this.restoreFiles(manifest.files)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 7. 恢复配置
|
|||
|
|
if (options.restoreConfigs !== false && manifest.configs) {
|
|||
|
|
await this.restoreConfigs(manifest.configs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 8. 重启服务
|
|||
|
|
if (options.stopServices) {
|
|||
|
|
await this.startServices()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await this.logBackupEvent('RESTORE_SUCCESS', { backup_name: backupName })
|
|||
|
|
logger.info(`备份恢复完成: ${backupName}`)
|
|||
|
|
|
|||
|
|
return true
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('备份恢复失败:', error)
|
|||
|
|
await this.logBackupEvent('RESTORE_FAILED', {
|
|||
|
|
backup_name: backupName,
|
|||
|
|
error: error.message
|
|||
|
|
})
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 备份清理
|
|||
|
|
static async cleanupOldBackups() {
|
|||
|
|
try {
|
|||
|
|
const now = new Date()
|
|||
|
|
|
|||
|
|
// 清理本地备份
|
|||
|
|
const localBackups = await this.listLocalBackups()
|
|||
|
|
|
|||
|
|
for (const backup of localBackups) {
|
|||
|
|
const age = (now - backup.created_at) / (1000 * 60 * 60 * 24) // 天数
|
|||
|
|
|
|||
|
|
let shouldDelete = false
|
|||
|
|
|
|||
|
|
if (backup.type === 'full' && age > BACKUP_CONFIG.retention.full) {
|
|||
|
|
shouldDelete = true
|
|||
|
|
} else if (backup.type === 'incremental' && age > BACKUP_CONFIG.retention.incremental) {
|
|||
|
|
shouldDelete = true
|
|||
|
|
} else if (backup.type === 'log' && age > BACKUP_CONFIG.retention.log) {
|
|||
|
|
shouldDelete = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (shouldDelete) {
|
|||
|
|
await this.deleteBackup(backup)
|
|||
|
|
logger.info(`已删除过期备份: ${backup.name}`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清理远程备份
|
|||
|
|
await this.cleanupRemoteBackups()
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('备份清理失败:', error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 🔧 安全配置和部署
|
|||
|
|
|
|||
|
|
### 服务器安全配置
|
|||
|
|
|
|||
|
|
#### Nginx安全配置
|
|||
|
|
```nginx
|
|||
|
|
# /etc/nginx/sites-available/jiebanke-security
|
|||
|
|
server {
|
|||
|
|
listen 443 ssl http2;
|
|||
|
|
listen [::]:443 ssl http2;
|
|||
|
|
server_name api.jiebanke.com;
|
|||
|
|
|
|||
|
|
# SSL配置
|
|||
|
|
ssl_certificate /etc/letsencrypt/live/api.jiebanke.com/fullchain.pem;
|
|||
|
|
ssl_certificate_key /etc/letsencrypt/live/api.jiebanke.com/privkey.pem;
|
|||
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|||
|
|
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
|||
|
|
ssl_prefer_server_ciphers off;
|
|||
|
|
ssl_session_cache shared:SSL:10m;
|
|||
|
|
ssl_session_timeout 10m;
|
|||
|
|
|
|||
|
|
# 安全头部
|
|||
|
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
|||
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|||
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|||
|
|
add_header X-XSS-Protection "1; mode=block" always;
|
|||
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|||
|
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" always;
|
|||
|
|
|
|||
|
|
# 隐藏服务器信息
|
|||
|
|
server_tokens off;
|
|||
|
|
|
|||
|
|
# 限制请求大小
|
|||
|
|
client_max_body_size 10M;
|
|||
|
|
client_body_buffer_size 128k;
|
|||
|
|
|
|||
|
|
# 限制请求速率
|
|||
|
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
|||
|
|
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
|
|||
|
|
|
|||
|
|
# 防止缓冲区溢出
|
|||
|
|
client_body_timeout 12;
|
|||
|
|
client_header_timeout 12;
|
|||
|
|
keepalive_timeout 15;
|
|||
|
|
send_timeout 10;
|
|||
|
|
|
|||
|
|
# 主要API路由
|
|||
|
|
location /api/ {
|
|||
|
|
limit_req zone=api burst=20 nodelay;
|
|||
|
|
|
|||
|
|
# 代理到后端服务
|
|||
|
|
proxy_pass http://127.0.0.1:3000;
|
|||
|
|
proxy_http_version 1.1;
|
|||
|
|
proxy_set_header Upgrade $http_upgrade;
|
|||
|
|
proxy_set_header Connection 'upgrade';
|
|||
|
|
proxy_set_header Host $host;
|
|||
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|||
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|||
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|||
|
|
proxy_cache_bypass $http_upgrade;
|
|||
|
|
|
|||
|
|
# 超时设置
|
|||
|
|
proxy_connect_timeout 5s;
|
|||
|
|
proxy_send_timeout 10s;
|
|||
|
|
proxy_read_timeout 10s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 登录接口特殊限制
|
|||
|
|
location /api/auth/login {
|
|||
|
|
limit_req zone=login burst=5 nodelay;
|
|||
|
|
proxy_pass http://127.0.0.1:3000;
|
|||
|
|
# ... 其他代理设置
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 静态文件
|
|||
|
|
location /uploads/ {
|
|||
|
|
alias /var/www/jiebanke/uploads/;
|
|||
|
|
expires 30d;
|
|||
|
|
add_header Cache-Control "public, immutable";
|
|||
|
|
|
|||
|
|
# 防止执行上传的脚本
|
|||
|
|
location ~* \.(php|jsp|asp|sh|py|pl|exe)$ {
|
|||
|
|
deny all;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 禁止访问敏感文件
|
|||
|
|
location ~ /\. {
|
|||
|
|
deny all;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
location ~ \.(sql|log|conf)$ {
|
|||
|
|
deny all;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 错误页面
|
|||
|
|
error_page 404 /404.html;
|
|||
|
|
error_page 500 502 503 504 /50x.html;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# HTTP重定向到HTTPS
|
|||
|
|
server {
|
|||
|
|
listen 80;
|
|||
|
|
listen [::]:80;
|
|||
|
|
server_name api.jiebanke.com;
|
|||
|
|
return 301 https://$server_name$request_uri;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 防火墙配置
|
|||
|
|
```bash
|
|||
|
|
#!/bin/bash
|
|||
|
|
# 防火墙安全配置脚本
|
|||
|
|
|
|||
|
|
# 清空现有规则
|
|||
|
|
iptables -F
|
|||
|
|
iptables -X
|
|||
|
|
iptables -t nat -F
|
|||
|
|
iptables -t nat -X
|
|||
|
|
|
|||
|
|
# 设置默认策略
|
|||
|
|
iptables -P INPUT DROP
|
|||
|
|
iptables -P FORWARD DROP
|
|||
|
|
iptables -P OUTPUT ACCEPT
|
|||
|
|
|
|||
|
|
# 允许本地回环
|
|||
|
|
iptables -A INPUT -i lo -j ACCEPT
|
|||
|
|
iptables -A OUTPUT -o lo -j ACCEPT
|
|||
|
|
|
|||
|
|
# 允许已建立的连接
|
|||
|
|
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
|||
|
|
|
|||
|
|
# 允许SSH(限制连接数)
|
|||
|
|
iptables -A INPUT -p tcp --dport 22 -m connlimit --connlimit-above 3 -j DROP
|
|||
|
|
iptables -A INPUT -p tcp --dport 22 -m state --state NEW -j ACCEPT
|
|||
|
|
|
|||
|
|
# 允许HTTP和HTTPS
|
|||
|
|
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
|
|||
|
|
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
|
|||
|
|
|
|||
|
|
# 防止DDoS攻击
|
|||
|
|
iptables -A INPUT -p tcp --dport 80 -m limit --limit 25/minute --limit-burst 100 -j ACCEPT
|
|||
|
|
iptables -A INPUT -p tcp --dport 443 -m limit --limit 25/minute --limit-burst 100 -j ACCEPT
|
|||
|
|
|
|||
|
|
# 防止端口扫描
|
|||
|
|
iptables -A INPUT -m recent --name portscan --rcheck --seconds 86400 -j DROP
|
|||
|
|
iptables -A INPUT -m recent --name portscan --remove
|
|||
|
|
iptables -A INPUT -p tcp -m tcp --dport 139 -m recent --name portscan --set -j LOG --log-prefix "Portscan:"
|
|||
|
|
iptables -A INPUT -p tcp -m tcp --dport 139 -j DROP
|
|||
|
|
|
|||
|
|
# 防止SYN洪水攻击
|
|||
|
|
iptables -A INPUT -p tcp --syn -m limit --limit 1/s --limit-burst 3 -j RETURN
|
|||
|
|
iptables -A INPUT -p tcp --syn -j DROP
|
|||
|
|
|
|||
|
|
# 防止ping洪水攻击
|
|||
|
|
iptables -A INPUT -p icmp --icmp-type echo-request -m limit --limit 1/s -j ACCEPT
|
|||
|
|
iptables -A INPUT -p icmp --icmp-type echo-request -j DROP
|
|||
|
|
|
|||
|
|
# 记录被丢弃的包
|
|||
|
|
iptables -A INPUT -m limit --limit 5/min -j LOG --log-prefix "iptables denied: " --log-level 7
|
|||
|
|
|
|||
|
|
# 保存规则
|
|||
|
|
iptables-save > /etc/iptables/rules.v4
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 环境变量安全管理
|
|||
|
|
|
|||
|
|
#### 密钥管理
|
|||
|
|
```javascript
|
|||
|
|
// 环境变量验证和管理
|
|||
|
|
class EnvironmentManager {
|
|||
|
|
constructor() {
|
|||
|
|
this.requiredVars = [
|
|||
|
|
'NODE_ENV',
|
|||
|
|
'PORT',
|
|||
|
|
'DB_HOST',
|
|||
|
|
'DB_USER',
|
|||
|
|
'DB_PASSWORD',
|
|||
|
|
'DB_NAME',
|
|||
|
|
'JWT_SECRET',
|
|||
|
|
'ENCRYPTION_KEY',
|
|||
|
|
'REDIS_HOST',
|
|||
|
|
'REDIS_PASSWORD'
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
this.sensitiveVars = [
|
|||
|
|
'DB_PASSWORD',
|
|||
|
|
'JWT_SECRET',
|
|||
|
|
'ENCRYPTION_KEY',
|
|||
|
|
'REDIS_PASSWORD',
|
|||
|
|
'SMS_API_KEY',
|
|||
|
|
'EMAIL_PASSWORD'
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证环境变量
|
|||
|
|
validateEnvironment() {
|
|||
|
|
const missing = []
|
|||
|
|
const weak = []
|
|||
|
|
|
|||
|
|
for (const varName of this.requiredVars) {
|
|||
|
|
const value = process.env[varName]
|
|||
|
|
|
|||
|
|
if (!value) {
|
|||
|
|
missing.push(varName)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查敏感变量强度
|
|||
|
|
if (this.sensitiveVars.includes(varName)) {
|
|||
|
|
if (!this.isStrongSecret(value)) {
|
|||
|
|
weak.push(varName)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (missing.length > 0) {
|
|||
|
|
throw new Error(`缺少必需的环境变量: ${missing.join(', ')}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (weak.length > 0) {
|
|||
|
|
logger.warn(`以下环境变量强度不足: ${weak.join(', ')}`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生产环境额外检查
|
|||
|
|
if (process.env.NODE_ENV === 'production') {
|
|||
|
|
this.validateProductionEnvironment()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查密钥强度
|
|||
|
|
isStrongSecret(secret) {
|
|||
|
|
if (secret.length < 32) return false
|
|||
|
|
|
|||
|
|
const hasUpper = /[A-Z]/.test(secret)
|
|||
|
|
const hasLower = /[a-z]/.test(secret)
|
|||
|
|
const hasNumber = /\d/.test(secret)
|
|||
|
|
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(secret)
|
|||
|
|
|
|||
|
|
return hasUpper && hasLower && hasNumber && hasSpecial
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生产环境验证
|
|||
|
|
validateProductionEnvironment() {
|
|||
|
|
const productionChecks = {
|
|||
|
|
NODE_ENV: (val) => val === 'production',
|
|||
|
|
JWT_SECRET: (val) => val.length >= 64,
|
|||
|
|
ENCRYPTION_KEY: (val) => val.length >= 64,
|
|||
|
|
DB_SSL: (val) => val === 'true',
|
|||
|
|
REDIS_TLS: (val) => val === 'true'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const [varName, validator] of Object.entries(productionChecks)) {
|
|||
|
|
const value = process.env[varName]
|
|||
|
|
if (value && !validator(value)) {
|
|||
|
|
throw new Error(`生产环境配置错误: ${varName}`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 密钥轮换
|
|||
|
|
async rotateSecrets() {
|
|||
|
|
try {
|
|||
|
|
logger.info('开始密钥轮换')
|
|||
|
|
|
|||
|
|
// 生成新的JWT密钥
|
|||
|
|
const newJwtSecret = this.generateStrongSecret(64)
|
|||
|
|
|
|||
|
|
// 生成新的加密密钥
|
|||
|
|
const newEncryptionKey = this.generateStrongSecret(64)
|
|||
|
|
|
|||
|
|
// 更新密钥存储
|
|||
|
|
await this.updateSecretStore({
|
|||
|
|
JWT_SECRET: newJwtSecret,
|
|||
|
|
ENCRYPTION_KEY: newEncryptionKey,
|
|||
|
|
rotated_at: new Date().toISOString()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 记录轮换事件
|
|||
|
|
await logSecurityEvent({
|
|||
|
|
type: 'SECRET_ROTATION',
|
|||
|
|
details: { rotated_secrets: ['JWT_SECRET', 'ENCRYPTION_KEY'] },
|
|||
|
|
risk_level: 'low'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
logger.info('密钥轮换完成')
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('密钥轮换失败:', error)
|
|||
|
|
throw error
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成强密钥
|
|||
|
|
generateStrongSecret(length = 64) {
|
|||
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'
|
|||
|
|
let result = ''
|
|||
|
|
|
|||
|
|
for (let i = 0; i < length; i++) {
|
|||
|
|
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新密钥存储
|
|||
|
|
async updateSecretStore(secrets) {
|
|||
|
|
// 这里可以集成AWS Secrets Manager、HashiCorp Vault等
|
|||
|
|
// 示例使用文件存储(生产环境不推荐)
|
|||
|
|
const secretsPath = '/etc/jiebanke/secrets.json'
|
|||
|
|
|
|||
|
|
const existingSecrets = await this.loadSecrets()
|
|||
|
|
const updatedSecrets = { ...existingSecrets, ...secrets }
|
|||
|
|
|
|||
|
|
await fs.writeFile(secretsPath, JSON.stringify(updatedSecrets, null, 2), {
|
|||
|
|
mode: 0o600 // 仅所有者可读写
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 初始化环境管理器
|
|||
|
|
const envManager = new EnvironmentManager()
|
|||
|
|
envManager.validateEnvironment()
|
|||
|
|
|
|||
|
|
// 定期密钥轮换(每90天)
|
|||
|
|
if (process.env.NODE_ENV === 'production') {
|
|||
|
|
setInterval(async () => {
|
|||
|
|
try {
|
|||
|
|
await envManager.rotateSecrets()
|
|||
|
|
} catch (error) {
|
|||
|
|
logger.error('自动密钥轮换失败:', error)
|
|||
|
|
}
|
|||
|
|
}, 90 * 24 * 60 * 60 * 1000) // 90天
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 📚 总结
|
|||
|
|
|
|||
|
|
本安全和权限管理文档涵盖了解班客项目的完整安全体系,包括:
|
|||
|
|
|
|||
|
|
### 🎯 核心安全特性
|
|||
|
|
- **多层安全防护**: 网络、应用、数据三层安全架构
|
|||
|
|
- **身份认证系统**: JWT + MFA多因素认证
|
|||
|
|
- **权限管理**: RBAC基于角色的访问控制
|
|||
|
|
- **数据保护**: 加密存储、传输和脱敏处理
|
|||
|
|
- **安全监控**: 实时威胁检测和告警系统
|
|||
|
|
|
|||
|
|
### 🛡️ 安全防护措施
|
|||
|
|
- **输入验证**: XSS、SQL注入、CSRF防护
|
|||
|
|
- **速率限制**: API访问频率控制
|
|||
|
|
- **数据加密**: 敏感信息加密存储
|
|||
|
|
- **安全备份**: 定期备份和恢复机制
|
|||
|
|
- **环境安全**: 密钥管理和轮换
|
|||
|
|
|
|||
|
|
### 📊 监控和审计
|
|||
|
|
- **安全日志**: 完整的操作审计跟踪
|
|||
|
|
- **威胁检测**: 自动化安全威胁识别
|
|||
|
|
- **告警系统**: 多渠道安全事件通知
|
|||
|
|
- **合规性**: 符合数据保护法规要求
|
|||
|
|
|
|||
|
|
通过实施这套完整的安全体系,解班客项目能够有效防范各类安全威胁,保护用户数据安全,确保系统稳定可靠运行。
|