feat(iter-v2): T01 partial - TOKEN_SECRET check, SQL param, export credential, docs
- S2: logs.ts LIMIT/OFFSET SQL parameterization (? placeholders) - S3: checkTokenSecret() startup validation, JWT_SECRET→TOKEN_SECRET in backup.ts - S3: Access Token expiry shortened to 2h (from 30d) - S4: Export download uses HMAC signed short-term token (/prepare endpoint) - S4: Frontend export.ts adapted for prepare-then-download flow - .env.example reorganized with security notes - Added PRD and architecture docs for iteration v2
This commit is contained in:
@@ -1,24 +1,21 @@
|
||||
# Server
|
||||
PORT=3000
|
||||
|
||||
# Security
|
||||
TOKEN_SECRET=your_random_secret_here
|
||||
|
||||
# CORS (comma-separated origins, empty = allow all)
|
||||
CORS_ORIGINS=
|
||||
|
||||
# MySQL
|
||||
DB_HOST=your_mysql_host_here
|
||||
DB_USER=your_mysql_user_here
|
||||
DB_PASSWORD=your_mysql_password_here
|
||||
DB_NAME=your_mysql_name_here
|
||||
DB_HOST=your_db_host
|
||||
DB_USER=your_db_user
|
||||
DB_PASSWORD=your_db_password
|
||||
DB_NAME=xiaocai
|
||||
|
||||
# Uploads
|
||||
UPLOAD_DIR=./uploads
|
||||
|
||||
# Backup
|
||||
BACKUP_DIR=/var/backups/xiaocai
|
||||
BACKUP_DIR=./backups
|
||||
|
||||
# Token (REQUIRED - server will not start with default value)
|
||||
TOKEN_SECRET=change-me-to-a-secure-random-string
|
||||
|
||||
# WeChat Mini Program
|
||||
WX_APPID=your_wx_appid_here
|
||||
WX_SECRET=your_wx_secret_here
|
||||
WX_APPID=your_wx_appid
|
||||
WX_SECRET=your_wx_secret
|
||||
|
||||
@@ -28,10 +28,8 @@ import tagRoutes from './routes/tag'
|
||||
import exportRoutes from './routes/export'
|
||||
import { backupDatabase } from './utils/backup'
|
||||
|
||||
// Warn if token secret is using default fallback
|
||||
if (!process.env.TOKEN_SECRET) {
|
||||
console.warn('[Security] TOKEN_SECRET not set — using default fallback. Set TOKEN_SECRET in .env for production!')
|
||||
}
|
||||
// 强制检查 TOKEN_SECRET 安全性
|
||||
checkTokenSecret()
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3000
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface AuthRequest extends Request {
|
||||
}
|
||||
|
||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||
const TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
const TOKEN_EXPIRY = 2 * 60 * 60 * 1000 // 2 hours
|
||||
|
||||
// 不需要认证的路径
|
||||
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/health']
|
||||
|
||||
@@ -8,6 +8,18 @@ const APPID = process.env.WX_APPID || ''
|
||||
const SECRET = process.env.WX_SECRET || ''
|
||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||
|
||||
/** 启动时检查 TOKEN_SECRET 是否为默认占位符,是则拒绝启动 */
|
||||
export function checkTokenSecret(): void {
|
||||
const defaults = [
|
||||
'xiaocai-token-secret-change-in-production',
|
||||
'change-me-to-a-secure-random-string',
|
||||
]
|
||||
if (defaults.includes(TOKEN_SECRET)) {
|
||||
console.error('[Auth] FATAL: TOKEN_SECRET is using a default placeholder. Set a secure TOKEN_SECRET in .env before starting the server.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export function signToken(userId: number): string {
|
||||
const payload = `${userId}:${Date.now()}`
|
||||
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||
|
||||
@@ -8,7 +8,7 @@ import path from 'path'
|
||||
|
||||
const router = Router()
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret'
|
||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||
|
||||
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
|
||||
router.use((req, res, next) => {
|
||||
@@ -42,7 +42,7 @@ router.get('/:id/download', async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
|
||||
// 校验 HMAC
|
||||
const expectedHmac = createHmac('sha256', JWT_SECRET)
|
||||
const expectedHmac = createHmac('sha256', TOKEN_SECRET)
|
||||
.update(`${backupId}:${timestamp}`)
|
||||
.digest('hex')
|
||||
if (hmac !== expectedHmac) {
|
||||
@@ -89,7 +89,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
// 为每条记录生成下载令牌
|
||||
const listWithToken = list.map(item => {
|
||||
const timestamp = Date.now()
|
||||
const hmac = createHmac('sha256', JWT_SECRET)
|
||||
const hmac = createHmac('sha256', TOKEN_SECRET)
|
||||
.update(`${item.name}:${timestamp}`)
|
||||
.digest('hex')
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { pipeline } from 'stream/promises'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { createHmac } from 'crypto'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -14,18 +15,46 @@ const VALID_FORMATS = ['csv', 'json']
|
||||
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
const LARGE_DATA_THRESHOLD = 10000
|
||||
const EXPORT_TIMEOUT = 60000
|
||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||
const DOWNLOAD_TOKEN_EXPIRY = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
/** 流式导出交易数据 */
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
/** 生成短期下载签名 token */
|
||||
function createDownloadToken(userId: number, startDate: string, endDate: string, type: string, format: string): string {
|
||||
const timestamp = Date.now()
|
||||
const payload = `${userId}:${startDate}:${endDate}:${type}:${format}:${timestamp}`
|
||||
const hmac = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||
return Buffer.from(`${payload}:${hmac}`).toString('base64')
|
||||
}
|
||||
|
||||
/** 验证下载签名 token,返回解出的参数或 null */
|
||||
function verifyDownloadToken(token: string): { userId: number; startDate: string; endDate: string; type: string; format: string } | null {
|
||||
try {
|
||||
// 支持 query token 认证:如果没有 Authorization header,从 query.token 读取
|
||||
if (!req.headers.authorization && req.query.token) {
|
||||
req.headers.authorization = `Bearer ${req.query.token}`
|
||||
}
|
||||
const decoded = Buffer.from(token, 'base64').toString('utf-8')
|
||||
const parts = decoded.split(':')
|
||||
// 格式:userId:startDate:endDate:type:format:timestamp:hmac
|
||||
// 但 type/format 不含冒号,所以按 7 段拆分
|
||||
if (parts.length !== 7) return null
|
||||
const [userIdStr, startDate, endDate, type, format, timestamp, hmac] = parts
|
||||
const userId = parseInt(userIdStr)
|
||||
const ts = parseInt(timestamp)
|
||||
if (isNaN(userId) || isNaN(ts)) return null
|
||||
if (Date.now() - ts > DOWNLOAD_TOKEN_EXPIRY) return null
|
||||
const payload = `${userIdStr}:${startDate}:${endDate}:${type}:${format}:${timestamp}`
|
||||
const expectedHmac = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||
if (hmac !== expectedHmac) return null
|
||||
if (!VALID_TYPES.includes(type) || !VALID_FORMATS.includes(format)) return null
|
||||
if (!DATE_REGEX.test(startDate) || !DATE_REGEX.test(endDate)) return null
|
||||
return { userId, startDate, endDate, type, format }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 准备导出:生成短期签名 token(需登录认证) */
|
||||
router.get('/prepare', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { startDate, endDate, type = 'all', format = 'csv' } = req.query
|
||||
|
||||
// 参数校验
|
||||
if (!startDate || !endDate || !DATE_REGEX.test(startDate as string) || !DATE_REGEX.test(endDate as string)) {
|
||||
return res.status(400).json({ code: 40001, message: '日期参数无效' })
|
||||
}
|
||||
@@ -36,13 +65,36 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
return res.status(400).json({ code: 40001, message: '格式参数无效' })
|
||||
}
|
||||
|
||||
const token = createDownloadToken(req.userId!, startDate as string, endDate as string, type as string, format as string)
|
||||
res.json({ code: 0, data: { token } })
|
||||
} catch (err) {
|
||||
console.error('[Export] prepare error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 流式导出交易数据(使用短期签名 token 认证,无需 Authorization header) */
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 从 query.token 读取签名 token 并验证
|
||||
const token = req.query.token as string
|
||||
if (!token) {
|
||||
return res.status(401).json({ code: 40100, message: '缺少下载令牌' })
|
||||
}
|
||||
const decoded = verifyDownloadToken(token)
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ code: 40100, message: '下载令牌无效或已过期' })
|
||||
}
|
||||
|
||||
const { userId, startDate, endDate, type, format } = decoded
|
||||
|
||||
const isCsv = format === 'csv'
|
||||
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||
const filename = isCsv ? `export_${today}.csv` : `export_${today}.json`
|
||||
|
||||
// 构建查询条件
|
||||
let where = 'WHERE t.user_id = ? AND t.date >= ? AND t.date <= ?'
|
||||
const params: any[] = [req.userId, startDate, endDate]
|
||||
const params: any[] = [userId, startDate, endDate]
|
||||
|
||||
if (type !== 'all') {
|
||||
where += ' AND t.type = ?'
|
||||
@@ -141,7 +193,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
} else {
|
||||
// 大数据量:临时文件流
|
||||
const tmpDir = os.tmpdir()
|
||||
const tmpFile = path.join(tmpDir, `export_${Date.now()}_${req.userId}.${format}`)
|
||||
const tmpFile = path.join(tmpDir, `export_${Date.now()}_${userId}.${format}`)
|
||||
|
||||
// 分批查询并写入临时文件
|
||||
const BATCH_SIZE = 5000
|
||||
|
||||
@@ -157,15 +157,14 @@ router.get('/track/list', requireAdmin, async (req, res) => {
|
||||
)
|
||||
const total = (countResult as any[])[0].total
|
||||
|
||||
// LIMIT/OFFSET 直接嵌入 SQL(mysql2 execute 对这些参数类型要求严格)
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT t.*, u.nickname
|
||||
FROM track_events t
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
WHERE ${where}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT ${pSize} OFFSET ${offset}`,
|
||||
params
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pSize, offset]
|
||||
)
|
||||
|
||||
res.json({
|
||||
|
||||
Reference in New Issue
Block a user