news 2026/10/10 7:44:39

Egg.js实战:登录鉴权、Session与CSRF防护全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Egg.js实战:登录鉴权、Session与CSRF防护全解析

2026年的第四天学习任务,和我前三天按官方文档敲代码的画风完全不一样了。前三天我把环境搭好、路由和 controller 理顺、service 层也接上了数据库,能写出一个“能跑”的接口服务。但真到做业务的时候我发现,光会这些远远不够——任何一个像样的功能都绕不开登录、权限、表单安全这三件事。所以第4天我把主线从“照着文档练习”换成了“做一个带登录的迷你留言板”,中间件、Session、CSRF 这个问题组合一起啃。这篇博文就是当天实操路线的完整记录,给同样自学 eggjs 的朋友做个参考。

1. 第3天结束后我为什么把“登录”当成第4天主线?

1.1 前三天的能力盘点:会写接口不等于会做功能

先简单梳理一下我前几天的进度,方便你对照自己的情况。

第一天主要解决“跑起来”的问题:安装 eggjs、初始化项目、把目录结构认了一遍。很多人刚开始接触 eggjs 会觉得目录多、约定多,但适应之后会发现“约定优于配置”在项目协作里是真省心。第二天和第三天,我照着 REST 风格写了一组接口:用户注册、用户查询、留言新增和留言列表,数据库用的是 Sequelize,两张表——“用户”和“留言”,关系很简单,一条用户对应多条留言。

到了第三天晚上,接口能通,数据能查,但我心里清楚,这东西离“能给别人用”还差得远。为什么?因为没有状态。留言板允许任何人随便发一条,那很快就没法看了。必须让用户先登录再发言,然后服务端能识别“这条留言是谁发的”,同时还要防止有人用歪门邪道伪造请求。

这也正是大多数初学者容易卡住的地方:文档里的路由、控制器、服务层你都会写,但把它们串成一个完整业务闭环时,知识是散的。登录、鉴权、安全防护就是把各个散点串起来的那根线。

1.2 为什么选“带登录的留言板”作为串联载体

我选留言板,不是因为它有意思,而是它在所有业务系统里刚好踩中三个最典型的需求:

  • 用户身份:登录、退出、记住登录状态;
  • 受保护路由:未登录用户不能执行某些操作;
  • 表单提交安全:防止跨站请求伪造。

你可能不写留言板,但不管你做的是博客后台、电商系统还是企业内部工具,最终都要面对这三件事。用一个最小可运行的项目把这套链路跑通,后面换成具体业务,只是改改表结构和页面而已。

所以第4天的目标非常明确:给这个留言板加上登录能力,让“发留言”这个动作必须在登录之后才能执行,并且在实现过程中顺手把中间件和 CSRF 机制搞明白。

2. 从登录动作出发:Session 在 eggjs 里到底怎么流转

2.1 登录接口的写法:验证密码,再写入 Session

先看登录接口的典型实现。用户提交用户名和密码后,controller 调用 service 做校验,校验通过就写入 Session:

// app/controller/user.js exports.login = async (ctx) => { const { username, password } = ctx.request.body; const user = await ctx.service.user.verify(username, password); if (!user) { ctx.status = 401; ctx.body = { message: '用户名或密码错误' }; return; } ctx.session.user = { id: user.id, username: user.username }; ctx.body = { message: '登录成功' }; };

这里最关键的一行就是ctx.session.user = ...。你不需要自己生成随机 Token、不需要手动操作 Cookie、也不需要在请求头里来回传递什么签名,eggjs 内部的 session 机制已经把这些隐藏掉了。

我试着用一个生活化的类比来理解 Session:服务端在用户登录后,发了一张“临时通行证”给浏览器,浏览器每次来办事儿都要自觉带着这张证;服务端一看证件编号就知道你是哪一位。

2.2 Session 数据到底存到哪里了

很多初学者会懵:ctx.session.user这个对象存到哪里去了?

eggjs 的 Session 默认实现是“Cookie 存储”。也就是说,登录之后,框架会把 Session 数据序列化、签名,写入浏览器的一个 Cookie 里。浏览器后续请求会把 Cookie 原样带上,服务端再验签、解析,恢复出ctx.session对象。

