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
|
#!/bin/sh
|
||||||
# Prevent .env files from being committed
|
# Prevent .env files (except .env.example) from being committed
|
||||||
if git diff --cached --name-only | grep -E '\.env$|\.env\.'; then
|
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 "❌ 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."
|
echo " If you need to update .env.example, that's allowed."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
25
server/package-lock.json
generated
25
server/package-lock.json
generated
@@ -13,7 +13,8 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"mysql2": "^3.9.0"
|
"mysql2": "^3.9.0",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
@@ -4792,6 +4793,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -6143,6 +6156,16 @@
|
|||||||
"node": ">= 0.4.0"
|
"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": {
|
"node_modules/v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"mysql2": "^3.9.0"
|
"mysql2": "^3.9.0",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from 'express'
|
import { Request, Response, NextFunction } from 'express'
|
||||||
import { createHmac, timingSafeEqual } from 'crypto'
|
import { createHmac, timingSafeEqual } from 'crypto'
|
||||||
import { TOKEN_SECRET } from '../config/token'
|
import { TOKEN_SECRET } from '../config/token'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
|
||||||
export interface AuthRequest extends Request {
|
export interface AuthRequest extends Request {
|
||||||
userId?: number
|
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']
|
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)) {
|
if (PUBLIC_PATHS.some(p => req.path === p)) {
|
||||||
return next()
|
return next()
|
||||||
@@ -57,6 +75,11 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
return res.status(401).json({ code: 40100, message: 'token无效' })
|
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查用户是否已被软删除
|
||||||
|
if (await isUserDeleted(userId)) {
|
||||||
|
return res.status(401).json({ code: 40100, message: '账号已禁用' })
|
||||||
|
}
|
||||||
|
|
||||||
req.userId = userId
|
req.userId = userId
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(401).json({ code: 40100, message: 'token无效' })
|
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) {
|
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 })
|
res.json({ code: 0 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -135,36 +135,50 @@ router.post('/login', async (req: Request, res: Response) => {
|
|||||||
router.post('/refresh', async (req: Request, res: Response) => {
|
router.post('/refresh', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { refreshToken } = req.body
|
const { refreshToken } = req.body
|
||||||
if (!refreshToken) {
|
if (!refreshToken || typeof refreshToken !== 'string') {
|
||||||
return res.status(400).json({ code: 40001, message: '缺少refreshToken' })
|
return res.status(400).json({ code: 40001, message: '缺少refreshToken' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = hashToken(refreshToken)
|
const tokenHash = hashToken(refreshToken)
|
||||||
// 查找有效记录
|
const conn = await pool.getConnection()
|
||||||
const [rows] = await pool.query(
|
|
||||||
'SELECT id, user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW()',
|
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]
|
[tokenHash]
|
||||||
)
|
)
|
||||||
if (!(rows as any[]).length) {
|
if (!(rows as any[]).length) {
|
||||||
|
await conn.rollback()
|
||||||
return res.status(401).json({ code: 40100, message: 'refreshToken无效或已过期' })
|
return res.status(401).json({ code: 40100, message: 'refreshToken无效或已过期' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = (rows as any[])[0]
|
const record = (rows as any[])[0]
|
||||||
|
|
||||||
// 撤销旧 token
|
// 撤销旧 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
|
// 生成新的双 token
|
||||||
const newAccessToken = signToken(record.user_id)
|
const newAccessToken = signToken(record.user_id)
|
||||||
const newRefreshToken = generateRefreshToken()
|
const newRefreshToken = generateRefreshToken()
|
||||||
const newHash = hashToken(newRefreshToken)
|
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))',
|
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
||||||
[record.user_id, newHash]
|
[record.user_id, newHash]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
res.json({ code: 0, data: { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 7200 } })
|
res.json({ code: 0, data: { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 7200 } })
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
console.error('[Auth] Refresh token failed:', 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: '服务器错误' })
|
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"
|
resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz"
|
||||||
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
|
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:
|
node-int64@^0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
|
||||||
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
|
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:
|
v8-to-istanbul@^9.0.1:
|
||||||
version "9.3.0"
|
version "9.3.0"
|
||||||
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
|
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user