From d170df5c517428af93218f9d623366e5f2fc2ef8 Mon Sep 17 00:00:00 2001 From: wangxiaogang <1433729587@qq.com> Date: Thu, 11 Jun 2026 09:34:27 +0800 Subject: [PATCH] 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 --- .githooks/pre-commit | 7 ++-- server/package-lock.json | 25 +++++++++++++- server/package.json | 3 +- server/src/middleware/auth.ts | 25 +++++++++++++- server/src/routes/admin.ts | 4 +-- server/src/routes/auth.ts | 64 +++++++++++++++++++++-------------- server/yarn.lock | 12 +++++++ 7 files changed, 108 insertions(+), 32 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 010bc38..01895d9 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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 diff --git a/server/package-lock.json b/server/package-lock.json index a1eb8ad..a792fe7 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index d72c97a..a5c2ac4 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 18bce15..bdd4c18 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -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() // userId → cachedAt + +async function isUserDeleted(userId: number): Promise { + 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无效' }) diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index d6e23e1..72ff7f0 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -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) { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index e57a314..6ebcf83 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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: '服务器错误' }) } }) diff --git a/server/yarn.lock b/server/yarn.lock index 30838f3..8560557 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -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"