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:
2026-06-11 08:58:24 +08:00
parent ae9b415822
commit 61f9b33f8c
13 changed files with 2178 additions and 138 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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']

View File

@@ -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')

View File

@@ -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 {

View File

@@ -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

View File

@@ -157,15 +157,14 @@ router.get('/track/list', requireAdmin, async (req, res) => {
)
const total = (countResult as any[])[0].total
// LIMIT/OFFSET 直接嵌入 SQLmysql2 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({