微信小程序自定义登录态维护:从入门到生产级落地

📌 本文要点 :一文搞懂小程序登录态的核心原理与生产级实现。包含 4种方案横向对比完整Node.js+Redis后端代码 (防刷/互踢/自动续期/并发控制)、数据库设计Redis持久化配置 以及前端完整封装 。全文共 10个章节6个对比表格30+代码示例,适合想从零搭建小程序登录体系的后端开发者。

📚 目录

一、为什么需要自定义登录态?

1.1 直接使用微信返回的数据有什么问题?

很多新手会问:既然微信已经帮我换好了 OpenIDsession_key,我直接拿这俩当"登录凭证"不就行了?

答案是不行。

🎢 一个通俗的比喻

你去游乐场买票,窗口给了你两样东西:

  • 身份证(OpenID) ------ 唯一标识你的身份
  • 储物柜钥匙(session_key) ------ 能打开你的私人物品

如果你每次玩项目,都把身份证 + 钥匙 直接亮给服务员看(即前端直接传给后端),一旦路上被"黄牛"(黑客)拍到,他就能复制你的身份、打开你的储物柜、甚至冒用你的身份玩所有项目

⚠️ 微信官方红线

微信官方文档明确强调session_key 属于敏感数据,绝不能对外暴露

✅ 正确的做法

我们自己做一个 "防伪手环"(自定义 Token):

  • 后端用 OpenID + session_key + 盐值 生成一串密文(Token)
  • 只把 Token 发给前端,前端每次带手环来,后端通过手环反查身份
  • 手环丢了可以随时挂失(删除 Redis),而不用换"身份证"(OpenID)

