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:
@@ -1,7 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Prevent .env files from being committed
|
||||
if git diff --cached --name-only | grep -E '\.env$|\.env\.'; then
|
||||
# Prevent .env files (except .env.example) from being committed
|
||||
STAGED_ENV=$(git diff --cached --name-only | grep -E '\.env$|\.env\.' | grep -v '\.env\.example$')
|
||||
if [ -n "$STAGED_ENV" ]; then
|
||||
echo "❌ ERROR: Attempting to commit .env file(s). This is blocked for security."
|
||||
echo " Blocked files:"
|
||||
echo "$STAGED_ENV"
|
||||
echo " If you need to update .env.example, that's allowed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
25
server/package-lock.json
generated
25
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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无效' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()',
|
||||
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 pool.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = ?', [record.id])
|
||||
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 pool.query(
|
||||
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: any) {
|
||||
console.error('[Auth] Refresh token failed:', err)
|
||||
} catch (err) {
|
||||
await conn.rollback()
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Auth] Refresh token error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user