
去年底给自己定了个小目标15天系统过一遍egg.js今年排期太紧拖到最近才真正启动。现在到了第11天说实话前面十天的内容已经消化得差不多了今天本来计划里的主题是“鉴权与安全”早上翻了下笔记越看越觉得这块有料可写干脆把今天的实战过程完整记录下来。这篇东西不打算写成教程目录式的罗列而是顺着我第11天的实际操作流程走一遍先回顾前十天的学习主线再聊今天动手做的用户鉴权、权限拦截、安全防护这几个重头戏最后把过程中踩的坑一并交代清楚。第11天刚好是15天计划里过了三分之二的位置前期的框架基础、路由、控制器、服务、数据库操作都摸过一轮了这个时间点非常适合把鉴权这种偏业务也偏安全的模块串起来既能把前面的知识点揉到一起又能真正做出一个可用的接口体系。1. 学习进度回顾与第11天的重点定位1.1 前10天都学了什么整个15天计划我没有按照官方文档的目录顺序去啃而是按一条从“能跑”到“跑得稳”的路线推进的。第1到第3天主要在建环境Node.js版本、egg-init初始化项目、目录结构的约定以及开发模式下的热更新机制。egg.js这套框架给我最大的第一印象是“约定优于配置”贯彻得特别彻底app/controller、app/service、app/router这些目录有严格的位置要求一开始觉得死板后面发现团队协作的时候这套规矩太省心了新成员不需要问“控制器放哪”自然就往app/controller里放。第4到第6天集中处理了接口层的三件套路由定义、控制器编写、服务层拆分。顺手把egg.js的依赖注入机制捋了一遍ctx这个上下文对象到底封装了什么request和response怎么用helper里可以去哪找工具方法。到第6天结束我已经能独立写一个完整的增删改查接口了Postman里测得很顺。第7到第10天开始碰数据层和中间件。数据这块我用的是egg-sequelize插件配合MySQL模型定义、迁移、关联查询都过了一遍。中间件部分是我前几天学得比较吃力的内容因为egg.js的中间件是基于Koa的洋葱模型和普通函数调用不一样逻辑顺序要绕一下才转得过来。好在第9天动手写了一个简单的日志中间件把请求耗时、状态码、IP这些信息记下来才算真正摸到中间件的工作机制。1.2 为什么第11天安排鉴权和安全规划学习方案的时候我把鉴权放在第11天而不是更靠前或靠后是有原因的。第11天之前已经有了完整的接口开发能力但这套接口是“裸奔”的任何人知道地址就能调。鉴权是接口从玩具变成生产级产品必须跨过的坎。如果把前面十天的内容比作把一栋房子的骨架和墙砌好那第11天做的事情就是装门锁和安监控没有这道工序房子能住人但谁都能进。另一个原因是鉴权这个主题天然能把前面散落的知识点串起来。你写登录要查数据库吧这就用到sequelize你做登录接口要走路由和控制器这是前几天的基本功你写权限拦截中间件又得深入理解中间件的执行时机。一天的内容能同时复习和强化这么多知识性价比很高。我给自己定的第11天目标是搞明白JWT和Session两种主流方案的差异为当前项目选出合适的方案在egg.js里完整实现一套基于JWT的登录、鉴权、主动退出流程掌握egg.js内置安全防护配置的核心项特别是CSRF的取舍设计一个能区分普通用户和管理员的可扩展权限模型2. 鉴权方案怎么选Session会话还是JWT令牌2.1 两种方案的底层逻辑对比关于服务端鉴权绕不开的两个词就是Session和JWT。我今天下午把两种实现都跑了一遍demo拉通对比一下各自的思路。Session方案的核心是“服务端记住你是谁”。用户登录成功后服务端生成一个sessionId把这个id种到浏览器的Cookie里同时在本地的内存或Redis里存一份session数据里面放着userId、角色之类的东西。后面每次请求浏览器自动带上Cookie服务端拿着sessionId查一下存储就知道你是谁了。好处是随时可以踢人、可以控制过期时间服务端想把某个会话删掉就删掉。缺点也很明显存储是有状态的多实例部署的时候session不共享就会出错得额外引入Redis而且浏览器端Cookie在某些跨域场景下处理起来很麻烦。JWT方案则换了一套思路“服务端给你一张带签名的通行证”。用户登录成功服务端用自己的密钥签发一个JSON Web Token这个token里可以携带用户ID、过期时间、角色等信息然后再用密钥做签名。客户端把token存起来每次请求放在Authorization请求头里发给服务端。服务端只需要用密钥验证一下签名有没有被篡改、过期时间到没到就能确认请求方身份。JWT是无状态的服务端不需要存任何会话记录多实例部署天然友好前后端分离场景下也比较顺手。用生活中类比的话说Session就像你进公司刷工卡门卫的内部系统里记录着你这张卡对应哪个员工JWT则像一张防伪的演唱会门票验票员不用查后台拿起来看一眼防伪水印就知道真伪。2.2 egg.js里的选型建议今天做选型的时候我的原则很简单看项目形态不盲目追新。如果是传统服务端渲染的多页面应用比如论坛、后台管理系统这类直接由egg.js渲染页面的项目Session方案更自然配合egg.js自带的session插件可以开箱即用。尤其是后台管理系统通常要求账号可以同时登录多台设备还是单端登录、能否强制下线Session方案实现这些需求更直接。如果是纯前后端分离或者主要提供API给小程序、App去调用那JWT是更合适的选择。最近的模拟项目X恰好就是这样一个形态前端是一个独立的Vue应用后端通过接口提供数据所以我今天直接选了JWT方案。需要提醒一句JWT虽然用起来方便但也不是银弹。它最大的坑是“没法主动失效”token签发出去之后在过期之前除非你把密钥换掉否则服务端没有任何办法让某个token作废。业务上遇到用户改密码、账号被顶下线、被管理员封禁这类场景JWT处理起来就比较别扭。互联网上讨论这个问题的主流做法是维护一个黑名单或白名单把token唯一标识存到Redis里但这又会让JWT失去无状态的优势。今天的最终结论核心业务接口用JWT做无状态鉴权特殊情况比如管理员踢人就靠Redis辅助做token黑名单。这个方案兼顾了扩展性和可操作性符合大部分中小型项目的需求。3. 手把手实现一套JWT鉴权全流程3.1 必要的依赖和基础配置正式开始写代码之前我先新建了一个模拟项目X的测试工程在已有egg.js项目基础上补齐了依赖npm i egg-jwt --save npm i bcryptjs --saveegg-jwt是egg.js生态里封装好的JWT插件底层依赖jsonwebtoken库。用插件而不是直接引jsonwebtoken好处是它能自动挂载到app.jwt配合egg.js的插件机制配置和调用方式更规矩。bcryptjs是密码哈希库用来处理用户密码的加密存储和比对。密码安全这事我在第11天不敢含糊明文存密码这种事情在自己练习项目里糊弄一下还行真要拿到业务里去那就是重大事故。依赖装好之后先注册插件。在config/plugin.js里加上exports.jwt { enable: true, package: egg-jwt, };然后去config/config.default.js里配置jwt参数config.jwt { secret: 这里填一个足够复杂的随机字符串别用123456这种, expiresIn: 2h };secret这个东西是签名的密钥所有token的签发和验证都依赖它。一旦泄露攻击者可以伪造任意用户身份的token。我习惯用随机生成的字符串长度不少于32位放在配置文件里并且加入到.gitignore不在代码仓库中明文共享。expiresIn是token有效期我暂时设成了2小时。实际项目里可以按业务调登录态要求严的就短一点方便用户长期停留的可以到7天但建议配合token刷新机制这块后面讲踩坑的时候展开。3.2 用户登录接口与密码校验鉴权流程的起点是登录接口。用户提交用户名和密码服务端比对通过后签发token。先准备User模型和Service层的查询方法。模拟项目X的用户表结构很简单核心字段是username和password。Service层方法use strict; const Service require(egg).Service; class UserService extends Service { async findByUsername(username) { const { ctx } this; return await ctx.model.User.findOne({ where: { username } }); } } module.exports UserService;控制器里处理登录逻辑use strict; const Controller require(egg).Controller; class UserController extends Controller { async login() { const { ctx, app } this; const { username, password } ctx.request.body; if (!username || !password) { ctx.body { code: 400, message: 用户名和密码不能为空 }; return; } const user await ctx.service.user.findByUsername(username); if (!user) { ctx.body { code: 401, message: 用户名或密码错误 }; return; } const isMatch await ctx.helper.comparePassword(password, user.password); if (!isMatch) { ctx.body { code: 401, message: 用户名或密码错误 }; return; } const token app.jwt.sign( { id: user.id, username: user.username, role: user.role }, app.config.jwt.secret, { expiresIn: app.config.jwt.expiresIn } ); ctx.body { code: 0, message: 登录成功, data: { token, userInfo: { id: user.id, username: user.username, role: user.role } } }; } } module.exports UserController;这里有个细节值得注意登录失败时不管是“用户不存在”还是“密码错误”我统一返回同一个提示“用户名或密码错误”。这样做的原因是防止恶意用户通过接口的返回差异去枚举有效用户名。安全设计上很多时候“不知道是否存在”比“明确告诉你不存在”要安全得多。密码比对我封装了一个helper方法内部用bcryptjs// app/extend/helper.js const bcrypt require(bcryptjs); module.exports { hashPassword(password) { const salt bcrypt.genSaltSync(10); return bcrypt.hashSync(password, salt); }, comparePassword(password, hash) { return bcrypt.compareSync(password, hash); } };bcrypt这个库的哈希是自带盐的两次哈希同一个密码得到的结果不同但验证的时候都能通过这比简单的MD5加盐拼接要稳妥得多。3.3 身份验证中间件的编写token签发了接下来要解决的是“受保护接口怎么识别token”。egg.js的做法是写一个中间件统一拦截请求验证token合法性然后放行或拦截。在app/middleware目录下新建auth.jsuse strict; module.exports () { return async function auth(ctx, next) { const authHeader ctx.request.header.authorization; if (!authHeader || !authHeader.startsWith(Bearer )) { ctx.status 401; ctx.body { code: 401, message: 未登录或者请求头不带token }; return; } const token authHeader.replace(Bearer , ); try { const decoded ctx.app.jwt.verify(token, ctx.app.config.jwt.secret); ctx.state.user decoded; } catch (err) { ctx.status 401; ctx.body { code: 401, message: token无效或已过期 }; return; } await next(); }; };中间件的逻辑很直白从Header取出Authorization确认前缀是Bearer然后调用插件的verify方法验签。验签失败会有异常抛出来统一归一成401响应。验证通过的用户信息挂载到ctx.state.user上后续控制器里可以直接从这里拿当前登录人。我特意把token前缀约定为Bearer这是比较通用的HTTP认证规范写法前端对接的时候统一按这个格式带。实际开发中有些同学图省事直接把token裸放在Header里也能跑但从规范性和可维护性角度还是建议用Bearer开头。关于ctx.state这个字段很多egg.js新手容易忽略。它是egg框架专门预留的“随请求传递数据”的地方中间件里写入后面的控制器、服务层都能读到比挂在ctx上其他自定义字段要规范也不会污染上下文。3.4 路由挂载与Postman实测中间件写好了接下来是把受保护的路由挂上。router.js里的配置use strict; module.exports app { const { router, controller, middleware } app; const auth middleware.auth(); router.post(/api/login, controller.user.login); router.get(/api/profile, auth, controller.user.profile); router.post(/api/logout, auth, controller.user.logout); };这里要注意中间件在路由上的挂载方式router.get(/api/profile, auth, controller.user.profile)auth作为一个普通中间件函数放在控制器处理器之前egg.js会按照路由中间件数组的顺序依次执行。request先进auth验证通过才进控制器验证失败直接就返回401controller.user.profile根本不会执行。profile控制器方法的实现async profile() { const { ctx, app } this; const userId ctx.state.user.id; const user await ctx.model.User.findByPk(userId, { attributes: { exclude: [password] } }); if (!user) { ctx.body { code: 404, message: 用户不存在 }; return; } ctx.body { code: 0, data: { user } }; }用Postman实测的流程是这样的先POST一次/api/login拿到的返回体里有个token字符串把它复制出来。然后GET/api/profile在Headers里添加Authorization字段值为Bearer 粘贴的token发送之后能看到正常返回用户信息。如果故意把token改一个字符再发服务端返回401说明验签生效了。登录接口本身返回的role字段我特意留了后面权限模型要用它做角色判断。4. 从鉴权到授权中间件组合与权限模型设计4.1 角色权限的中间件扩展第11天下午我把上午做好的鉴权中间件往上叠了一层做成了带角色判断的权限中间件。这里的核心变化是auth只管“你是不是合法登录用户”另外再写一个adminAuth负责“你是不是管理员”。// app/middleware/adminAuth.js use strict; module.exports () { return async function adminAuth(ctx, next) { const user ctx.state.user; if (!user) { ctx.status 401; ctx.body { code: 401, message: 未登录 }; return; } if (user.role ! admin) { ctx.status 403; ctx.body { code: 403, message: 没有管理员权限 }; return; } await next(); }; };路由里这样组合router.get(/api/admin/users, auth, adminAuth, controller.admin.userList);执行顺序是auth先验证登录态没登录直接401然后adminAuth看角色不是管理员直接403两层都过了才进控制器。一开始可能会想能不能把角色判断直接合并到auth中间件里省一个文件我今天的体会是最好别这么做。拆开的好处是灵活有的接口只需要登录就行有的接口要管理员以后还可能出现运营、编辑这些新角色如果都写死到一个中间件后面维护还得去翻旧代码。4.2 用户资源级权限的校验角色权限处理了“谁能访问管理接口”但业务里还有一类更细的权限资源归属权。典型场景是“用户只能修改自己的资料不能改别人的”。这个判断必须在服务层里做不能靠路由中间件一刀切。我写了一个更新个人资料的接口做示例async updateProfile() { const { ctx } this; const currentUserId ctx.state.user.id; const targetId parseInt(ctx.params.id, 10); if (targetId ! currentUserId ctx.state.user.role ! admin) { ctx.status 403; ctx.body { code: 403, message: 不能修改其他用户的信息 }; return; } // 后续才是真正的更新逻辑 }这里有个容易被忽略的坑ctx.params.id是字符串从JWT里解出来的user.id是数字如果不做类型转换直接用判断1 1永远为false就会把用户自己的合法请求也拦掉。我在实际调试中就踩过这个最后排查出来是因为类型不一致。建议在控制器入口统一做一次parseInt或者把JWT签发时的id存成字符串。4.3 中间件顺序与洋葱模型的关系写到权限中间件必须把egg.js中间件的执行顺序好好说清楚。egg.js里的中间件基于Koa的洋葱模型。一个请求从外到内穿过所有中间件再逆着原路出来。如果我在路由里挂[auth, adminAuth]这两个中间件相当于是套在控制器外面的两层“洋葱皮”。请求先进authauth调await next()进入adminAuthadminAuth再await next()然后才到控制器。控制器处理完返回时又依次从adminAuth、auth的外层穿出。理解这个顺序对排查问题特别重要。比如我自己写日志中间件的时候如果放在auth外层日志会记录所有请求包括那些被鉴权拦下来的放在auth内层的话没通过鉴权的请求就不会进日志中间件。这个差异在做安全审计时非常关键你总想知道是谁在尝试非法访问所以审计类中间件应该放在鉴权之前。4.4 免鉴权白名单与全局中间件挂载实际业务里不是每个接口都需要鉴权。登录、注册、验证码这些接口必须放行否则用户连门都进不来。我的做法是把auth中间件挂在具体路由上而不是全局使用。这样哪些接口要鉴权在路由文件里一眼就能看清。但如果整个项目的大部分接口都要鉴权逐个挂载比较繁琐这时可以在config.default.js里配置中间件白名单config.auth { ignore: [/api/login, /api/register] };再稍微改一下auth中间件让它读取ignore配置module.exports () { return async function auth(ctx, next) { const ignorePaths ctx.app.config.auth.ignore || []; if (ignorePaths.includes(ctx.path)) { await next(); return; } // 原有的鉴权逻辑... }; };这种做法适合把auth做成全局中间件的时候用白名单集合之外的接口一律要求登录。从工程实践看显式在路由上挂载更直观全局中间件加白名单更省事两种方式没有绝对好坏看项目规模和个人习惯。我今天自己练习的时候用的是路由挂载方式因为接口数量少每一条路由都清楚。5. 安全配置与踩坑记录5.1 egg.js内置安全策略CSRF的正确解刚开始接触egg.js时我被它的CSRF防护狠狠坑过一次。当时前端POST接口一直返回403控制台报错提示CSRF token缺失我还以为是接口写错了查了半天才发现是egg.js的默认安全策略在做拦截。egg.js默认开启了CSRF防护普通的前后端同源页面可以直接把CSRF token渲染到模板里表单提交时带上就行。但前后端分离项目里接口是给外部客户端调用的CSRF防护就有点尴尬了。我的处理方式是区分场景。对纯API接口可以在config.default.js里按路由区分或调整配置保留必要的防护不直接全部关闭。有些团队为了省事直接enable: false我很不建议这样做除非你确定所有调用方都是非浏览器客户端。我的实际做法是维持CSRF开启状态同时为API路径添加白名单确保页面表单场景依然被保护这是安全上的折中方案。配置参考config.security { csrf: { enable: true, headerName: x-csrf-token, }, };5.2 JWT常见问题排查今天下午集中踩了几个JWT相关的坑一个个记录下来方便以后自查。第一个坑是token过期时间误差。egg-jwt底层用jsonwebtokenexpiresIn参数如果传字符串2h是合法的但如果误写成数字2含义会变成“2秒”而不是“2小时”。查文档才知道jsonwebtoken里数字单位是秒字符串才能用语义化的时间粒度。第一次用的时候我传了{ expiresIn: 2 }结果登录后两秒token就过期了调试时一脸懵。第二个坑是Authorization头解析。有些客户端会把token放在请求头的其他位置或者大小写写法不统一导致服务端取不到。我在auth中间件里做了兼容没有拿到header时直接提示“请携带token”比服务端静默失效要好排查。第三个坑是JWT无状态导致的主动退出难题。用户点了退出登录前端把token丢掉就完事了但如果有攻击者已经截获了这个token它依然能在有效期内正常调用接口。要真正解决这个问题需要引入Redis黑名单。我在logout接口里用了这个方法async logout() { const { ctx, app } this; const token ctx.request.header.authorization.replace(Bearer , ); const decoded app.jwt.verify(token, app.config.jwt.secret); const key blacklist_${decoded.id}_${token}; await app.redis.set(key, 1, EX, 7200); ctx.body { code: 0, message: 退出成功 }; }auth中间件在验证token通过后先去查一次Redis黑名单存在就直接拦截。这种方案的思路是用Redis记住“已退出但还未过期的token”每次鉴权时先查这个集合。付出一次Redis读操作的代价换取主动失效的能力对大多数业务来说值得。5.3 统一响应格式与状态码的规范鉴权相关接口最容易出现状态码混乱。有的接口返回200加业务码有的直接把HTTP状态码当业务码用前端对接起来很痛苦。我的约定是HTTP状态码只表示传输层的成功或失败业务成功一律200且code为0业务失败返回200但code非0只有在未登录、无权限这类通用场景才用HTTP 401/403配合code字段。这样设计的好处是前端网络层只要能进入success回调就先看code值做业务分支逻辑统一。为了让响应格式统一我在controller层写了一个简单的成功响应方法// app/extend/ctx.js module.exports { success(data, message ok) { this.body { code: 0, message, data }; }, fail(code, message) { this.body { code, message }; } };控制器里用起来就是ctx.success({ userId: 1 });能省不少样板代码。5.4 bcrypt对性能的影响顺带提一个第11天发现的小知识bcrypt是故意设计成“慢”的哈希算法它内部要迭代很多轮次让暴力破解的成本变得非常高。代价就是接口响应时间会增加我在本地测试的时候一次bcrypt比对大概耗时60到80毫秒比明文比较慢几个数量级。很多新手误以为越快的哈希越好恰恰相反密码哈希要看的是“慢”。MD5算得飞快攻击者可以每秒尝试几亿次bcrypt算得慢但每次都这么慢攻击者想爆破就要付出天文数字的算力成本。这是密码存储安全的基本逻辑。登录接口本身因为bcrypt会多几十毫秒延迟这在思路上不需要优化安全换性能是值得的。如果真要缓解延迟压力可以做Redis缓存用户信息避免每次请求都查库。6. 第11天学习总结与实战心得6.1 今天完成的成果清单列一下今天一天完成了哪些事情给自己也给大家一个清晰的产出视图完整实现了JWT登录接口密码通过bcryptjs哈希存储和校验写了auth鉴权中间件通过Authorization头解析Bearer token加了角色权限中间件能区分普通用户和管理员做了资源归属校验防止用户越权修改他人数据解决了CSRF在前后端分离下的配置问题用Redis黑名单实现了JWT主动退出机制统一了接口响应格式规范了HTTP状态码和业务码的分工这些内容全部跑过Postman实测不是纸上谈兵每个关键点都验证通过了。6.2 踩坑次数最多的三个问题按今天的踩坑频率和影响程度我排了个前三名最影响效率的是JWT过期时间单位搞错因为接口看起来一切正常就是一会儿能调一会儿不能调排查思路很容易走偏。最隐蔽的是userId类型不一致问题字符串和数字的比较在JavaScript里如果不严格相等会静默返回false而且不报错业务日志也不会显示异常。最需要提前规划的是CSRF拦截前端第一次联调POST接口就撞上403可能让不熟悉egg.js的同事误判为路由写错。6.3 对剩余四天学习计划的想法第11天结束15天计划还剩第12到第15天。我原本第12天安排的是日志收集和错误处理今天做完鉴权后我反而想把日志这块的优先级再提高一点。因为今天排查上面这些问题的时候我发现开发环境打印的egg.js日志在定位问题时还是不够直观异常堆栈、请求参数、返回结果串在一起看起来费劲。剩下几天的安排大致是这个方向第12天日志分级、请求日志链路、自定义错误处理中间件第13天性能优化包括数据层查询优化、接口响应压缩、缓存策略第14天部署上线流程进程守护、环境配置、优雅重启第15天把模拟项目X从单机接口升级成带鉴权、日志、部署脚本的完整后端服务收尾总结今天是第11天实际学到的东西比我原计划里标注的“鉴权与安全”还要再多一部分因为把前面十天的知识点又重新串联了一遍。学框架这事回头看的次数越多理解就越扎实。剩下的四天继续推进。