!在这里插入图片描述(https://i-blog.csdnimg.cn/direct/75514f409e394e5caf214db7adac4c26.png)

1.2 登录态的本质是什么?

登录态的本质是:服务端如何确认"你是谁"

方式 原理 类比
账号密码 服务端验证你"知道什么" 门禁密码
手机验证码 服务端验证你"拥有什么" 门禁卡
session_key 微信验证你"是谁" 身份证
自定义Token 服务端给你发一个"临时通行证" 游乐场手环

关键区别:身份证(session_key)丢了,你的所有身份信息都会泄露;手环(自定义Token)丢了,服务端可以随时作废它,重新发一个新的。

二、四种登录态方案深度对比与选型

动手之前,先搞清楚:市面上有哪些方案?各自适合什么场景?优缺点是什么?

2.1 方案一:直接使用 OpenID + session_key(❌ 绝对不推荐)

做法:微信换回数据后直接存前端,每次请求带着去后端验证。

javascript 复制代码
// ❌ 错误示范:前端直接存 session_key
wx.setStorageSync('session_key', session_key);
wx.setStorageSync('openid', openid);

// 每次请求直接传
wx.request({
  url: '/api/user/info',
  data: {
    openid: openid,
    session_key: session_key  // 危险!
  }
});
维度 评估
实现难度 ⭐ 最简单(几行代码搞定)
安全性 极低(session_key 暴露在网络中)
可主动失效 ❌ 不能(微信服务端控制,开发者无法干预)
多端互踢 ❌ 不能
防篡改 ❌ 不能
是否违反微信规范 违反(session_key 不得暴露)
推荐度 绝对禁用

2.2 方案二:Session + Redis(有状态会话)

做法 :登录成功后生成随机 session_id(如 UUID),存入 Redis 并关联 openid,前端只存 session_id

javascript 复制代码
// 后端生成 session_id
const sessionId = uuid.v4();
await redisClient.setEx(`session:${sessionId}`, 172800, openid);
// 只把 sessionId 返回给前端
res.json({ sessionId });

// 前端只存 sessionId
wx.setStorageSync('sessionId', sessionId);
维度 评估
实现难度 ⭐⭐ 简单
安全性 ✅ 较高(session_id 随机生成,无法反推)
可主动失效 ✅ 能(删除 Redis 记录立即生效)
多端互踢 ✅ 能(更新 session 映射关系)
防篡改 ❌ 不能(session_id 本身无校验)
分布式支持 ⚠️ 需共享 Redis
推荐度 ⚠️ 中小项目/内部工具可用

适用场景:个人项目、内部管理系统、对并发要求不高的场景。

2.3 方案三:JWT(无状态令牌)

做法:服务端用密钥签名生成 JWT 字符串返回前端,前端每次请求携带。

javascript 复制代码
// 后端生成 JWT
const jwt = require('jsonwebtoken');
const token = jwt.sign(
  { openid: 'xxx', exp: Math.floor(Date.now() / 1000) + 172800 },
  'your_secret_key'
);
res.json({ token });

// 前端存储并携带
wx.setStorageSync('jwt_token', token);
// Header: Authorization: Bearer xxx
维度 评估
实现难度 ⭐⭐⭐ 中等(需理解签名机制)
安全性 ⚠️ 中等(Payload 仅 Base64 编码,任何人可解码查看)
可主动失效 不能(发出去就收不回,只能等过期)
多端互踢 ❌ 不能
防篡改 ✅ 能(签名机制保证完整性)
分布式支持 ✅ 极佳(任何服务器用同一密钥即可验证)
是否需要查存储 ✅ 不需要(无状态,性能高)
推荐度 ⚠️ 微服务/跨端场景适用

JWT 的安全隐患

javascript 复制代码
// JWT 的 Payload 是 Base64 编码,任何人都能解码
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcGVuaWQiOiJ4eHgiLCJpYXQiOjE2OTAwMDAwMDB9.xxx';
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
console.log(payload); // { openid: 'xxx', iat: 1690000000 }  任何人都能看到!

适用场景:微服务架构、跨端应用(Web/App/小程序共用)、对性能要求极高的场景。

2.4 方案四:自定义 Token + Redis(本文方案)

做法 :用 OpenID + session_key + 盐值 做 SHA256 哈希生成 Token,存入 Redis。

javascript 复制代码
// 后端生成
const token = crypto.createHash('sha256')
  .update(openid + SALT + session_key)
  .digest('hex');
await redisClient.setEx(token, 172800, session_key);
res.json({ token });

// 前端存 token,每次请求带上
维度 评估
实现难度 ⭐⭐⭐ 中等
安全性 ✅✅ 最高(盐值哈希 + Redis 状态双重保障)
可主动失效 ✅ 能(删除 Redis 记录)
多端互踢 ✅ 能(版本号控制)
防篡改 ✅ 能(哈希校验)
分布式支持 ⚠️ 需共享 Redis
推荐度 生产环境强烈推荐

适用场景:小程序生产环境、对安全性要求高的商业项目。

2.5 全面对比总表

对比维度 方案一:直接传 session_key 方案二:Session + Redis 方案三:JWT 方案四:自定义Token+Redis(本文)
实现难度 ⭐ 最简单 ⭐⭐ 简单 ⭐⭐⭐ 中等 ⭐⭐⭐ 中等
安全性 ❌ 极低 ✅ 较高 ⚠️ 中等 ✅✅ 最高
可主动失效 ❌ 不能 ✅ 能 ❌ 不能 ✅ 能
多端互踢 ❌ 不能 ✅ 能 ❌ 不能 ✅ 能
防篡改 ❌ 不能 ❌ 不能 ✅ 能 ✅ 能
分布式支持 ✅ 好 ⚠️ 需共享Redis ✅ 好 ⚠️ 需共享Redis
是否需要查存储 ❌ 不需要 ✅ 每次查Redis ❌ 不需要 ✅ 每次查Redis
Token 体积 较大(含两个字段) 小(一个ID) 大(Base64编码) 小(固定64位十六进制)
官方合规性 ❌ 违反规范 ✅ 合规 ✅ 合规 ✅ 合规
推荐度 绝对禁用 ⚠️ 中小项目 ⚠️ 微服务场景 强烈推荐

2.6 场景化选型建议

#mermaid-svg-ZlwGLfzKguciPUSP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZlwGLfzKguciPUSP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZlwGLfzKguciPUSP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZlwGLfzKguciPUSP .error-icon{fill:#552222;}#mermaid-svg-ZlwGLfzKguciPUSP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZlwGLfzKguciPUSP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZlwGLfzKguciPUSP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZlwGLfzKguciPUSP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZlwGLfzKguciPUSP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZlwGLfzKguciPUSP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZlwGLfzKguciPUSP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZlwGLfzKguciPUSP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZlwGLfzKguciPUSP .marker.cross{stroke:#333333;}#mermaid-svg-ZlwGLfzKguciPUSP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZlwGLfzKguciPUSP p{margin:0;}#mermaid-svg-ZlwGLfzKguciPUSP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZlwGLfzKguciPUSP .cluster-label text{fill:#333;}#mermaid-svg-ZlwGLfzKguciPUSP .cluster-label span{color:#333;}#mermaid-svg-ZlwGLfzKguciPUSP .cluster-label span p{background-color:transparent;}#mermaid-svg-ZlwGLfzKguciPUSP .label text,#mermaid-svg-ZlwGLfzKguciPUSP span{fill:#333;color:#333;}#mermaid-svg-ZlwGLfzKguciPUSP .node rect,#mermaid-svg-ZlwGLfzKguciPUSP .node circle,#mermaid-svg-ZlwGLfzKguciPUSP .node ellipse,#mermaid-svg-ZlwGLfzKguciPUSP .node polygon,#mermaid-svg-ZlwGLfzKguciPUSP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZlwGLfzKguciPUSP .rough-node .label text,#mermaid-svg-ZlwGLfzKguciPUSP .node .label text,#mermaid-svg-ZlwGLfzKguciPUSP .image-shape .label,#mermaid-svg-ZlwGLfzKguciPUSP .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZlwGLfzKguciPUSP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZlwGLfzKguciPUSP .rough-node .label,#mermaid-svg-ZlwGLfzKguciPUSP .node .label,#mermaid-svg-ZlwGLfzKguciPUSP .image-shape .label,#mermaid-svg-ZlwGLfzKguciPUSP .icon-shape .label{text-align:center;}#mermaid-svg-ZlwGLfzKguciPUSP .node.clickable{cursor:pointer;}#mermaid-svg-ZlwGLfzKguciPUSP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZlwGLfzKguciPUSP .arrowheadPath{fill:#333333;}#mermaid-svg-ZlwGLfzKguciPUSP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZlwGLfzKguciPUSP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZlwGLfzKguciPUSP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZlwGLfzKguciPUSP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZlwGLfzKguciPUSP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZlwGLfzKguciPUSP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZlwGLfzKguciPUSP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZlwGLfzKguciPUSP .cluster text{fill:#333;}#mermaid-svg-ZlwGLfzKguciPUSP .cluster span{color:#333;}#mermaid-svg-ZlwGLfzKguciPUSP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZlwGLfzKguciPUSP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZlwGLfzKguciPUSP rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZlwGLfzKguciPUSP .icon-shape,#mermaid-svg-ZlwGLfzKguciPUSP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZlwGLfzKguciPUSP .icon-shape p,#mermaid-svg-ZlwGLfzKguciPUSP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZlwGLfzKguciPUSP .icon-shape .label rect,#mermaid-svg-ZlwGLfzKguciPUSP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZlwGLfzKguciPUSP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZlwGLfzKguciPUSP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZlwGLfzKguciPUSP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 个人项目/内部工具
商业/生产项目
微服务/多端
小程序单体
混合架构
开始选型
项目规模?
方案二:Session+Redis
架构类型?
方案三:JWT
方案四:自定义Token+Redis
方案四 + 网关统一鉴权

场景 推荐方案 理由
个人练手项目 方案二 实现简单,能跑就行
内部管理后台 方案二 用户少,够用
小程序创业项目 方案四 安全可控,成本适中
大型微服务架构 方案三(JWT) 天然分布式,性能好
金融/支付类小程序 方案四 + 额外加密 安全性要求最高
已有 JWT 基础设施 方案三 复用现有能力

2.7 为什么最终选择方案四?

一句话总结:方案四 = 方案二的"可控性" + 方案三的"防篡改",专为小程序生产环境量身打造。

需求 方案四如何满足
用户被踢下线 更新 latest_session,旧 Token 立即失效
Token 被盗用 重算哈希对比,篡改立即被发现
用户注销登录 删除 Redis 记录,Token 立即失效
自动续期 校验时判断 TTL,剩余不足1天自动续期
并发登录控制 通过 latest_session 实现多端互踢
审计追溯 Redis 登录日志 + 数据库日志表双重记录

三、核心流程速览

整个流程本质上是 "换三把钥匙"

步骤 角色 钥匙 特点
前端找微信要 code(临时票) 一次性,有效期5分钟
后端拿 code 换 session_key(核心权) 绝对不出服务器
后端造 Token(自定义手环) 发给前端存储,用于后续请求

整体流程如下图所示:

!在这里插入图片描述(https://i-blog.csdnimg.cn/direct/612423492a114b4a89180fbf8bddc90f.png)

时序图:完整的登录与请求流程

Redis 微信服务器 开发者后端 小程序前端 用户 Redis 微信服务器 开发者后端 小程序前端 用户 #mermaid-svg-L9umHOIIXrVXxCcY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-L9umHOIIXrVXxCcY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-L9umHOIIXrVXxCcY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-L9umHOIIXrVXxCcY .error-icon{fill:#552222;}#mermaid-svg-L9umHOIIXrVXxCcY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-L9umHOIIXrVXxCcY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-L9umHOIIXrVXxCcY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-L9umHOIIXrVXxCcY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-L9umHOIIXrVXxCcY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-L9umHOIIXrVXxCcY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-L9umHOIIXrVXxCcY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-L9umHOIIXrVXxCcY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-L9umHOIIXrVXxCcY .marker.cross{stroke:#333333;}#mermaid-svg-L9umHOIIXrVXxCcY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-L9umHOIIXrVXxCcY p{margin:0;}#mermaid-svg-L9umHOIIXrVXxCcY .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-L9umHOIIXrVXxCcY text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-L9umHOIIXrVXxCcY .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-L9umHOIIXrVXxCcY .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-L9umHOIIXrVXxCcY .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-L9umHOIIXrVXxCcY .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-L9umHOIIXrVXxCcY #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-L9umHOIIXrVXxCcY .sequenceNumber{fill:white;}#mermaid-svg-L9umHOIIXrVXxCcY #sequencenumber{fill:#333;}#mermaid-svg-L9umHOIIXrVXxCcY #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-L9umHOIIXrVXxCcY .messageText{fill:#333;stroke:none;}#mermaid-svg-L9umHOIIXrVXxCcY .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-L9umHOIIXrVXxCcY .labelText,#mermaid-svg-L9umHOIIXrVXxCcY .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-L9umHOIIXrVXxCcY .loopText,#mermaid-svg-L9umHOIIXrVXxCcY .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-L9umHOIIXrVXxCcY .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-L9umHOIIXrVXxCcY .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-L9umHOIIXrVXxCcY .noteText,#mermaid-svg-L9umHOIIXrVXxCcY .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-L9umHOIIXrVXxCcY .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-L9umHOIIXrVXxCcY .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-L9umHOIIXrVXxCcY .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-L9umHOIIXrVXxCcY .actorPopupMenu{position:absolute;}#mermaid-svg-L9umHOIIXrVXxCcY .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-L9umHOIIXrVXxCcY .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-L9umHOIIXrVXxCcY .actor-man circle,#mermaid-svg-L9umHOIIXrVXxCcY line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-L9umHOIIXrVXxCcY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 后续业务请求 打开小程序 wx.login() 返回 code POST /api/login { code } auth.code2Session(code) 返回 { openid, session_key } Token = SHA256(openid + SALT + session_key) setEx(Token, 172800, session_key) setEx(user:openid:latest_session, 172800, session_key) 返回 { token, openid } 存 token 到 Storage GET /api/user/info { token, openid } 重算哈希,对比 Token get(Token) 返回 session_key 对比 latest_session 返回业务数据

四、核心步骤解析(先看原理再写代码)

步骤 谁来做 做什么 关键点
第一步 前端 调用 wx.login()code,传给后端 code 只能使用一次
第二步 后端 code 调微信接口,拿 OpenID + session_key session_key 绝不返回前端
第三步 后端 OpenID + session_key + 盐值 → SHA256 → Token 存入 Redis,2天过期
第四步 前端 存 Token 到 Storage 每次请求在 Header 携带
第五步 后端 重算哈希对比 (防篡改)+ 查 Redis (防过期)+ 版本对比(防旧登录) 三重校验

为什么 Token 要包含 session_key?

因为 session_key 是微信给每个用户的唯一会话密钥 ,每次登录都会刷新。把 session_key 加入 Token 的哈希计算中,可以实现:

  1. 防篡改 :改了 session_keyOpenID 任意一个,哈希结果都不同
  2. 自动失效 :用户重新登录后 session_key 变化,旧 Token 自然失效
  3. 多端互踢 :通过 latest_session 记录最新版本,旧 Token 失效

五、生产级后端代码实现(Node.js + Redis)

以下代码涵盖登录、校验中间件、防刷、互踢、登出、自动续期、并发控制全流程。

5.1 基础配置与 Redis 连接

javascript 复制代码
const express = require('express');
const axios = require('axios');
const crypto = require('crypto');
const redis = require('redis');

const app = express();
app.use(express.json());

// ============ Redis 连接 ============
const redisClient = redis.createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6379',
  password: process.env.REDIS_PASSWORD || '',
  socket: {
    reconnectStrategy: (retries) => Math.min(retries * 100, 3000)
  }
});
redisClient.on('error', (err) => console.error('Redis 错误:', err));
redisClient.on('connect', () => console.log('✅ Redis 连接成功'));
redisClient.connect();

