实战:认证中间件与权限检查
客户端登录后,通过请求头携带 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.files 为 true,适合加载这种声明合并。
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. 401 与 403
401 Unauthorized:没有有效身份,例如未携带、过期或签名错误。403 Forbidden:身份有效,但权限不足。
没有通行证 -> 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()。