这个设计有利有弊。好处是服务端不用维护共享存储,部署多个实例也不会出现 Session 不互通的问题。坏处是 Cookie 体积有限,不能往里塞太多数据,而且必须开签名防篡改。所以我在配置里做得比较保守,只往 Session 里放了用户 id 和用户名这种必要信息。

生产环境建议的做法是换成 Redis 存储,Cookie 里只留一个 sessionId。这个话题我先记下来,第 5 天再去专门测,因为引入 Redis 之后还会涉及序列化器、过期策略等新问题。

2.3 常用配置与几个参数的作用

在config/config.default.js里,我按项目需要调整了 Session 配置:

config.session = { key: 'MY_APP_SESSION', maxAge: 2 * 3600 * 1000, httpOnly: true, encrypt: true, renew: true, sameSite: 'lax', };

逐个解释一下我为什么这么配:

  • key:Cookie 的名称。默认值是EGG_SESSION,我改成项目专属名称,避免和其他应用混淆;
  • maxAge:有效期,我这里设成 2 小时。业务不同这个值差异很大,像“记住我”功能的场景会设置很长;
  • httpOnly:设为true后,浏览器里的脚本无法读这个 Cookie,能有效减少 XSS 攻击造成的泄漏风险;
  • encrypt:开启加密。默认虽然签名,但明文在 Cookie 里仍然可能被看到,开加密更稳妥;
  • renew:当 Session 剩余有效期不足一半时自动续期。用户长时间在页面上操作不会被强行踢出;
  • sameSite:我设成lax,能够缓解大部分跨站请求问题,但要配合后文的 CSRF 一起看待。

这些参数并不需要死记硬背,理解每个参数是为了回答“别人凭什么相信这张通行证”这个问题。签名是防篡改,加密是防泄露,域名权限和 HttpOnly 是防偷取,过期是限制使用时间。

2.4 验证 Session 是否生效:一探到底的测试方法

写完登录逻辑后,别急着做页面,先用一个临时接口验证 Session 是否正常:

// app/controller/user.js exports.profile = async (ctx) => { const user = ctx.session.user; if (!user) { ctx.body = { ok: false, message: '未登录' }; return; } ctx.body = { ok: true, user }; };

把这个接口挂到路由上,再用浏览器访问。登录成功后请求/api/user/profile,能看到user对象;清掉浏览器 Cookie 后再请求,就变成未登录状态。这个简单的闭环测试,能让你把“写入 Session”“请求携带 Session”“服务端解析 Session”三个节点一口气验证完。

3. 中间件做鉴权:最值得写进代码注释的通用规则

3.1 为什么鉴权适合放到中间件而不是 controller

写登录状态判断,最简单的做法是每个需要登录的 controller 里都写一遍:

const user = ctx.session.user; if (!user) { ctx.status = 401; ctx.body = { message: '请先登录' }; return; }

但这样做有三个问题:代码重复、容易漏掉某个入口、后续如果要加日志或权限点还得全局翻。中间件的意义就是把这些“横切关注点”统一收口,在请求进入 controller 之前或之后统一处理。

Egg.js 的中间件写起来很直接,它本质是一个接收options和app参数、返回异步函数的模块:

// app/middleware/auth.js module.exports = (options, app) => { return async function auth(ctx, next) { const user = ctx.session.user; if (!user) { ctx.status = 401; ctx.body = { code: 401, message: '请先登录' }; return; } await next(); }; };

我把这个文件放在app/middleware/auth.js里,Egg 会自动加载。await next()是中间件里最关键的一步,它代表“放行,继续执行后续逻辑”。如果用户没登录,我们直接返回,根本不会执行到 controller。

3.2 局部挂载和全局挂载两种方式

如果只有一个接口需要保护,用局部挂载即可。在路由里指定某个中间件:

// app/router.js module.exports = (app) => { const { controller, middleware } = app; const auth = middleware.auth(); router.post('/api/comment', auth, controller.comment.create); router.post('/api/comment/:id/delete', auth, controller.comment.delete); // 无需登录的路由 router.post('/api/login', controller.user.login); router.get('/api/comments', controller.comment.list); };

