实战:认证中间件与权限检查

客户端登录后,通过请求头携带 Access Token:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

认证中间件负责验证身份,业务处理函数负责使用身份,授权中间件负责检查权限。

1. 扩展 Express Request 类型

在项目现有 types/ 目录中可以定义:

// types/express.d.ts
declare global {
  namespace Express {
    interface Request {
      auth?: {
        userId: string;
        sessionId: string;
        role: "user" | "admin";
      };
    }
  }
}

export {};

当前 tsconfig.json 已包含 types/**/*.d.ts,并且 ts-node.filestrue,适合加载这种声明合并。

2. 解析 Bearer Token

function readBearerToken(
  authorization: string | undefined
): string | null {
  if (!authorization) {
    return null;
  }

  const [scheme, token, extra] = authorization.trim().split(/\s+/);

  if (
    scheme?.toLowerCase() !== "bearer" ||
    !token ||
    extra !== undefined
  ) {
    return null;
  }

  return token;
}

不要只使用 replace("Bearer ", ""),它容易接受格式不明确的请求。

3. Payload 类型守卫

import jwt from "jsonwebtoken";

type UserRole = "user" | "admin";

interface AccessClaims {
  sub: string;
  sessionId: string;
  tokenType: "access";
  role: UserRole;
}

function isAccessClaims(
  value: string | jwt.JwtPayload
): value is jwt.JwtPayload & AccessClaims {
  return (
    typeof value !== "string" &&
    typeof value.sub === "string" &&
    typeof value.sessionId === "string" &&
    value.tokenType === "access" &&
    (value.role === "user" || value.role === "admin")
  );
}

verify() 验证密码学真实性和标准 Claims,但业务自定义字段仍需检查。

4. 认证中间件

import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";

const accessTokenSecret = readRequiredEnv("JWT_ACCESS_SECRET");

export function authenticateAccessToken(
  request: Request,
  response: Response,
  next: NextFunction
): void {
  const token = readBearerToken(request.get("authorization"));

  if (!token) {
    response.status(401).json({ message: "请先登录" });
    return;
  }

  try {
    const decoded = jwt.verify(token, accessTokenSecret, {
      algorithms: ["HS256"],
      issuer: "nloop-api",
      audience: "nloop-client",
    });

    if (!isAccessClaims(decoded)) {
      response.status(401).json({ message: "登录凭证无效" });
      return;
    }

    request.auth = {
      userId: decoded.sub,
      sessionId: decoded.sessionId,
      role: decoded.role,
    };

    next();
  } catch (error: unknown) {
    if (error instanceof jwt.TokenExpiredError) {
      response.status(401).json({
        message: "登录凭证已过期",
        code: "ACCESS_TOKEN_EXPIRED",
      });
      return;
    }

    if (error instanceof jwt.JsonWebTokenError) {
      response.status(401).json({ message: "登录凭证无效" });
      return;
    }

    next(error);
  }
}

5. /profile

export function getProfile(
  request: Request,
  response: Response
): void {
  const auth = request.auth;

  if (!auth) {
    response.status(401).json({ message: "请先登录" });
    return;
  }

  const user = users.find((item) => item.id === auth.userId);

  if (!user) {
    response.status(401).json({ message: "用户状态无效" });
    return;
  }

  response.status(200).json({
    user: {
      id: user.id,
      username: user.username,
      role: user.role,
      createdAt: user.createdAt,
    },
  });
}

路由:

authRouter.get(
  "/profile",
  authenticateAccessToken,
  getProfile
);

即使 Token 中有用户 ID,查询个人资料时仍需查数据库,因为资料和账号状态可能已变化。

6. 授权中间件

认证回答“你是谁”,授权回答“你是否可以执行该操作”。

export function requireRole(...allowedRoles: UserRole[]) {
  return function roleMiddleware(
    request: Request,
    response: Response,
    next: NextFunction
  ): void {
    const auth = request.auth;

    if (!auth) {
      response.status(401).json({ message: "请先登录" });
      return;
    }

    if (!allowedRoles.includes(auth.role)) {
      response.status(403).json({ message: "没有操作权限" });
      return;
    }

    next();
  };
}

使用:

authRouter.delete(
  "/admin/users/:id",
  authenticateAccessToken,
  requireRole("admin"),
  deleteUser
);

7. 401403

没有通行证 -> 401
有普通区通行证但想进机房 -> 403

8. 是否每次检查会话

上面的中间件只验证 JWT,不查 authSessions,因此会话退出后,旧 Access Token 仍可使用到过期。这是短期 JWT 的常见折中。

高风险接口可以进一步查询会话:

const session = authSessions.find(
  (item) => item.id === decoded.sessionId
);

if (!session || session.revokedAt !== null) {
  response.status(401).json({ message: "登录会话已失效" });
  return;
}

代价是每次请求都需要查询数据库或缓存。系统应根据性能和即时撤销要求做选择,而不是认为只有一种正确方案。

9. 不要使用 decode() 认证

// 错误
const payload = jwt.decode(token);
request.auth = payload as Express.Request["auth"];

decode() 不验证签名。攻击者可以自己构造 role: "admin"。认证链路必须使用 verify()