尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Node.js+JWT 小程序鉴权:TaoToken 统一 Key 配置与校验骨架

Node.js+JWT 小程序鉴权:TaoToken 统一 Key 配置与校验骨架 1. 小程序鉴权链路为什么总在 token 这一步翻车小程序登录鉴权这件事说穿了就三步前端拿到code换openId服务端签发 JWT后续请求带着 token 让中间件校验。听起来简单但真正落到 Node.js 项目里坑往往出在三个地方密钥散落在多个文件里改一次漏一处、中间件只判断了 token 存在却没处理过期、以及鉴权请求本身走的通道不统一导致排查困难。我这次要做的是把「小程序登录 → JWT 签发 → 携带 token 请求 → 过期拦截」这条链路完整跑通同时把密钥和 API 通道收敛到 TaoToken 统一管理。TaoToken 在这里扮演的角色是统一 Key 与 API 通道的承接层官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。你不需要在每个 handler 里硬编码一堆 secret而是让配置集中、通道统一后面换环境或者加校验逻辑时只动一处。这篇文章适合谁如果你正在写小程序后端用的是 Express Mongoose jsonwebtoken 这套组合并且已经能跑通登录但 token 校验总是出问题那这篇就是给你准备的。我会给出可复制的 config 骨架、checkTokenMiddleware 中间件、user 和 book 两组路由的完整代码最后用 curl 把签发、携带、过期拦截三步逐一验证。全程不涉及任何网络工具纯本地 Node.js 环境操作。先说清楚整体结构避免你看到一半迷路。项目大致分这几层config/config.js放配置middlewares/checkTokenMiddleware.js做校验router_handler/user.js负责登录签发router_handler/book.js演示受保护资源models/下是 Mongoose 模型。下面按这个顺序展开。2. TaoToken 前置统一 Key 与 API 通道怎么接在写代码之前先把 TaoToken 这边的准备工作做完。这一步的核心目的是让鉴权相关的密钥和请求通道有一个统一出口而不是散落在.env、config.js、handler 里各写一份。你需要先拿到 API Key。进入控制台创建即可地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。创建完之后在 API Keys 页面可以看到自己的 key页面地址 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。这个 key 后面会写进 config作为统一通道的凭证。如果你对模型对话能力也有需求比如登录后要调用大模型做内容处理可以先去模型对话页面看看支持的模型列表地址 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。这一步不是必须的但如果你打算把鉴权和 AI 能力放在同一个后端里提前了解通道会省事。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面写了请求头格式和基础调用方式。我实测下来最关键的就是把 key 放进请求头通道地址统一用https://taotoken.net/api不要在每个 handler 里各写各的。这里要提醒一句TaoToken 是统一 Key 与 API 通道的承接层不是让你绕过任何正常流程。你的小程序jscode2session请求该走微信官方还是走微信官方TaoToken 承接的是你自己后端里那些需要统一凭证的调用。两者不要混为一谈。配置项整理成一张表方便你对照配置项作用示例值AppID小程序 appIdwx1234567890secret小程序 appSecret用于 jscode2sessionjwtSecretJWT 签名密钥自定义长字符串taoTokenKeyTaoToken 统一 Key控制台获取taoTokenBaseTaoToken API 通道https://taotoken.net/api把这几项集中到 config 里后面所有文件都从这里读改一处全生效。3. 可复制配置config 骨架与 JWT 中间件先写config/config.js。这个文件是整个项目的配置中心JWT 密钥、小程序凭证、TaoToken 通道都放这里。注意 jwtSecret 不要用短字符串越长越安全。// config/config.js module.exports { DBHOST: 127.0.0.1, DBPORT: 27017, DBNAME: test, // 小程序凭证 AppID: wx1234567890, secret: your_app_secret_here, // JWT 签名密钥务必换成足够长的随机串 jwtSecret: change_this_to_a_long_random_string_please, // TaoToken 统一 Key 与通道 taoTokenKey: your_taotoken_key_here, taoTokenBase: https://taotoken.net/api };接着写 JWT 中间件middlewares/checkTokenMiddleware.js。这里有几个细节要处理好Authorization 头缺失返回 401token 格式不对返回 401token 过期或签名错误返回 403。区分 401 和 403 对前端处理很关键401 是没带凭证403 是凭证无效。// middlewares/checkTokenMiddleware.js const jwt require(jsonwebtoken); const { jwtSecret } require(../config/config); module.exports (req, res, next) { const Authorization req.get(Authorization); if (!Authorization) { return res.json({ code: 401, msg: 暂无权限请先登录, data: null }); } // 期望格式Bearer token const parts Authorization.split( ); if (parts.length ! 2 || parts[0] ! Bearer) { return res.json({ code: 401, msg: token 格式错误, data: null }); } const token parts[1]; jwt.verify(token, jwtSecret, (err, data) { if (err) { // TokenExpiredError 单独提示方便前端触发重新登录 if (err.name TokenExpiredError) { return res.json({ code: 403, msg: token 已过期请重新登录, data: null }); } return res.json({ code: 403, msg: token 失效, data: null }); } req.user data; next(); }); };这里我把secret改成了jwtSecret原因是小程序 appSecret 和 JWT 签名密钥是两回事混用会导致换 appSecret 时所有 token 失效。分开命名能避免这个坑。然后是用户模型models/UserModel.js字段保持简洁// models/UserModel.js const mongoose require(mongoose); const UserSchema new mongoose.Schema({ openId: String, token: String, id: String }); module.exports mongoose.model(users, UserSchema);书籍模型models/BookModel.js注意user字段用来绑定 openId实现数据隔离// models/BookModel.js const mongoose require(mongoose); const BookSchema new mongoose.Schema({ name: { type: String, required: true }, author: { type: String, default: 匿名 }, gender: { type: String, enum: [男, 女] }, price: Number, is_hot: Boolean, user: { type: String, required: true } }); module.exports mongoose.model(books, BookSchema);配置和模型都齐了接下来写签发逻辑。4. 签发与校验user handler 与 book 路由登录签发的核心在router_handler/user.js。流程是前端传code后端拿 code 去换 openId查库判断用户是否存在存在就签发新 token 并更新不存在就创建用户再签发。这里用jwt.sign设置 7 天过期。// router_handler/user.js const UserModel require(../models/UserModel); const { secret, AppID, jwtSecret } require(../config/config); const jwt require(jsonwebtoken); const request require(request); const shortid require(shortid); const getToken (openId, id) { return jwt.sign({ openId, id }, jwtSecret, { expiresIn: 7d }); }; exports.reguser async (req, res) { try { const js_code req.body.code; if (!js_code) { return res.json({ code: 400, message: 缺少 js_code 参数 }); } sendRequest(js_code, handleResponse(res)); } catch (err) { console.error(Error registering user:, err); res.json({ code: 400, msg: 注册失败请稍后再试, data: null }); } }; const sendRequest (js_code, callback) { const url https://api.weixin.qq.com/sns/jscode2session?appid${AppID}secret${secret}js_code${js_code}grant_typeauthorization_code; request(url, callback); }; const handleResponse (res) { return async (error, response, body) { if (error) { console.error(Error sending request:, error); return res.json({ code: 500, message: 请求失败请稍后再试 }); } if (response.statusCode ! 200) { return res.json({ code: 400, message: 获取用户信息失败 }); } try { const bodyInfo JSON.parse(body); const openId bodyInfo.openid; if (!openId) { return res.json({ code: 400, msg: code 失效, data: null }); } const userData await UserModel.findOne({ openId }); if (userData) { const token getToken(userData.openId, userData.id); await UserModel.updateOne({ id: userData.id }, { token }); return res.json({ code: 200, msg: 该用户已存在并生成新的 token, data: token }); } const id shortid.generate(); const token getToken(openId, id); const data await UserModel.create({ openId, token, id }); res.json({ code: 200, msg: 获取成功, data: data.token }); } catch (err) { console.error(Error registering user:, err); res.json({ code: 400, msg: 注册失败请稍后再试, data: null }); } }; }; exports.login async (req, res) { try { const { openId } req.body; const data await UserModel.findOne({ openId }); if (!data) { return res.json({ code: 400, msg: 用户不存在, data: null }); } const token getToken(data.openId, data.id); res.json({ code: 200, msg: 登录成功, data: token }); } catch (err) { console.error(Error logging in:, err); res.json({ code: 500, msg: 登录失败, data: null }); } };注意我把expiresIn从原来的秒数改成了7d字符串写法jsonwebtoken 两种都支持但字符串更直观。另外logout在 JWT 无状态场景下其实不需要服务端销毁前端删掉本地 token 即可所以这里不展开。路由文件routes/user.js// routes/user.js const express require(express); const router express.Router(); const user_handler require(../router_handler/user); router.post(/reguser, user_handler.reguser); router.post(/login, user_handler.login); module.exports router;受保护资源用书籍路由演示。routes/book.js里每个路由都挂上checkTokenMiddleware// routes/book.js const express require(express); const router express.Router(); const book_handler require(../router_handler/book); const checkTokenMiddleware require(../middlewares/checkTokenMiddleware); router.post(/addbook, checkTokenMiddleware, book_handler.addBook); router.get(/getBook, checkTokenMiddleware, book_handler.getBook); router.get(/book/:id, checkTokenMiddleware, book_handler.getOneBook); router.delete(/book/:id, checkTokenMiddleware, book_handler.delBook); module.exports router;router_handler/book.js里所有查询都带上user: req.user.openId这样不同用户只能看到自己的数据// router_handler/book.js const BookModel require(../models/BookModel); exports.addBook async (req, res) { try { const data await BookModel.create({ ...req.body, user: req.user.openId }); return res.json({ code: 200, message: 添加成功, data }); } catch (err) { console.error(Error creating book:, err); return res.json({ code: 500, message: 添加失败, error: err.message }); } }; exports.getBook async (req, res) { try { const data await BookModel.find({ user: req.user.openId }); return res.json({ code: 200, message: 查询成功, data }); } catch (err) { console.error(Error querying book:, err); return res.json({ code: 500, message: 查询失败, error: err.message }); } }; exports.getOneBook async (req, res) { const { id } req.params; const { openId } req.user; try { const data await BookModel.findOne({ _id: id, user: openId }); if (!data) { return res.json({ code: 404, message: 未找到匹配的书籍 }); } return res.json({ code: 200, message: 查询成功, data }); } catch (err) { console.error(Error querying book:, err); return res.json({ code: 500, message: 查询失败, error: err.message }); } }; exports.delBook async (req, res) { const { id } req.params; const { openId } req.user; try { const result await BookModel.deleteOne({ _id: id, user: openId }); if (result.deletedCount 1) { return res.json({ code: 200, message: 删除成功, data: {} }); } return res.json({ code: 404, message: 未找到匹配的文档 }); } catch (err) { console.error(Error deleting book:, err); return res.json({ code: 500, message: 删除失败, error: err.message }); } };到这里签发和校验的骨架就完整了。接下来用 curl 把三步验证跑一遍。5. 验证请求签发、携带、过期拦截三步启动服务前确认 MongoDB 在跑然后node app.js起服务。假设监听 3000 端口。第一步模拟登录签发 token。因为jscode2session需要真实 code本地测试可以直接调/login接口前提是库里已有用户。先手动插一条用户数据或者用/reguser配合真实 code。这里用/login演示curl -X POST http://127.0.0.1:3000/api/login \ -H Content-Type: application/json \ -d {openId:test_openid_001}预期返回{ code: 200, msg: 登录成功, data: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... }把返回的 token 复制出来第二步携带它请求受保护资源curl http://127.0.0.1:3000/api/getBook \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...预期返回该 openId 下的书籍列表{ code: 200, message: 查询成功, data: [] }如果没带 Authorization 头会返回{code:401,msg:暂无权限请先登录}。如果 token 格式不对比如少了Bearer前缀同样返回 401。第三步验证过期拦截。为了快速测试临时把getToken里的expiresIn改成10s重新登录拿一个短命 token等 10 秒后再请求curl http://127.0.0.1:3000/api/getBook \ -H Authorization: Bearer 短命token预期返回{ code: 403, msg: token 已过期请重新登录, data: null }看到这个就说明过期拦截生效了。测完记得把expiresIn改回7d。如果你在登录后还想调用模型对话能力可以用同一个 TaoToken Key 去请求模型对话接口地址 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 请求头带上 key 即可。这样鉴权和 AI 能力共用一套凭证维护成本低。6. 本篇常见错排查报错一JsonWebTokenError: secret or public key must be provided原因通常是jwt.verify的第二个参数是 undefined。检查config.js里jwtSecret是否导出以及中间件里解构是否正确。我踩过的坑是早期把secret和jwtSecret混用改了一处漏了另一处。报错二TokenExpiredError没被单独捕获前端收到 403 但不知道是过期中间件里要判断err.name TokenExpiredError单独返回提示。否则前端只能笼统提示「登录失效」用户体验差。报错三req.user在 handler 里是 undefined说明中间件没挂上或者挂载顺序错了。检查routes/book.js里checkTokenMiddleware是否在 handler 之前。Express 中间件是按顺序执行的写反了就不会赋值。报错四jscode2session返回errcode: 40029这是 code 失效通常是 code 被重复使用或超过 5 分钟。小程序端每次登录都要重新wx.login()拿新 code不能缓存复用。报错五跨用户数据串了检查book.js里所有查询是否都带了user: req.user.openId。只按_id查会导致 A 用户能查到 B 用户的书。这个坑很隐蔽测试时用两个 openId 交叉验证一下。报错六TaoToken 请求返回 401检查请求头里的 key 是否和控制台一致通道地址是否用了https://taotoken.net/api。如果 key 复制时带了空格也会导致鉴权失败。排障相关的接入细节可以对照文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有请求头和错误码说明。如果你需要重新生成 key去 API Keys 页面操作即可。7. 把 Key 和通道收敛到一处后面少改很多次整套跑下来最值得保留的习惯是所有密钥和通道地址只写在config.js一处handler 和中间件全部从这里读。这样换环境、轮换 key、调整过期时间都只动一个文件。如果你后面要做长期编码或者 Agent 类项目可以考虑用 Coding Plan 来管理调用额度入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。鉴权链路本身不依赖它但如果你打算把 AI 能力接进小程序后端提前规划额度会省心。最后留一个实用技巧JWT 的expiresIn不要设太长7 天是个折中值。如果业务对安全要求高可以缩短到 2 小时配合 refresh token 机制。refresh token 的实现思路是在登录时同时签发两个 token一个短命 access token 用于业务请求一个长命 refresh token 只用于换新 access token刷新接口同样挂中间件校验 refresh token。这套骨架你已经有了加一个/refresh路由就能扩展出来。
返回列表