这个写法非常清晰:登录、列表接口不用鉴权,创建和删除留言必须登录。如果你发现自己在一个路由文件里反复给十几个页面手动添加同一个中间件,那说明这里更适合做成全局中间件。

全局挂载在配置里声明:

// config/config.default.js config.middleware = ['auth']; // 对应的中间件配置,注意键名必须和中间件名称一致 config.auth = { ignore: ['/api/login', '/api/register', '/api/comments'], };

ignore数组表示这些路径不经过鉴权中间件。还可以用match反着写,只匹配某些路径。我更喜欢ignore,因为大多数项目里“公开接口”是少数,保护全部、放行例外,逻辑上更安全。

3.3 洋葱模型执行顺序:理解中间件的先后关系

eggjs 的中间件执行模型继承自 Koa 的洋葱模型。请求从最外层中间件进入,一层层await next()向内执行,到达 controller 后再一层层返回。可以用一个简单的流程来理解:

  • 请求进入
  • 日志中间件记录“请求开始”
  • 鉴权中间件检查登录状态
  • controller 执行业务逻辑并返回
  • 日志中间件记录“响应结束”

我以前一度以为中间件是像流水线一样“走完一个再进下一个”,直到动手写日志中间件时才理解,洋葱模型最直观的特征是:你写在await next()之前的代码是“请求进来时”执行的,写在await next()之后的代码是“响应出去时”执行的。

// app/middleware/access_log.js module.exports = () => { return async function accessLog(ctx, next) { const start = Date.now(); await next(); const cost = Date.now() - start; console.log(`${ctx.method} ${ctx.url} - ${cost}ms`); }; };

这个小中间件能帮你看清执行顺序。如果日志总是先打印后执行 controller,再打印耗时,说明你对洋葱模型的理解基本到位了。

3.4 中间件选型和性能细节

在真实项目里,中间件别写得过于“重”。比如有些场景想在鉴权中间件里顺便查一下用户角色、权限点,再去数据库查一遍用户详情。建议不要每个请求都查,而是把这类信息缓存在进程内或 Redis 里,否则中间件会成为所有接口的公共瓶颈。

还有一个细节:全局中间件默认对静态资源也会生效。如果某个中间件并不需要处理静态文件,可以加ignore把静态资源目录排除掉,例如/public、/favicon.ico,否则白白增加开销。

4. CSRF 防护成为表单第一道门槛:默认配置背后的攻防逻辑

4.1 “明明请求没问题,为什么报 403”

当我第一次写完留言提交表单,兴冲冲地点击发送,控制台给我一个 403 响应,错误信息是invalid csrf token。

第一反应是:我是不是参数写错了?检查了好多遍,路径没问题、字段没问题、请求体也正常,最后才意识到这是 eggjs 默认开启的 CSRF 防护在发挥作用。

CSRF 的全称是跨站请求伪造。通俗地讲:你在论坛 A 登录过,浏览器里还留着有效 Cookie;这时候你打开了一个恶意网站 B,B 偷偷向 A 的接口发起了一个 POST 请求。因为浏览器会自动携带 A 的 Cookie,服务端会认为这个请求是你本人发起的,于是把不该执行的敏感操作执行了。

防护思路也很直接:服务端在渲染页面时生成一个随机的 token 发给浏览器,提交表单时带上这个 token,服务端校验通过才执行。恶意的 B 网站无法获取这个 token,请求就会被拒绝。

4.2 模板渲染时把 token 放进去

如果用服务端模板渲染页面,在表单里加一个隐藏字段即可。比如我在留言板模板里这样写:

<form method="post" action="/api/comment"> <input type="hidden" name="_csrf" value="{{ ctx.csrf }}" /> <textarea name="content"></textarea> <button type="submit">提交留言</button> </form>

这里{{ ctx.csrf }}是 eggjs 安全组件提供给模板的变量,每次渲染页面时都会生成或复用当前会话的 CSRF token。

我把这个字段起名叫_csrf,因为 eggjs 默认从请求体里读取这个字段名。如果你改成了别的名字,请求体会一直校验不通过。

4.3 前端用 fetch 提交时怎么带 token

很多朋友现在用的是前后端分离写法,没有服务端模板,那 CSRF token 从哪来?