// ============ 微信配置 ============
const WX_CONFIG = {
  appId: process.env.WX_APP_ID || '你的AppID',
  appSecret: process.env.WX_APP_SECRET || '你的AppSecret'
};

// ⚠️ 生产环境必须使用环境变量,不要硬编码!
const SALT = process.env.TOKEN_SALT || 'my_secret_salt_2026';

// ============ 常量配置 ============
const TOKEN_EXPIRE = 172800;          // 2 天(秒)
const AUTO_RENEW_THRESHOLD = 86400;   // 1 天(秒),小于此值自动续期
const CODE_ERROR_CACHE = 7200;        // 2 小时
const LOGIN_RATE_LIMIT = 20;          // 每分钟最多 20 次
const MAX_LOGIN_HISTORY = 100;        // 保留最近 100 条登录记录

5.2 工具函数

javascript 复制代码
/**
 * 生成自定义 Token:OpenID + 盐值 + session_key → SHA256
 * 输出:64 位十六进制字符串,固定长度,不可逆
 */
function generateToken(openid, sessionKey) {
  return crypto.createHash('sha256')
    .update(openid + SALT + sessionKey)
    .digest('hex');
}

/**
 * 生成随机 Token(备用方案,用于方案二)
 */
function generateRandomToken() {
  return crypto.randomBytes(32).toString('hex');
}

/**
 * 调用微信接口,用 code 换 OpenID 和 session_key
 */
