威胁图谱挂好了,这一节装甲上弹:前半场用 koa-helmet 批量配安全头、给 Cookie 会话补 CSRF 防护;后半场实现全册最完整的一段中间件——JWT 鉴权层,把签发、校验、过期刷新、角色检查、密钥轮换这些工程问题一次讲全。第 3 章主线任务里的"鉴权层",在这一节交出成品。
第 3.2 节装过 helmet,这里展开它到底设了哪些头、各防什么:
const helmet = require('koa-helmet'); app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], // 一切资源默认只许本站 scriptSrc: ["'self'"], // 脚本只许本站:XSS 注入的脚本无处执行 styleSrc: ["'self'", "'unsafe-inline'"], // 样式常见内联,按需放开 }, }, hsts: { maxAge: 31536000, includeSubDomains: true }, // 强制 HTTPS 一年,覆盖子域 frameguard: { action: 'deny' }, // 禁止被 iframe 嵌套:防点击劫持 noSniff: true, // 禁止浏览器猜类型:防 MIME 混淆攻击 }));
按"防什么"记清单比按头名记高效:CSP 防 XSS 落地执行;HSTS 防降级劫持(强制走 HTTPS,7.3 节配套);frameguard 防点击劫持;noSniff 防 MIME 混淆。页面服务全套开,纯 JSON API 可以只留 HSTS 与 noSniff(没有页面就没有脚本注入面,CSP 意义有限)。
7.1 节讲过原理,这里给最小实现——双提交 Cookie 方案:token 一份放 Cookie、一份放表单或头里,服务端比对两者,攻击者能借浏览器带 Cookie,却读不到 Cookie 内容,也就伪造不出配对的头:
const crypto = require('crypto'); // 签发:进入表单页时种下 token app.use(async (ctx, next) => { if (!ctx.cookies.get('csrfToken')) { ctx.cookies.set('csrfToken', crypto.randomBytes(24).toString('hex'), { httpOnly: false, // 前端 JS 要读它放进请求头,所以不能 httpOnly sameSite: 'lax', // SameSite 本身就是一层 CSRF 缓冲 secure: true, }); } await next(); }); // 校验:只拦会改变状态的请求 app.use(async (ctx, next) => { if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(ctx.method)) { const fromHeader = ctx.get('x-csrf-token'); const fromCookie = ctx.cookies.get('csrfToken'); if (!fromHeader || fromHeader !== fromCookie) { ctx.throw(403, 'CSRF 校验失败'); } } await next(); });
SameSite 属性值得单独强调:现代浏览器默认把 Cookie 收紧为 SameSite=Lax,跨站 POST 不再自动携带 Cookie——协议层面已经替你挡了大半 CSRF。但这不是豁免理由:存量浏览器、被嵌入的第三方场景仍需 token 兜底。Cookie 会话:SameSite 加 token 双保险;Header 凭证:两者都不需要。
进入本节主菜。先立概念:JWT 是一段自包含的签名字符串,分三段——头(算法)、载荷(用户 ID、角色、过期时间等声明)、签名(服务端用密钥对前两段签名)。服务端不存会话,收到 token 验签即可确认身份,这就是"无状态鉴权",水平扩展时不需要共享会话存储。
签发端(登录接口):
const jwt = require('jsonwebtoken'); router.post('/auth/login', validate(loginSchema), async (ctx) => { const { username, password } = ctx.validated; const user = await userRepo.findByUsername(username); if (!user || !(await bcrypt.compare(password, user.passwordHash))) { throw new Unauthorized('用户名或密码错误'); // 模糊提示:不暴露哪个字段错 } const token = jwt.sign( { sub: user.id, role: user.role }, // 载荷:只放身份必需字段 process.env.JWT_SECRET, { expiresIn: '15m', issuer: 'koa-onion' } // 短有效期是安全的核心参数 ); const refresh = jwt.sign( { sub: user.id, typ: 'refresh' }, process.env.JWT_SECRET, { expiresIn: '7d' } ); ctx.body = { token, refresh, expiresIn: 900 }; });
校验端(鉴权中间件,第 3.1 节的三形态在这里全部出现):
const { Unauthorized, Forbidden } = require('../errors'); // Unauthorized 与 2.4 节的 BadRequest 同出 HttpError 基类: // class Unauthorized extends HttpError { constructor(message = '未认证') { super(401, 'UNAUTHORIZED', message); } } function auth(options = {}) { const { roles } = options; return async (ctx, next) => { const header = ctx.get('authorization'); if (!header.startsWith('Bearer ')) { throw new Unauthorized('缺少 Bearer 凭证'); // 截停形态一:没带凭证 } let payload; try { payload = jwt.verify(header.slice(7), process.env.JWT_SECRET, { issuer: 'koa-onion', }); } catch (err) { const msg = err.name === 'TokenExpiredError' ? '凭证已过期' : '凭证无效'; throw new Unauthorized(msg); // 截停形态二:验签失败 } if (payload.typ === 'refresh') { throw new Unauthorized('刷新凭证不能访问业务接口'); // 截停形态三:凭证类型错 } ctx.state.user = { id: payload.sub, role: payload.role }; // 放行:身份入 state if (roles && !roles.includes(payload.role)) { throw new Forbidden(); // 角色不符 } await next(); }; } // 路由上按需指定角色(4.1 节路由级中间件的实战) router.delete('/users/:id', auth({ roles: ['admin'] }), deleteUser); router.get('/me', auth(), getProfile);

教程代码到这里,工程问题才刚开始。过期与刷新:业务 token 设短(15 分钟),泄露后的暴露窗口就短;刷新 token 设长(7 天)且载荷里带 typ: 'refresh' 与业务 token 区分,专用刷新接口验它、发新业务 token——上面代码里"刷新凭证不能访问业务接口"那一行拦截的就是混用攻击。载荷纪律:只放身份必需字段(sub、role),不放大对象也不放敏感信息——JWT 载荷只是 Base64 编码,任何人都能解码阅读,把手机号放进去等于明文广播。密钥轮换:密钥泄露或例行轮换时,用 kid(key ID)机制平滑过渡——签发时在头里带 kid,校验端按 kid 查对应密钥,新旧密钥并存一个过期周期,全部旧 token 自然过期后下线旧密钥:
// 多密钥并存:轮换期间新旧 token 都能通过 const keys = { '2024a': process.env.JWT_SECRET_OLD, '2024b': process.env.JWT_SECRET }; const kidOf = (header) => keys[header.kid]; payload = jwt.verify(token, (header) => keys[header.kid], { issuer: 'koa-onion' });
⚠️ 常见坑:用
jwt.decode代替jwt.verify。decode 只做 Base64 解码不验签,等于把"声称的身份"直接当真——任何伪造的 token 都能通过。校验端只允许出现 verify,decode 仅用于调试查看载荷。
传输与数据两道门还没关:HTTPS 怎么配、经反代时信任怎么给、输入验证的深度清单长什么样——下一节收口防护层。