13 — 中间件进阶

3 阅读4分钟

13.2 鉴权中间件

最常用的中间件之一:检查用户是否登录。

实现思路

  1. 从请求中取出 token(从 header、cookie 或 query 里)
  2. 用 token 查找对应的用户/会话
  3. 找到就把用户信息挂到 request 上,继续执行
  4. 找不到就返回未登录错误

代码实现

src/middlewares/auth.ts

import type { RequestHandler } from 'express';
import { sendError } from '@/utils/response.js';
import type { SessionModel } from '@/models/session/session-model.js';
import type { UserModel } from '@/models/user/user-model.js';

// 白名单:不需要登录的接口
const WHITELIST = [
  'POST /api/v1/auth/login',
  'POST /api/v1/auth/register',
  'GET /api/v1/health',
];

export function requireAuth(sessions: SessionModel, users: UserModel): RequestHandler {
  return (req, res, next) => {
    // 白名单直接放行
    const key = `${req.method} ${req.path}`;
    if (WHITELIST.includes(key)) {
      return next();
    }

    // 从 header 中取 token
    const token = req.headers['x-token'] as string | undefined;
    if (!token) {
      return sendError(res, { status: 4, msg: '未登录', httpStatus: 401 });
    }

    // 查找会话
    const session = sessions.findByToken(token);
    if (!session) {
      return sendError(res, { status: 4, msg: '登录已过期', httpStatus: 401 });
    }

    // 查找用户
    const user = users.findById(session.userId);
    if (!user) {
      return sendError(res, { status: 4, msg: '用户不存在', httpStatus: 401 });
    }

    // 把用户信息挂到 request 上,后面的中间件和路由都能用
    (req as any).currentUser = user;
    (req as any).sessionToken = token;

    next();
  };
}

类型扩展

用 TypeScript 的话,最好扩展一下 Request 类型,而不是用 as any

src/types/express.d.ts

import type { User } from '@/models/user/user.js';

declare global {
  namespace Express {
    interface Request {
      currentUser?: User;
      sessionToken?: string;
    }
  }
}

export {};

这样后面在 Controller 里就能直接用 req.currentUser,还有类型提示。

使用方式

// app.ts 里注册
app.use('/api', requireAuth(models.sessions, models.users));

或者只给部分路由加:

// 路由文件里
const router = Router();

// 不需要登录的
router.post('/login', authController.login);

// 需要登录的
router.use(requireAuth(sessions, users));
router.get('/profile', userController.profile);

13.3 权限校验中间件

鉴权是"有没有登录",权限是"有没有权限做这件事"。

基于角色的权限控制(RBAC)

// src/middlewares/require-role.ts
import type { RequestHandler } from 'express';
import { sendError } from '@/utils/response.js';

export function requireRole(...roles: string[]): RequestHandler {
  return (req, res, next) => {
    const user = req.currentUser;
    if (!user) {
      return sendError(res, { status: 4, msg: '未登录', httpStatus: 401 });
    }

    if (!roles.includes(user.role)) {
      return sendError(res, { status: 3, msg: '无权限', httpStatus: 403 });
    }

    next();
  };
}

使用:

// 只有管理员能删除项目
router.delete('/:id', requireRole('admin'), projectController.remove);

13.4 日志中间件

记录每个请求的方法、URL、状态码、耗时。

src/middlewares/logger.ts

import type { RequestHandler } from 'express';

export const requestLogger: RequestHandler = (req, res, next) => {
  const start = Date.now();
  const { method, url, ip } = req;

  res.on('finish', () => {
    const duration = Date.now() - start;
    const { statusCode } = res;
    const log = `[${method}] ${url} ${statusCode} - ${duration}ms - ${ip}`;

    // 根据状态码决定日志级别
    if (statusCode >= 500) {
      console.error(log);
    } else if (statusCode >= 400) {
      console.warn(log);
    } else {
      console.log(log);
    }
  });

  next();
};

生产环境可以把日志写入文件或上报到日志服务。常用的库:

  • morgan:成熟的访问日志中间件
  • winston / pino:通用日志库,支持多级别、多输出

13.5 限流中间件

防止接口被刷,限制某个 IP 在一段时间内的请求次数。

import type { RequestHandler } from 'express';
import { sendError } from '@/utils/response.js';

// 简单的内存限流(不适合分布式部署)
export function rateLimit(options: { max: number; windowMs: number }): RequestHandler {
  const hits = new Map<string, { count: number; resetAt: number }>();

  return (req, res, next) => {
    const key = req.ip!;
    const now = Date.now();
    const record = hits.get(key);

    // 窗口过期,重置
    if (!record || record.resetAt < now) {
      hits.set(key, { count: 1, resetAt: now + options.windowMs });
      return next();
    }

    // 超限
    if (record.count >= options.max) {
      return sendError(res, {
        status: 429,
        msg: '请求过于频繁,请稍后再试',
        httpStatus: 429,
      });
    }

    record.count++;
    next();
  };
}

使用:

// 登录接口限流:1 分钟最多 5 次
router.post('/login', rateLimit({ max: 5, windowMs: 60_000 }), authController.login);

生产环境推荐用 express-rate-limit 库,功能更完善。分布式部署需要用 Redis 存储。


13.6 CORS 中间件

跨域资源共享,前端后端不同域名时需要配置。

最简单的方式:用 cors 库。

npm install cors
npm install -D @types/cors
import cors from 'cors';

// 允许所有来源(开发环境用)
app.use(cors());

// 或者更严格的配置
app.use(
  cors({
    origin: ['https://example.com'],  // 允许的前端域名
    credentials: true,                 // 允许带 Cookie
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
  }),
);

13.7 错误处理中间件

放在所有路由之后,专门捕获错误。

import type { ErrorRequestHandler } from 'express';
import { AppError } from '@/errors/app-error.js';
import { sendError } from '@/utils/response.js';

export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
  // 业务错误
  if (error instanceof AppError) {
    return sendError(res, {
      status: error.status,
      msg: error.message,
    });
  }

  // 参数校验错误(如 zod 抛出的)
  if (error.name === 'ZodError') {
    return sendError(res, {
      status: 2,
      msg: '参数错误',
      data: error.errors,
    });
  }

  // 未知错误
  console.error('[Server Error]', error);
  return sendError(res, {
    status: 500,
    msg: '服务器内部错误',
    httpStatus: 500,
  });
};

注意:错误处理中间件必须是 4 个参数 (error, req, res, next),Express 才能识别。


13.8 中间件的执行顺序

一张图总结:

请求进来
  ↓
1. CORS / Helmet(安全头)
  ↓
2. express.json()(解析请求体)
  ↓
3. Cookie / Session 解析
  ↓
4. 请求日志
  ↓
5. 限流
  ↓
6. 鉴权(登录检查)
  ↓
7. 权限校验
  ↓
8. 业务路由
  ↓
9. 404
  ↓
10. 错误处理

前面的中间件可以决定是否继续往下走。比如鉴权失败就直接返回,不会到业务路由。


13.9 小结

  • 鉴权中间件:检查登录状态,把用户信息挂到 request 上
  • 权限中间件:检查是否有操作权限
  • 日志中间件:记录请求信息和耗时
  • 限流中间件:防止接口被恶意刷
  • CORS 中间件:处理跨域
  • 错误处理中间件:统一捕获和返回错误
  • 中间件的执行顺序很重要,按代码顺序依次执行