async function getWxSession(code) {
  const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${WX_CONFIG.appId}&secret=${WX_CONFIG.appSecret}&js_code=${code}&grant_type=authorization_code`;
  const response = await axios.get(url, { timeout: 5000 });
  
  if (response.data.errcode) {
    throw new Error(`微信接口报错 [${response.data.errcode}]: ${response.data.errmsg}`);
  }
  
  return {
    openid: response.data.openid,
    session_key: response.data.session_key,
    unionid: response.data.unionid
  };
}

/**
 * 获取客户端真实 IP(考虑代理)
 */
function getClientIp(req) {
  return req.headers['x-forwarded-for']?.split(',')[0].trim() 
    || req.headers['x-real-ip'] 
    || req.connection?.remoteAddress 
    || req.socket?.remoteAddress 
    || 'unknown';
}

5.3 登录接口(含完整防刷)

javascript 复制代码
/**
 * POST /api/login
 * 功能:用 code 换 Token
 * 安全特性:
 *   1. 错误 code 缓存 2 小时
 *   2. IP 频率限制(60秒20次)
 *   3. 登录日志记录(最近100条)
 *   4. 新登录自动踢旧设备(多端互踢)
 */
app.post('/api/login', async (req, res) => {
  const { code } = req.body;
  if (!code) {
    return res.status(400).json({ code: 400, msg: '缺少 code 参数' });
  }

  const clientIp = getClientIp(req);

  // 🛡️ 防刷1:错误 code 缓存
  const errorCacheKey = `code_error:${code}`;
  if (await redisClient.get(errorCacheKey)) {
    return res.status(400).json({ code: 400, msg: '无效的登录凭证' });
  }

  // 🛡️ 防刷2:IP 频率限制
  const ipLimitKey = `login_limit:${clientIp}`;
  const ipAttempts = await redisClient.incr(ipLimitKey);
  if (ipAttempts === 1) {
    await redisClient.expire(ipLimitKey, 60);
  }
  if (ipAttempts > LOGIN_RATE_LIMIT) {
    return res.status(429).json({ 
      code: 429, 
      msg: `登录请求过于频繁,请在 ${Math.ceil(await redisClient.ttl(ipLimitKey))} 秒后重试` 
    });
  }

  try {
    // 1️⃣ 调用微信接口换数据
    const { openid, session_key, unionid } = await getWxSession(code);

    // 2️⃣ 生成自定义 Token
    const token = generateToken(openid, session_key);

    // 3️⃣ 存储 Token,有效期 2 天
    await redisClient.setEx(token, TOKEN_EXPIRE, session_key);

    // 4️⃣ 【核心】存储最新 session_key,用于多端互踢
    //    每次新登录都覆盖此值,旧设备校验时会发现不匹配而被踢下线
    await redisClient.setEx(`user:${openid}:latest_session`, TOKEN_EXPIRE, session_key);

    // 5️⃣ 记录登录日志(用于审计和排查)
    const loginLog = JSON.stringify({
      time: new Date().toISOString(),
      ip: clientIp,
      ua: req.headers['user-agent'] || 'unknown',
      unionid: unionid || ''
    });
    await redisClient.lPush(`user:${openid}:login_history`, loginLog);
    await redisClient.lTrim(`user:${openid}:login_history`, 0, MAX_LOGIN_HISTORY - 1);

    // 6️⃣ 响应成功
    res.json({
      code: 0,
      data: {
        openid,
        token,
        expiresIn: TOKEN_EXPIRE,
        unionid: unionid || undefined
      },
      msg: '登录成功'
    });

  } catch (error) {
    // 缓存错误 code,防止恶意刷接口
    await redisClient.setEx(errorCacheKey, CODE_ERROR_CACHE, 'error');
    console.error(`[登录失败] IP:${clientIp}`, error.message);
    res.status(500).json({
      code: 500,
      msg: error.message || '登录失败,请稍后重试'
    });
  }
});

5.4 核心校验中间件

javascript 复制代码
/**
 * 登录态校验中间件
 * 三重校验:① Redis 存在 ② 哈希防篡改 ③ 最新版本(多端互踢)
 * 
 * 使用方式:router.get('/api/protected', authMiddleware, handler)
 */
async function authMiddleware(req, res, next) {
  // 从 Header 取(推荐)或从 Body 取
  const openid = req.headers['x-openid'] || req.body.openid;
  const token = req.headers['x-token'] || req.body.token;

  if (!openid || !token) {
    return res.status(401).json({
      code: 401,
      msg: '请先登录',
      needLogin: true
    });
  }

  try {
    // ===== 校验1:Redis 中存在该 Token? =====
    const sessionKey = await redisClient.get(token);
    if (!sessionKey) {
      return res.status(401).json({
        code: 401,
        msg: '登录态已过期,请重新登录',
        needLogin: true
      });
    }

    // ===== 校验2:哈希防篡改 =====
    const expectedToken = generateToken(openid, sessionKey);
    if (expectedToken !== token) {
      // 可能是被篡改,也可能盐值发生了变化
      await redisClient.del(token); // 删除可疑 Token
      return res.status(403).json({
        code: 403,
        msg: 'Token 被篡改,请重新登录',
        needLogin: true
      });
    }

    // ===== 校验3:是否为最新登录态(多端互踢) =====
    const latestSessionKey = await redisClient.get(`user:${openid}:latest_session`);
    if (sessionKey !== latestSessionKey) {
      // 用户已在其他设备登录,此 Token 失效
      await redisClient.del(token);
      return res.status(401).json({
        code: 401,
        msg: '账号已在其他设备登录,请重新登录',
        needLogin: true
      });
    }

    // ===== 自动续期:活跃用户保持登录 =====
    const ttl = await redisClient.ttl(token);
    if (ttl > 0 && ttl < AUTO_RENEW_THRESHOLD) {
      // 距离过期不足1天,自动续期到2天
      await redisClient.expire(token, TOKEN_EXPIRE);
      await redisClient.expire(`user:${openid}:latest_session`, TOKEN_EXPIRE);
      // 可以添加日志记录续期行为
    }

    // ===== 放行,挂载用户信息 =====
    req.user = {
      openid,
      sessionKey,
      token,
      unionid: req.headers['x-unionid'] || null
    };
    next();

  } catch (error) {
    console.error('[中间件错误]', error.message);
    res.status(500).json({
      code: 500,
      msg: '校验失败,请稍后重试'
    });
  }
}

// ===== 受保护接口示例 =====
app.get('/api/user/info', authMiddleware, async (req, res) => {
  // 此时 req.user 已包含用户信息,可直接使用
  res.json({
    code: 0,
    data: {
      openid: req.user.openid,
      nickname: '测试用户',
      avatar: 'https://example.com/avatar.png'
    }
  });
});

// ===== 多接口批量保护示例 =====
// 可以批量应用中间件
app.use('/api/admin/*', authMiddleware);  // 所有 /api/admin/* 都需要登录

5.5 登出、刷新、查询接口

javascript 复制代码
/**
 * POST /api/logout
 * 功能:退出登录,删除 Redis 中的 Token
 */
app.post('/api/logout', authMiddleware, async (req, res) => {
  const token = req.headers['x-token'] || req.body.token;
  const openid = req.user?.openid;

  if (token) {
    await redisClient.del(token);
    console.log(`[登出] 用户 ${openid} 已登出`);
  }

  res.json({ code: 0, msg: '已安全退出' });
});

/**
 * POST /api/refresh-token
 * 功能:主动刷新 Token(适用于敏感操作前)
 */
app.post('/api/refresh-token', authMiddleware, async (req, res) => {
  const { openid, sessionKey } = req.user;
  const oldToken = req.headers['x-token'] || req.body.token;

  // 生成新 Token
  const newToken = generateToken(openid, sessionKey);

  // 存储新 Token
  await redisClient.setEx(newToken, TOKEN_EXPIRE, sessionKey);

  // 删除旧 Token(使旧 Token 立即失效)
  if (oldToken && oldToken !== newToken) {
    await redisClient.del(oldToken);
  }

  res.json({
    code: 0,
    data: {
      token: newToken,
      expiresIn: TOKEN_EXPIRE
    },
    msg: 'Token 刷新成功'
  });
});

/**
 * GET /api/auth/check
 * 功能:检查登录态是否有效(供前端定期验证)
 */
app.get('/api/auth/check', authMiddleware, async (req, res) => {
  res.json({
    code: 0,
    data: {
      valid: true,
      openid: req.user.openid,
      expiresIn: await redisClient.ttl(req.user.token)
    },
    msg: '登录态有效'
  });
});

/**
 * GET /api/login-history
 * 功能:获取用户的登录历史(安全审计)
 */
app.get('/api/login-history', authMiddleware, async (req, res) => {
  const { openid } = req.user;
  const history = await redisClient.lRange(`user:${openid}:login_history`, 0, 20);
  
  res.json({
    code: 0,
    data: {
      history: history.map(JSON.parse),
      total: history.length
    }
  });
});

5.6 并发请求问题处理

在高并发场景下,多个请求同时到达,可能出现 Token 续期竞争latest_session 覆盖问题

javascript 复制代码
/**
 * 并发安全的 Token 刷新
 * 使用 Redis 分布式锁防止并发续期
 */
async function safeRenewToken(token, openid, sessionKey) {
  const lockKey = `renew_lock:${openid}`;
  const lockValue = crypto.randomBytes(16).toString('hex');
  
  // 尝试获取锁(最多等待 3 秒)
  const locked = await redisClient.set(lockKey, lockValue, {
    NX: true,
    EX: 3
  });
  
  if (!locked) {
    // 没有获取到锁,说明有其他请求正在续期,等待即可
    await new Promise(resolve => setTimeout(resolve, 500));
    return;
  }
  
  try {
    // 再次检查 TTL,防止已经续期
    const ttl = await redisClient.ttl(token);
    if (ttl > 0 && ttl < AUTO_RENEW_THRESHOLD) {
      await redisClient.expire(token, TOKEN_EXPIRE);
      await redisClient.expire(`user:${openid}:latest_session`, TOKEN_EXPIRE);
    }
  } finally {
    // 释放锁(使用 Lua 脚本保证原子性)
    const script = `
      if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
      else
        return 0
      end
    `;
    await redisClient.eval(script, 1, lockKey, lockValue);
  }
}

5.7 完整的项目结构

复制代码
project/
├── src/
│   ├── config/
│   │   └── index.js          # 配置文件(环境变量)
│   ├── redis/
│   │   └── client.js         # Redis 连接
│   ├── middleware/
│   │   ├── auth.js           # 登录态校验中间件
│   │   ├── rateLimit.js      # 频率限制中间件
│   │   └── signature.js      # 请求签名中间件
│   ├── services/
│   │   ├── login.js          # 登录服务
│   │   └── token.js          # Token 生成/校验服务
│   ├── routes/
│   │   ├── auth.js           # 登录/登出/刷新路由
│   │   └── user.js           # 用户信息路由
│   ├── utils/
│   │   ├── crypto.js         # 加密工具
│   │   └── ip.js             # IP 工具
│   └── app.js               # 应用入口
├── .env.example              # 环境变量示例
└── package.json

六、数据库设计与完整CRUD操作

6.1 用户表结构设计

sql 复制代码
-- ===== 微信用户表 =====
CREATE TABLE `wx_users` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `openid` VARCHAR(64) NOT NULL COMMENT '微信OpenID(唯一)',
  `unionid` VARCHAR(64) DEFAULT NULL COMMENT '微信UnionID(跨应用)',
  `nickname` VARCHAR(100) DEFAULT '' COMMENT '用户昵称',
  `avatar_url` VARCHAR(500) DEFAULT '' COMMENT '头像URL',
  `gender` TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女',
  `country` VARCHAR(50) DEFAULT '' COMMENT '国家',
  `province` VARCHAR(50) DEFAULT '' COMMENT '省份',
  `city` VARCHAR(50) DEFAULT '' COMMENT '城市',
  `phone` VARCHAR(255) DEFAULT '' COMMENT '手机号(AES-256加密存储)',
  `phone_encrypted` TINYINT DEFAULT 0 COMMENT '手机号是否加密',
  `status` TINYINT DEFAULT 1 COMMENT '状态:0禁用 1正常',
  `last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间',
  `last_login_ip` VARCHAR(45) DEFAULT NULL COMMENT '最后登录IP',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
  `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_openid` (`openid`),
  KEY `idx_unionid` (`unionid`),
  KEY `idx_status` (`status`),
  KEY `idx_last_login` (`last_login_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='微信用户表';

-- ===== 用户登录日志表 =====
CREATE TABLE `user_login_logs` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `openid` VARCHAR(64) NOT NULL COMMENT '用户OpenID',
  `login_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '登录时间',
  `login_ip` VARCHAR(45) DEFAULT NULL COMMENT '登录IP',
  `device_info` VARCHAR(255) DEFAULT NULL COMMENT '设备信息',
  `token_prefix` VARCHAR(16) DEFAULT NULL COMMENT 'Token前缀(用于追溯)',
  `is_success` TINYINT DEFAULT 1 COMMENT '是否成功:1成功 0失败',
  `fail_reason` VARCHAR(100) DEFAULT NULL COMMENT '失败原因',
  PRIMARY KEY (`id`),
  KEY `idx_openid` (`openid`),
  KEY `idx_login_time` (`login_time`),
  KEY `idx_openid_time` (`openid`, `login_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录日志表';

-- ===== 用户设备表(用于多端管理) =====
CREATE TABLE `user_devices` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `openid` VARCHAR(64) NOT NULL,
  `device_id` VARCHAR(64) NOT NULL COMMENT '设备唯一标识',
  `device_name` VARCHAR(100) DEFAULT '' COMMENT '设备名称',
  `device_type` VARCHAR(20) DEFAULT '' COMMENT '设备类型:ios/android',
  `last_active_at` DATETIME DEFAULT NULL COMMENT '最后活跃时间',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_openid_device` (`openid`, `device_id`),
  KEY `idx_last_active` (`last_active_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户设备表';

6.2 用户CRUD完整操作

javascript 复制代码
// ===== 数据库连接配置 =====
const mysql = require('mysql2/promise');

const dbPool = mysql.createPool({
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT) || 3306,
  user: process.env.DB_USER || 'root',
  password: process.env.DB_PASSWORD || '',
  database: process.env.DB_NAME || 'miniprogram',
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

// ===== 用户同步(登录时调用) =====
async function syncUserProfile(openid, profile, ip, userAgent) {
  const connection = await dbPool.getConnection();
  
  try {
    await connection.beginTransaction();

    // 1. 查询用户是否存在
    const [existing] = await connection.query(
      'SELECT id, nickname, avatar_url FROM wx_users WHERE openid = ?',
      [openid]
    );

    let userId;
    if (existing.length === 0) {
      // ===== 新用户:插入 =====
      const [result] = await connection.query(
        `INSERT INTO wx_users 
         (openid, nickname, avatar_url, gender, country, province, city, last_login_at, last_login_ip)
         VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?)`,
        [
          openid,
          profile.nickname || '',
          profile.avatarUrl || '',
          profile.gender || 0,
          profile.country || '',
          profile.province || '',
          profile.city || '',
          ip
        ]
      );
      userId = result.insertId;
    } else {
      // ===== 老用户:更新 =====
      userId = existing[0].id;
      await connection.query(
        `UPDATE wx_users SET 
          last_login_at = NOW(),
          last_login_ip = ?,
          nickname = COALESCE(NULLIF(?, ''), nickname),
          avatar_url = COALESCE(NULLIF(?, ''), avatar_url),
          gender = COALESCE(?, gender)
         WHERE openid = ?`,
        [ip, profile.nickname || '', profile.avatarUrl || '', profile.gender, openid]
      );
    }

    // 2. 记录登录日志
    await connection.query(
      `INSERT INTO user_login_logs (openid, login_time, login_ip, device_info, is_success)
       VALUES (?, NOW(), ?, ?, 1)`,
      [openid, ip, userAgent || '']
    );

    await connection.commit();
    return { userId, isNew: existing.length === 0 };

  } catch (error) {
    await connection.rollback();
    console.error('[用户同步失败]', error.message);
    throw error;
  } finally {
    connection.release();
  }
}

// ===== 获取用户信息 =====
async function getUserInfo(openid) {
  const [rows] = await dbPool.query(
    `SELECT id, openid, nickname, avatar_url, gender, country, province, city, 
            status, last_login_at, created_at
     FROM wx_users 
     WHERE openid = ? AND status = 1`,
    [openid]
  );
  return rows[0] || null;
}

// ===== 更新用户信息 =====
async function updateUserInfo(openid, updates) {
  const fields = [];
  const values = [];
  
  if (updates.nickname !== undefined) {
    fields.push('nickname = ?');
    values.push(updates.nickname);
  }
  if (updates.avatarUrl !== undefined) {
    fields.push('avatar_url = ?');
    values.push(updates.avatarUrl);
  }
  if (updates.phone !== undefined) {
    // 手机号需要加密存储
    const encrypted = encryptPhone(updates.phone);
    fields.push('phone = ?');
    values.push(encrypted);
  }
  
  if (fields.length === 0) return;
  
  values.push(openid);
  await dbPool.query(
    `UPDATE wx_users SET ${fields.join(', ')}, updated_at = NOW() WHERE openid = ?`,
    values
  );
}

// ===== 加密手机号 =====
function encryptPhone(phone) {
  const algorithm = 'aes-256-gcm';
  const key = Buffer.from(process.env.ENCRYPTION_KEY || '32byteslongkey1234567890abcdefgh', 'utf8');
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(algorithm, key, iv);
  let encrypted = cipher.update(phone, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const tag = cipher.getAuthTag().toString('hex');
  return iv.toString('hex') + ':' + tag + ':' + encrypted;
}

// ===== 查询登录历史 =====
async function getLoginHistory(openid, limit = 20) {
  const [rows] = await dbPool.query(
    `SELECT login_time, login_ip, device_info, is_success, fail_reason
     FROM user_login_logs
     WHERE openid = ?
     ORDER BY login_time DESC
     LIMIT ?`,
    [openid, limit]
  );
  return rows;
}

// ===== 统计用户活跃度 =====
async function getUserStats(openid) {
  const [rows] = await dbPool.query(
    `SELECT 
       COUNT(*) as total_logins,
       MAX(login_time) as last_login,
       MIN(login_time) as first_login,
       COUNT(DISTINCT DATE(login_time)) as active_days
     FROM user_login_logs
     WHERE openid = ? AND is_success = 1`,
    [openid]
  );
  return rows[0];
}

// ===== 禁用用户 =====
async function banUser(openid, reason) {
  await dbPool.query(
    'UPDATE wx_users SET status = 0, updated_at = NOW() WHERE openid = ?',
    [openid]
  );
  // 同时删除 Redis 中的 Token
  // 需要根据 openid 查找所有 Token 并删除(用 SCAN 命令)
  console.log(`[封禁] 用户 ${openid} 已被禁用,原因: ${reason}`);
}

6.3 方案对比:Redis存储 vs 数据库存储

对比维度 Redis 存储 数据库存储 推荐
读写速度 ⚡ 极快(内存) 🐢 较慢(磁盘) Redis
数据持久化 ⚠️ 需配置AOF ✅ 天然持久化 数据库
过期自动删除 ✅ 原生支持 ❌ 需要定时任务 Redis
查询灵活性 ⚠️ 有限 ✅ SQL 灵活查询 数据库
适用场景 临时会话数据 永久用户数据 各司其职

最佳实践:Redis 存会话,数据库存用户信息。登录态查 Redis,用户资料查数据库。

七、Token过期策略与Redis持久化

7.1 过期时间配置详解

配置项 推荐值 原因
Token 有效期 2 天(172800秒) 平衡安全与体验,用户不会频繁登录
自动续期阈值 剩余 < 1 天(86400秒) 高频用户无感知续期,提升体验
错误 code 缓存 2 小时(7200秒) 防止恶意刷接口,又不影响正常用户
IP 登录限频 60秒/20次 防暴力破解,正常用户足够
登录历史保留 最近 100 条 便于审计,不占过多存储

7.2 多种过期策略对比

javascript 复制代码
// ===== 策略1:固定过期时间 =====
await redisClient.setEx(token, 172800, sessionKey);
// 优点:实现简单
// 缺点:用户2天后必须重新登录

// ===== 策略2:滑动过期时间(每次请求续期) =====
async function renewOnEachRequest(token) {
  await redisClient.expire(token, 172800);
}
// 优点:活跃用户永不过期
// 缺点:不活跃用户不会自动清除

// ===== 策略3:混合策略(本文采用) =====
// 固定2天过期,剩余不足1天时自动续期
// 优点:平衡了用户体验和资源清理

// ===== 策略4:分级过期策略 =====
// 普通用户 1 天,VIP 用户 7 天
async function setTokenExpire(token, userLevel) {
  const expireMap = {
    'normal': 86400,    // 1天
    'vip': 604800,      // 7天
    'admin': 43200      // 12小时(安全要求更高)
  };
  await redisClient.expire(token, expireMap[userLevel] || 86400);
}

7.3 Redis 持久化方案对比

方案 工作原理 优点 缺点 适用场景
RDB(快照) 定期将内存数据写入磁盘 文件小,恢复快 可能丢最后一次快照后的数据 对完整性要求不高的场景
AOF(日志) 记录每一条写命令 最多丢1秒数据,数据安全 文件大,恢复慢 生产环境推荐
RDB + AOF 混合 两者结合 兼顾性能和安全 配置稍复杂 高要求生产环境

7.4 Redis 完整配置(redis.conf)

conf 复制代码
# ===== 网络配置 =====
bind 0.0.0.0
port 6379
protected-mode yes
requirepass your_strong_password

# ===== 持久化配置 =====
# AOF 持久化(推荐)
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec        # 每秒同步一次,性能与安全的平衡点

# RDB 快照(作为 AOF 的补充)
save 900 1                  # 900秒内至少1次修改则保存
save 300 10                 # 300秒内至少10次修改则保存
save 60 10000               # 60秒内至少10000次修改则保存

# 混合持久化(Redis 4.0+)
aof-use-rdb-preamble yes

# ===== 内存管理 =====
maxmemory 256mb
maxmemory-policy allkeys-lru   # 淘汰最近最少使用的 Key

# ===== 慢查询日志 =====
slowlog-log-slower-than 10000  # 10ms 以上的查询记录
slowlog-max-len 128

# ===== 安全配置 =====
rename-command FLUSHALL ""     # 禁用危险命令
rename-command FLUSHDB ""
rename-command CONFIG ""

7.5 Docker 部署命令

bash 复制代码
# 基础部署
docker run -d \
  --name redis \
  -p 6379:6379 \
  -v /data/redis:/data \
  -v /data/redis/redis.conf:/usr/local/etc/redis/redis.conf \
  redis:alpine \
  redis-server /usr/local/etc/redis/redis.conf

# 带密码的部署
docker run -d \
  --name redis \
  -p 6379:6379 \
  -v /data/redis:/data \
  redis:alpine \
  redis-server --requirepass "your_password" --appendonly yes --appendfsync everysec

# 查看持久化状态
docker exec redis redis-cli INFO persistence

7.6 Redis 数据备份与恢复

bash 复制代码
# ===== 手动备份 =====
# 1. 进入容器
docker exec -it redis sh

# 2. 触发 RDB 备份
redis-cli BGSAVE

# 3. 查看备份文件
ls -la /data/dump.rdb
ls -la /data/appendonly.aof

# ===== 数据恢复 =====
# 1. 停止容器
docker stop redis

# 2. 将备份文件复制到数据目录
cp /backup/dump.rdb /data/redis/

# 3. 重启容器
docker start redis

# ===== 定时备份脚本 =====
#!/bin/bash
# backup_redis.sh
BACKUP_DIR="/backup/redis"
DATE=$(date +%Y%m%d_%H%M%S)
docker exec redis redis-cli BGSAVE
sleep 5
cp /data/redis/dump.rdb $BACKUP_DIR/redis_$DATE.rdb
find $BACKUP_DIR -name "*.rdb" -mtime +7 -delete  # 删除7天前的备份

八、前端小程序完整封装

8.1 登录模块

javascript 复制代码
// utils/login.js

/**
 * 执行登录流程
 */
function doLogin() {
  return new Promise((resolve, reject) => {
    wx.login({
      success: (res) => {
        if (!res.code) {
          return reject(new Error('获取 code 失败'));
        }
        
        wx.request({
          url: 'https://你的域名/api/login',
          method: 'POST',
          data: { code: res.code },
          timeout: 10000,
          success: (response) => {
            const { code, data, msg } = response.data;
            
            if (code === 0) {
              // 存储登录态
              wx.setStorageSync('token', data.token);
              wx.setStorageSync('openid', data.openid);
              wx.setStorageSync('token_expires_at', Date.now() + data.expiresIn * 1000);
              
              // 可选:存储 unionid(用于跨应用)
              if (data.unionid) {
                wx.setStorageSync('unionid', data.unionid);
              }
              
              resolve(data);
            } else {
              reject(new Error(msg || '登录失败'));
            }
          },
          fail: (err) => {
            reject(new Error('网络请求失败,请检查网络'));
          }
        });
      },
      fail: () => {
        reject(new Error('微信登录失败'));
      }
    });
  });
}

/**
 * 检查登录态,自动重登
 */
function ensureLogin() {
  return new Promise((resolve, reject) => {
    // 1. 检查本地 Token 是否过期
    const expiresAt = wx.getStorageSync('token_expires_at');
    if (expiresAt && Date.now() > expiresAt) {
      console.log('[登录检查] 本地 Token 已过期,重新登录');
      return doLogin().then(resolve).catch(reject);
    }

    // 2. 检查微信 session_key 是否有效
    wx.checkSession({
      success: () => {
        const token = wx.getStorageSync('token');
        const openid = wx.getStorageSync('openid');
        if (token && openid) {
          console.log('[登录检查] 登录态有效');
          resolve({ token, openid });
        } else {
          console.log('[登录检查] 缺少本地凭证,重新登录');
          doLogin().then(resolve).catch(reject);
        }
      },
      fail: () => {
        console.log('[登录检查] session_key 已过期,重新登录');
        doLogin().then(resolve).catch(reject);
      }
    });
  });
}

module.exports = { doLogin, ensureLogin };

8.2 统一请求封装

javascript 复制代码
// utils/request.js

const { ensureLogin, doLogin } = require('./login');

// 配置项
const CONFIG = {
  baseURL: 'https://你的域名',
  timeout: 10000,
  maxRetry: 2,        // 最大重试次数
  retryDelay: 1000    // 重试延迟(毫秒)
};

/**
 * 统一请求方法
 * @param {Object} options - 请求配置
 * @param {number} retryCount - 重试次数(内部使用)
 */
function request(options, retryCount = 0) {
  return new Promise((resolve, reject) => {
    // 先确保登录态有效
    ensureLogin()
      .then(({ token, openid }) => {
        // 构建请求配置
        const requestConfig = {
          url: options.url.startsWith('http') ? options.url : CONFIG.baseURL + options.url,
          method: options.method || 'GET',
          data: options.data || {},
          timeout: options.timeout || CONFIG.timeout,
          header: {
            'x-openid': openid,
            'x-token': token,
            'content-type': 'application/json',
            'x-request-id': generateRequestId(), // 用于链路追踪
            ...options.header
          }
        };

        // 发起请求
        wx.request({
          ...requestConfig,
          success: (res) => {
            const { code, data, msg } = res.data;

            // ===== 登录态失效处理 =====
            if (code === 401 || code === 403) {
              console.log('[请求] 登录态失效,清除缓存并重登');
              
              // 清除本地缓存
              clearAuthCache();
              
              // 重试
              if (retryCount < CONFIG.maxRetry) {
                doLogin()
                  .then(() => {
                    // 递归请求,增加重试计数
                    request(options, retryCount + 1)
                      .then(resolve)
                      .catch(reject);
                  })
                  .catch(reject);
              } else {
                reject(new Error('登录态已失效,请重新登录'));
              }
              return;
            }

            // ===== 业务错误 =====
            if (code !== 0) {
              reject(new Error(msg || '请求失败'));
              return;
            }

            // ===== 成功 =====
            resolve(data);
          },
          fail: (err) => {
            console.error('[请求失败]', err);
            
            // 网络错误重试
            if (retryCount < CONFIG.maxRetry) {
              const delay = CONFIG.retryDelay * Math.pow(2, retryCount);
              console.log(`[重试] ${retryCount + 1}/${CONFIG.maxRetry},延迟 ${delay}ms`);
              setTimeout(() => {
                request(options, retryCount + 1)
                  .then(resolve)
                  .catch(reject);
              }, delay);
            } else {
              reject(new Error('网络请求失败,请检查网络'));
            }
          }
        });
      })
      .catch(reject);
  });
}

/**
 * 生成请求ID(用于链路追踪)
 */
function generateRequestId() {
  return Date.now().toString(36) + '_' + Math.random().toString(36).substring(2, 8);
}

/**
 * 清除认证缓存
 */
function clearAuthCache() {
  ['token', 'openid', 'token_expires_at', 'unionid'].forEach(key => {
    wx.removeStorageSync(key);
  });
}

/**
 * GET 请求
 */
function get(url, data, options = {}) {
  return request({ ...options, url, method: 'GET', data });
}

/**
 * POST 请求
 */
function post(url, data, options = {}) {
  return request({ ...options, url, method: 'POST', data });
}

/**
 * PUT 请求
 */
function put(url, data, options = {}) {
  return request({ ...options, url, method: 'PUT', data });
}

/**
 * DELETE 请求
 */
function del(url, data, options = {}) {
  return request({ ...options, url, method: 'DELETE', data });
}

module.exports = {
  request,
  get,
  post,
  put,
  del,
  clearAuthCache
};

8.3 使用示例

javascript 复制代码
// pages/user/user.js
const { get, post } = require('../../utils/request');
const { clearAuthCache } = require('../../utils/request');

Page({
  data: {
    userInfo: null,
    loginHistory: []
  },

  onLoad() {
    this.loadUserInfo();
    this.loadLoginHistory();
  },

  // ===== 加载用户信息 =====
  async loadUserInfo() {
    try {
      wx.showLoading({ title: '加载中...' });
      const data = await get('/api/user/info');
      this.setData({ userInfo: data });
    } catch (err) {
      wx.showToast({ title: err.message, icon: 'none' });
    } finally {
      wx.hideLoading();
    }
  },

  // ===== 加载登录历史 =====
  async loadLoginHistory() {
    try {
      const data = await get('/api/login-history');
      this.setData({ loginHistory: data.history || [] });
    } catch (err) {
      console.error('加载登录历史失败', err);
    }
  },

  // ===== 更新昵称 =====
  async updateNickname() {
    const newNickname = '新昵称_' + Date.now().toString().slice(-4);
    try {
      await post('/api/user/update', { nickname: newNickname });
      wx.showToast({ title: '更新成功', icon: 'success' });
      this.loadUserInfo();
    } catch (err) {
      wx.showToast({ title: err.message, icon: 'none' });
    }
  },

  // ===== 退出登录 =====
  async handleLogout() {
    wx.showModal({
      title: '提示',
      content: '确定要退出登录吗?',
      success: async (res) => {
        if (res.confirm) {
          try {
            await post('/api/logout');
            clearAuthCache();
            wx.showToast({ title: '已退出', icon: 'success' });
            wx.navigateTo({ url: '/pages/login/login' });
          } catch (err) {
            wx.showToast({ title: '退出失败', icon: 'none' });
          }
        }
      }
    });
  },

  // ===== 下拉刷新 =====
  onPullDownRefresh() {
    Promise.all([
      this.loadUserInfo(),
      this.loadLoginHistory()
    ]).finally(() => {
      wx.stopPullDownRefresh();
    });
  }
});

九、常见坑与解决方案(6个典型问题)

通俗解释 解决方案
① 旧 Token 无法失效 "退房后房卡还能开门" 存储 latest_session 版本号,每次校验对比,不匹配则作废
② 伪造 code 刷接口 "有人拿假门票反复排队" 错误 code 缓存 2 小时,再次请求直接拒绝
③ 伪造 token 刷业务 "拿纸糊的手环进 VIP 室" 后端重算哈希对比 + 频率限制
④ session 过期但 Redis 没过期 "游乐场清场了,App 还在玩" 前端每次请求前调 wx.checkSession
⑤ Redis 重启丢数据 "总闸门突然断电" 配置 AOF 持久化 + 挂载数据目录
⑥ 同设备重登被误踢 "自己换衣服被当小偷" 引入设备 ID 做更细粒度判断

问题①:旧 Token 无法失效(详细解决方案)

javascript 复制代码
// 登录时存储最新版本
await redisClient.setEx(`user:${openid}:latest_session`, 172800, session_key);

// 校验时对比版本
const latestSessionKey = await redisClient.get(`user:${openid}:latest_session`);
if (sessionKey !== latestSessionKey) {
  await redisClient.del(token);
  return res.status(401).json({ code: 401, msg: '已在其他设备登录' });
}

问题⑥:同设备重登被误踢(精细化方案)

javascript 复制代码
// 登录时生成设备指纹
function generateDeviceFingerprint(req) {
  const ua = req.headers['user-agent'] || '';
  const acceptLang = req.headers['accept-language'] || '';
  return crypto.createHash('sha256')
    .update(ua + acceptLang)
    .digest('hex');
}

// 存储设备信息
await redisClient.setEx(`user:${openid}:device_id`, 172800, deviceId);

// 校验时判断是否同一设备
const savedDeviceId = await redisClient.get(`user:${openid}:device_id`);
const isSameDevice = savedDeviceId === deviceId;

if (!isSameDevice && sessionKey !== latestSessionKey) {
  await redisClient.del(token);
  return res.status(401).json({ code: 401, msg: '已在其他设备登录' });
}

十、生产环境安全加固(6层防护)

10.1 安全措施全景图

#mermaid-svg-Xt44IYiQXXHEMteA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Xt44IYiQXXHEMteA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xt44IYiQXXHEMteA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xt44IYiQXXHEMteA .error-icon{fill:#552222;}#mermaid-svg-Xt44IYiQXXHEMteA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xt44IYiQXXHEMteA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xt44IYiQXXHEMteA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xt44IYiQXXHEMteA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xt44IYiQXXHEMteA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xt44IYiQXXHEMteA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xt44IYiQXXHEMteA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xt44IYiQXXHEMteA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xt44IYiQXXHEMteA .marker.cross{stroke:#333333;}#mermaid-svg-Xt44IYiQXXHEMteA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xt44IYiQXXHEMteA p{margin:0;}#mermaid-svg-Xt44IYiQXXHEMteA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Xt44IYiQXXHEMteA .cluster-label text{fill:#333;}#mermaid-svg-Xt44IYiQXXHEMteA .cluster-label span{color:#333;}#mermaid-svg-Xt44IYiQXXHEMteA .cluster-label span p{background-color:transparent;}#mermaid-svg-Xt44IYiQXXHEMteA .label text,#mermaid-svg-Xt44IYiQXXHEMteA span{fill:#333;color:#333;}#mermaid-svg-Xt44IYiQXXHEMteA .node rect,#mermaid-svg-Xt44IYiQXXHEMteA .node circle,#mermaid-svg-Xt44IYiQXXHEMteA .node ellipse,#mermaid-svg-Xt44IYiQXXHEMteA .node polygon,#mermaid-svg-Xt44IYiQXXHEMteA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Xt44IYiQXXHEMteA .rough-node .label text,#mermaid-svg-Xt44IYiQXXHEMteA .node .label text,#mermaid-svg-Xt44IYiQXXHEMteA .image-shape .label,#mermaid-svg-Xt44IYiQXXHEMteA .icon-shape .label{text-anchor:middle;}#mermaid-svg-Xt44IYiQXXHEMteA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Xt44IYiQXXHEMteA .rough-node .label,#mermaid-svg-Xt44IYiQXXHEMteA .node .label,#mermaid-svg-Xt44IYiQXXHEMteA .image-shape .label,#mermaid-svg-Xt44IYiQXXHEMteA .icon-shape .label{text-align:center;}#mermaid-svg-Xt44IYiQXXHEMteA .node.clickable{cursor:pointer;}#mermaid-svg-Xt44IYiQXXHEMteA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Xt44IYiQXXHEMteA .arrowheadPath{fill:#333333;}#mermaid-svg-Xt44IYiQXXHEMteA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Xt44IYiQXXHEMteA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Xt44IYiQXXHEMteA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xt44IYiQXXHEMteA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Xt44IYiQXXHEMteA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xt44IYiQXXHEMteA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Xt44IYiQXXHEMteA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Xt44IYiQXXHEMteA .cluster text{fill:#333;}#mermaid-svg-Xt44IYiQXXHEMteA .cluster span{color:#333;}#mermaid-svg-Xt44IYiQXXHEMteA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Xt44IYiQXXHEMteA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Xt44IYiQXXHEMteA rect.text{fill:none;stroke-width:0;}#mermaid-svg-Xt44IYiQXXHEMteA .icon-shape,#mermaid-svg-Xt44IYiQXXHEMteA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xt44IYiQXXHEMteA .icon-shape p,#mermaid-svg-Xt44IYiQXXHEMteA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Xt44IYiQXXHEMteA .icon-shape .label rect,#mermaid-svg-Xt44IYiQXXHEMteA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xt44IYiQXXHEMteA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Xt44IYiQXXHEMteA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Xt44IYiQXXHEMteA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端请求
第1层: HTTPS
第2层: 域名白名单
第3层: 频率限制
第4层: 签名验证
第5层: 登录态校验
第6层: 业务权限
返回响应

10.2 各层防护详解

层级 措施 优先级 说明
第1层 HTTPS 强制 ⭐⭐⭐ 必须 小程序强制要求,确保数据传输加密
第2层 域名白名单 ⭐⭐⭐ 必须 小程序后台配置,防止 API 被非小程序调用
第3层 频率限制 ⭐⭐⭐ 必须 防止暴力破解和恶意刷接口
第4层 请求签名 ⭐⭐ 推荐 防止重放攻击和参数篡改
第5层 登录态校验 ⭐⭐⭐ 必须 本文核心内容
第6层 业务权限 ⭐⭐⭐ 必须 基于角色的访问控制(RBAC)

10.3 签名中间件完整代码

javascript 复制代码
/**
 * 请求签名中间件(防止重放攻击)
 * 
 * 前端需要配合实现:
 * 1. 生成 nonce(随机数)
 * 2. 计算签名: HMAC-SHA256(method + path + timestamp + nonce + body, secret)
 * 3. 在 Header 中携带: x-timestamp, x-nonce, x-signature
 */
async function signatureMiddleware(req, res, next) {
  // 只对 POST/PUT/DELETE 请求验证签名(GET 请求仅验证时间戳)
  const isWriteMethod = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method);
  
  const timestamp = req.headers['x-timestamp'];
  const nonce = req.headers['x-nonce'];
  const signature = req.headers['x-signature'];

  // ===== 1. 检查必要头 =====
  if (!timestamp || !nonce) {
    return res.status(400).json({ code: 400, msg: '缺少签名头' });
  }

  // ===== 2. 时间戳校验(防重放) =====
  const now = Date.now();
  const requestTime = parseInt(timestamp);
  if (isNaN(requestTime) || Math.abs(now - requestTime) > 60000) {
    return res.status(400).json({ code: 400, msg: '请求已过期,请同步时间' });
  }

  // ===== 3. Nonce 防重放(5分钟内不能重复使用) =====
  const nonceKey = `nonce:${nonce}`;
  const nonceExists = await redisClient.get(nonceKey);
  if (nonceExists) {
    return res.status(400).json({ code: 400, msg: '重复请求,请重新发起' });
  }
  await redisClient.setEx(nonceKey, 300, '1');

  // ===== 4. 对写请求验证签名 =====
  if (isWriteMethod) {
    if (!signature) {
      return res.status(400).json({ code: 400, msg: '缺少签名' });
    }

    // 构建待签名的 payload
    const bodyStr = req.body ? JSON.stringify(req.body) : '';
    const payload = `${req.method}\n${req.path}\n${timestamp}\n${nonce}\n${bodyStr}`;
    
    // 计算期望的签名
    const expectedSignature = crypto
      .createHmac('sha256', SALT)
      .update(payload)
      .digest('hex');

    // 对比签名(使用 timingSafeEqual 防止时序攻击)
    if (!crypto.timingSafeEqual(
      Buffer.from(signature, 'hex'),
      Buffer.from(expectedSignature, 'hex')
    )) {
      return res.status(403).json({ code: 403, msg: '签名验证失败' });
    }
  }

  next();
}

// ===== 应用签名中间件 =====
app.use('/api/*', signatureMiddleware);

10.4 IP 自动封禁完整逻辑

javascript 复制代码
/**
 * IP 自动封禁系统
 * 当某个 IP 在短时间内失败次数过多时自动拉黑
 */
const BLOCK_CONFIG = {
  failWindow: 300,        // 5分钟窗口
  failThreshold: 50,      // 失败50次触发封禁
  blockDuration: 3600,    // 封禁1小时
  maxFailLog: 100         // 最多记录100条失败日志
};

async function recordFailure(ip, reason) {
  // 1. 记录失败次数
  const failKey = `fail:${ip}`;
  const count = await redisClient.incr(failKey);
  if (count === 1) {
    await redisClient.expire(failKey, BLOCK_CONFIG.failWindow);
  }

  // 2. 记录失败详情(用于排查)
  const detailKey = `fail_detail:${ip}`;
  await redisClient.lPush(detailKey, JSON.stringify({
    time: new Date().toISOString(),
    reason: reason,
    count: count
  }));
  await redisClient.lTrim(detailKey, 0, BLOCK_CONFIG.maxFailLog - 1);

  // 3. 达到阈值,自动封禁
  if (count >= BLOCK_CONFIG.failThreshold) {
    await redisClient.setEx(`blacklist:${ip}`, BLOCK_CONFIG.blockDuration, '1');
    // 清理失败计数,防止重复封禁
    await redisClient.del(failKey);
    
    console.log(`[安全] IP ${ip} 已被封禁,原因: 失败次数 ${count},时间: ${new Date().toISOString()}`);
  }
}

async function checkBlacklist(ip) {
  const isBlocked = await redisClient.get(`blacklist:${ip}`);
  if (isBlocked) {
    const ttl = await redisClient.ttl(`blacklist:${ip}`);
    throw new Error(`您的 IP 已被限制访问,剩余 ${Math.ceil(ttl / 60)} 分钟后解封`);
  }
}

// ===== 在登录接口中使用 =====
app.post('/api/login', async (req, res) => {
  const ip = getClientIp(req);
  
  try {
    await checkBlacklist(ip);
    // ... 正常登录逻辑
  } catch (error) {
    if (error.message.includes('限制访问')) {
      return res.status(403).json({ code: 403, msg: error.message });
    }
    // 登录失败时记录
    await recordFailure(ip, '登录失败');
    res.status(500).json({ code: 500, msg: error.message });
  }
});

10.5 完整的安全配置清单

配置项 推荐值 检查方法
HTTPS 强制启用 检查 SSL 证书是否有效
请求超时 5-10 秒 防止慢连接攻击
最大请求体 1MB 防止大数据攻击
并发连接限制 100/秒 防止 DDoS 攻击
Redis 密码 强密码(≥16位) 防止未授权访问
敏感数据加密 AES-256 手机号等字段加密存储
日志脱敏 不记录完整 Token 保护用户隐私

十一、总结与最佳实践

11.1 三条"黄金法则"

  1. 后端绝对信任"计算",绝不信任"传输"

    每次请求重算哈希 对比 Token,不匹配就拒绝。传入即校验,校验不通过即拒绝。

  2. Redis 是登录态的"总闸门"

    Token 有效性完全由 Redis 决定。删 Key → 用户掉线;更新 latest_session → 老设备失效。一切以 Redis 为准。

  3. 前后端双重检查

    后端做哈希校验 + 版本控制;前端做 checkSession + 过期重登。把安全防线拉满,不给黑客留后门。

11.2 方案总结对比

方案 安全性 可控性 实现复杂度 推荐场景
直接传 session_key ❌ 极低 ❌ 差 ⭐ 简单 禁用
Session + Redis ✅ 较高 ✅ 好 ⭐⭐ 简单 个人项目
JWT ⚠️ 中等 ❌ 差 ⭐⭐⭐ 中等 微服务
自定义Token+Redis ✅✅ 最高 ✅✅ 最好 ⭐⭐⭐ 中等 生产推荐

11.3 生产环境检查清单

发布到生产环境前,请确认以下事项:

  • 所有敏感配置(AppSecret、盐值、Redis密码)使用环境变量,不硬编码
  • Redis 已配置 AOF 持久化,且数据目录挂载到宿主机
  • Nginx/服务器已启用 HTTPS,且配置了域名白名单
  • 已添加请求频率限制中间件(登录接口 20次/分钟)
  • 已添加请求签名中间件(防止重放攻击)
  • 登录日志已配置(便于安全审计)
  • Token 自动续期逻辑已测试
  • 多端互踢逻辑已测试
  • 错误 code 缓存已启用
  • 数据库索引已创建(openid、login_time)

11.4 一句话概括整个方案

用盐值哈希生成一次性令牌,以 Redis 作为管控中心,辅以版本号实现多端互踢,配合用户表和登录日志做数据沉淀,构建一套完全自主可控的私有登录态体系。

11.5 关键配置速查表

javascript 复制代码
// ===== 所有可配置参数一览 =====
const CONFIG = {
  // Token 配置
  TOKEN_EXPIRE: 172800,              // 2 天
  AUTO_RENEW_THRESHOLD: 86400,       // 1 天,小于此值自动续期
  
  // 安全配置
  CODE_ERROR_CACHE: 7200,            // 2 小时,错误 code 缓存
  LOGIN_RATE_LIMIT: 20,              // 每分钟最多 20 次登录请求
  MAX_LOGIN_HISTORY: 100,            // 保留最近 100 条登录记录
  
  // Redis 配置
  REDIS_MAX_MEMORY: '256mb',
  REDIS_EVICTION_POLICY: 'allkeys-lru',
  
  // 签名配置
  SIGNATURE_TIMEOUT: 60000,          // 1 分钟,签名有效期
  NONCE_EXPIRE: 300,                 // 5 分钟,Nonce 有效期
  
  // IP 封禁配置
  FAIL_WINDOW: 300,                  // 5 分钟
  FAIL_THRESHOLD: 50,                // 失败 50 次封禁
  BLOCK_DURATION: 3600,              // 封禁 1 小时
};

📚 参考文档

  1. 小程序登录官方文档

    https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html

  2. 会话密钥 session_key 有效性

    https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html#会话密钥-session_key-有效性

  3. Redis 持久化官方文档

    https://redis.io/docs/management/persistence/

  4. JWT 官方文档

    https://jwt.io/introduction

  5. Node.js Crypto 文档

    https://nodejs.org/api/crypto.html

相关推荐
努力努力再努力wz1 小时前
【Redis入门系列】:从 RESP 协议到 redis-plus-plus:Redis 客户端编程与 C++ 接口设计
开发语言·数据库·c++·redis·分布式·缓存·架构
火眼金睛炼单词1 小时前
零基础高效记忆单词:小程序学习法的完整实操指南
学习·小程序
计算机学姐1 小时前
基于SpringBoot的高校爱心慈善管理系统
java·vue.js·spring boot·后端·spring·tomcat·mybatis
Dawson Zhu1 小时前
Kafka 在 AI Agent 系统中的应用:任务队列与事件总线的角色辨析及工程实践
人工智能·语言模型·架构·aigc·agi
vx+_bysj68691 小时前
springboot 旅行小程序97353
spring boot·后端·小程序·旅行小程序
山岚的运维笔记1 小时前
mysql 专业笔记 -- 第 14 章:GROUP BY
运维·数据库·笔记·后端·学习·mysql·dba
不爱运动的跑者1 小时前
架构可视化不是“画图“:让 AI 产出可追溯架构图的四层工程
人工智能·架构
乱码三千2 小时前
Open Knowledge Framework 一套让智能体越用越好用的成长框架
架构·github
凤城老人2 小时前
基于 Flask 的企业级 CMS 架构设计与实现
后端·python·flask