可以专门做一个接口把ctx.csrf返回给前端,也可以在首次渲染时把 token 输出到某个全局变量里。我这边实现比较简单,先通过接口获取:

// 前端代码,获取 csrf token const res = await fetch('/api/csrf'); const data = await res.json(); // 提交留言 await fetch('/api/comment', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-csrf-token': data.csrfToken, }, body: JSON.stringify({ content: 'hello' }), });

注意这里用的是请求头x-csrf-token,这是 eggjs 默认读取的自定义 Header 名称。如果你的项目安全要求更高,可以修改默认配置里的headerName字段。

4.4 关于关闭 CSRF 的一个忠告

开发阶段看到 403 很烦人,有人会直接把 CSRF 开关关掉:

config.security = { csrf: { enable: false, }, };

我在第 4 天试过,确实“立竿见影”,但它会让整个应用的写操作暴露在 CSRF 风险之下。尤其登录、注册、留言这类操作,一旦攻击者利用你浏览器里残留的 Cookie,可能无声无息地替你做出一堆操作。

所以我不建议关,也不建议把ignoreJSON: true随便打开。只有当你明确确认“当前请求来源完全可信且不涉及敏感操作”时,才需要考虑例外放行。

5. 一天内真实踩过的四个问题:排查链路还原

5.1 问题一:登录接口自己也被鉴权中间件拦了

现象:登录接口/api/login返回 401,提示未登录。

排查链路:

  • 我先看网络面板,确认请求确实打到了服务端;
  • 在登录 controller 里添加日志,发现日志根本没打印,说明请求没有进入 controller;
  • 猜到是中间件拦截,全局中间件配置是['auth'],没有任何ignore;
  • 于是登录接口也被保护了,形成“要先登录才能登录”的逻辑死锁。

修复方式很简单,在config.auth里加ignore: ['/api/login', '/api/register']。

这是一个看起来不起眼、但很容易犯的错。给全局中间件加白名单时,要先把所有“公开入口”列全,不然用户直接卡死在第一步。

5.2 问题二:改了一次密钥,所有 Session 直接失效

现象:开发时重启服务后,浏览器里明明还有 Cookie,但接口返回 401。

排查链路:

  • 打开浏览器 DevTools,看到 Cookie 还在;
  • 登录接口可以正常登录,说明不是全局配置问题;
  • 重新登录后一切正常,但把之前的 Cookie 放回去又失效;
  • 后来发现是我在调试 Session 时改了config.keys。

config.keys是用来给 Cookie 签名的密钥。Session Cookie 里带有校验签名,一旦密钥变化,服务端校验就会失败,旧 Session 全部作废。

这里有一个教训:生产环境千万不能随便改keys,否则所有在线用户都会被强制下线。

5.3 问题三:模板里 token 明明渲染出来了,提交还是 403

现象:页面里能看到隐藏字段_csrf的值,但提交时仍然报invalid csrf token。

排查链路:

  • 查看页面源码,确认 token 确实存在;
  • 查看请求体,发现字段名是csrf,并不是_csrf;
  • 我在前端把隐藏字段改名时漏掉了约定名称;
  • 服务端默认读取_csrf这个字段名,字段不一致自然校验失败。

修复方式是统一字段名。如果确实想用别的字段名,需要在安全配置里同步修改bodyName。

config.security = { csrf: { bodyName: 'csrfToken', }, };

我后来统一用默认的_csrf,约定大于配置在框架中就是省心。

5.4 问题四:登录后立刻提交留言,偶尔会提示 CSRF 校验失败

现象:比较诡异,不是必现,而是偶然出现。

排查链路:

  • 第一次登录后直接提交,出现 403;
  • 刷新页面再提交就好了;
  • 后来分析发现,登录前后 Session 内容发生了变化,CSRF token 也随 Session 一起刷新了;
  • 如果登录页和提交页用的是同一个页面,用户登录成功后页面没有刷新,旧 token 自然失效。

修复方式:登录成功后强制刷新页面,或者重新从服务端获取新的 CSRF token。这也是为什么很多项目的登录逻辑成功后都会做一次页面跳转或刷新。

5.5 踩坑汇总表

