fix(iter-v2): T01 QA fixes - race condition, hook regex, deleted user check

- BUG-1: Pre-commit hook allows .env.example (grep -v filter)
- BUG-2: /auth/refresh uses transaction + FOR UPDATE to prevent race condition
- WARN-1: Auth middleware checks deleted_at with 5-min TTL cache
- WARN-2: PUT /admin/users/:id/status filters deleted_at IS NULL
- WARN-3: Install node-cron@^3.0.3
This commit is contained in:
2026-06-11 09:34:27 +08:00
parent 263a7c4616
commit d170df5c51
7 changed files with 108 additions and 32 deletions

View File

@@ -13,7 +13,8 @@
"express": "^4.18.2",
"express-rate-limit": "^8.5.2",
"multer": "^2.1.1",
"mysql2": "^3.9.0"
"mysql2": "^3.9.0",
"node-cron": "^3.0.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -4792,6 +4793,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-cron": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
"license": "ISC",
"dependencies": {
"uuid": "8.3.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -6143,6 +6156,16 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",

View File

@@ -15,7 +15,8 @@
"express": "^4.18.2",
"express-rate-limit": "^8.5.2",
"multer": "^2.1.1",
"mysql2": "^3.9.0"
"mysql2": "^3.9.0",
"node-cron": "^3.0.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",

View File

@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from 'express'
import { createHmac, timingSafeEqual } from 'crypto'
import { TOKEN_SECRET } from '../config/token'
import pool from '../db/connection'
export interface AuthRequest extends Request {
userId?: number
@@ -11,7 +12,24 @@ const TOKEN_EXPIRY = 2 * 60 * 60 * 1000 // 2 hours
// 不需要认证的路径
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/auth/refresh', '/api/health']
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
/** 已删除用户 ID 缓存5 分钟 TTL */
const deletedUserCache = new Map<number, number>() // userId → cachedAt
async function isUserDeleted(userId: number): Promise<boolean> {
const cached = deletedUserCache.get(userId)
if (cached && Date.now() - cached < 5 * 60 * 1000) {
return true // 仍在缓存中,视为已删除
}
const [rows] = await pool.query('SELECT deleted_at FROM users WHERE id = ?', [userId])
if (!(rows as any[]).length || (rows as any[])[0].deleted_at !== null) {
deletedUserCache.set(userId, Date.now())
return true
}
deletedUserCache.delete(userId)
return false
}
export async function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
// 公开路径跳过认证
if (PUBLIC_PATHS.some(p => req.path === p)) {
return next()
@@ -57,6 +75,11 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
return res.status(401).json({ code: 40100, message: 'token无效' })
}
// 检查用户是否已被软删除
if (await isUserDeleted(userId)) {
return res.status(401).json({ code: 40100, message: '账号已禁用' })
}
req.userId = userId
} catch {
return res.status(401).json({ code: 40100, message: 'token无效' })

View File

@@ -135,9 +135,9 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
}
}
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id])
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ? AND deleted_at IS NULL', [role, req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
return res.status(404).json({ code: 40400, message: '用户不存在或已删除' })
}
res.json({ code: 0 })
} catch (err) {

View File

@@ -135,36 +135,50 @@ router.post('/login', async (req: Request, res: Response) => {
router.post('/refresh', async (req: Request, res: Response) => {
try {
const { refreshToken } = req.body
if (!refreshToken) {
if (!refreshToken || typeof refreshToken !== 'string') {
return res.status(400).json({ code: 40001, message: '缺少refreshToken' })
}
const tokenHash = hashToken(refreshToken)
// 查找有效记录
const [rows] = await pool.query(
'SELECT id, user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW()',
[tokenHash]
)
if (!(rows as any[]).length) {
return res.status(401).json({ code: 40100, message: 'refreshToken无效或已过期' })
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 加行级锁查询,防止并发重放
const [rows] = await conn.query(
'SELECT id, user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW() FOR UPDATE',
[tokenHash]
)
if (!(rows as any[]).length) {
await conn.rollback()
return res.status(401).json({ code: 40100, message: 'refreshToken无效或已过期' })
}
const record = (rows as any[])[0]
// 撤销旧 token
await conn.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = ?', [record.id])
// 生成新的双 token
const newAccessToken = signToken(record.user_id)
const newRefreshToken = generateRefreshToken()
const newHash = hashToken(newRefreshToken)
await conn.query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[record.user_id, newHash]
)
await conn.commit()
res.json({ code: 0, data: { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 7200 } })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
const record = (rows as any[])[0]
// 撤销旧 token
await pool.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = ?', [record.id])
// 生成新的双 token
const newAccessToken = signToken(record.user_id)
const newRefreshToken = generateRefreshToken()
const newHash = hashToken(newRefreshToken)
await pool.query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[record.user_id, newHash]
)
res.json({ code: 0, data: { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 7200 } })
} catch (err: any) {
console.error('[Auth] Refresh token failed:', err)
} catch (err) {
console.error('[Auth] Refresh token error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})

View File

@@ -2498,6 +2498,13 @@ nice-try@^1.0.4:
resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
node-cron@^3.0.3:
version "3.0.3"
resolved "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz"
integrity sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==
dependencies:
uuid "8.3.2"
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz"
@@ -3212,6 +3219,11 @@ utils-merge@1.0.1:
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid@8.3.2:
version "8.3.2"
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
v8-to-istanbul@^9.0.1:
version "9.3.0"
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"