- 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
90 lines
3.0 KiB
TypeScript
90 lines
3.0 KiB
TypeScript
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
|
||
}
|
||
|
||
const TOKEN_EXPIRY = 2 * 60 * 60 * 1000 // 2 hours
|
||
|
||
// 不需要认证的路径
|
||
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/auth/refresh', '/api/health']
|
||
|
||
/** 已删除用户 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()
|
||
}
|
||
|
||
// 从 Authorization header 获取 token
|
||
const authHeader = req.headers.authorization
|
||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||
return res.status(401).json({ code: 40100, message: '未登录' })
|
||
}
|
||
|
||
const token = authHeader.slice(7)
|
||
try {
|
||
const decoded = Buffer.from(token, 'base64').toString('utf-8')
|
||
const parts = decoded.split(':')
|
||
if (parts.length !== 3) {
|
||
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||
}
|
||
|
||
const [userIdStr, timestamp, signature] = parts
|
||
const userId = parseInt(userIdStr)
|
||
const ts = parseInt(timestamp)
|
||
|
||
if (isNaN(userId) || isNaN(ts)) {
|
||
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||
}
|
||
|
||
// Check expiry and reject future timestamps
|
||
const now = Date.now()
|
||
if (now - ts > TOKEN_EXPIRY) {
|
||
return res.status(401).json({ code: 40101, message: 'token已过期' })
|
||
}
|
||
if (ts > now + 60000) {
|
||
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||
}
|
||
|
||
// Verify HMAC signature (constant-time comparison)
|
||
const payload = `${userId}:${timestamp}`
|
||
const expectedSig = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||
const sigBuf = Buffer.from(signature, 'hex')
|
||
const expectedBuf = Buffer.from(expectedSig, 'hex')
|
||
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
||
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无效' })
|
||
}
|
||
|
||
next()
|
||
}
|