问题现象根因解决思路
登录接口返回 401全局鉴权把公开接口也拦了配置忽略路径
重启后旧 Cookie 全部失效修改了签名密钥生产环境不随意改 keys
提交表单 403请求体中 token 字段名不对统一使用默认字段名
登录后首次提交偶发 403登录导致 Session 刷新登录成功后重新获取 token

6. 第4天的过关标准与留给第5天的引子

到这一步,我给自己定的验收标准是:

  • 能说清 Session 的写入、携带、解析完整链路;
  • 能独立写出鉴权中间件,并理解局部与全局挂载的区别;
  • 理解 CSRF 的作用原理,而不是只会关掉它;
  • 遇到 403 类报错能按“字段名、Header 名、token 是否过期”这个顺序快速排查。

如果这些你都能做到,说明今天的登录链路已经内化了。

为了巩固,我给自己留了一个小练习:给留言板的“删除留言”功能加一个权限判断,只有这条留言的作者本人才能删除。这既会用到 Session 里的用户 id,也会用到中间件的局部挂载,相当于把今天的内容又揉进去复习了一遍。你可以试试能不能顺手完成。

下一篇我会把第 4 天里留的坑填上:用 Redis 做生产环境可用的 Session 存储,并且配上异常处理与请求日志,让这套 mini 留言板真正拥有“能上线”的底气。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 7:44:38

全集成LLC控制器LP9961:从拓扑原理到300W电源实战调试

搞开关电源的这几年&#xff0c;LLC谐振拓扑早已不是什么新鲜名词。从中大功率适配器、服务器电源到储能辅助电源&#xff0c;只要功率上了百瓦级&#xff0c;效率要求又高&#xff0c;大家第一个想到的方案基本就是LLC。以前做LLC&#xff0c;往往要用一颗PWM控制器搭配独立的…

作者头像 李华
网站建设 2026/10/10 7:44:32

WPF三大基类深度拆解:DependencyObject、Visual与UIElement的职责与协作

作为常年跟 WPF 和 UWP 打交道的人&#xff0c;我几乎每天都会敲到DependencyObject、Visual、UIElement这几个类。刚入行那会儿&#xff0c;我也只是把它们当成“必须继承的基类”来用&#xff0c;直到有一次在排查性能问题和诡异的布局 bug 时&#xff0c;才被逼着去把这三个…

作者头像 李华
网站建设 2026/10/10 7:43:46

Java异常处理全解析:从try-catch到自定义异常与堆栈排查

自学Java那会儿&#xff0c;我最怕的不是某个语法记不住&#xff0c;而是程序明明编译通过&#xff0c;一运行却突然甩出一大段红色堆栈&#xff0c;里面全是看不懂的类名和方法名。异常&#xff08;Exception&#xff09;这个知识点&#xff0c;表面上看就是try、catch两个关键…

作者头像 李华
网站建设 2026/10/10 7:43:13

大模型对话系统长期记忆实战:向量检索与上下文注入全解析

我自己做AI应用开发这两年&#xff0c;最大的一个感受就是&#xff1a;和助手聊天&#xff0c;最怕的不是它“答错”&#xff0c;而是它“忘事”。昨天刚给它确认过的技术方案&#xff0c;今天打开新会话&#xff0c;它又一脸无辜地反问“这个需求我没看过呀”。这个问题几乎出…

作者头像 李华
网站建设 2026/10/10 7:41:06

京瓷1025打印机驱动安装与优化实战指南

1. 项目概述&#xff1a;为什么一台老型号激光打印机的驱动安装&#xff0c;至今仍是高频痛点&#xff1f;“京瓷1025打印机驱动安装与优化”——这行字看起来平平无奇&#xff0c;甚至有点过时。但如果你在某高校行政办公室、某区级社区服务中心、某中小型设计工作室的IT支持群…

作者头像 李华
网站建设 2026/10/10 7:40:23

Matlab心形绘图全攻略:从参数方程到三维动画

1. 心形曲线的三种数学来源&#xff1a;先弄清我们要画什么总有人拿着Matlab心形绘图的问题来问我&#xff0c;尤其是情人节前后和每学期图形学课设开始的时候。很多人的第一反应是上网抄一段代码&#xff0c;plot出来一个红彤彤的桃心就交差了&#xff0c;但代码为什么这么写、…

作者头像 李华