📌 本文要点 :一文搞懂小程序登录态的核心原理与生产级实现。包含 4种方案横向对比 、完整Node.js+Redis后端代码 (防刷/互踢/自动续期/并发控制)、数据库设计 、Redis持久化配置 以及前端完整封装 。全文共 10个章节 、6个对比表格 、30+代码示例,适合想从零搭建小程序登录体系的后端开发者。
📚 目录
- 一、为什么需要自定义登录态?
- 二、四种登录态方案深度对比与选型
- 三、核心流程速览
- 四、核心步骤解析(先看原理再写代码)
- [五、生产级后端代码实现(Node.js + Redis)](#五、生产级后端代码实现(Node.js + Redis))
- 六、数据库设计与完整CRUD操作
- 七、Token过期策略与Redis持久化
- 八、前端小程序完整封装
- 九、常见坑与解决方案(6个典型问题)
- 十、生产环境安全加固(6层防护)
- 十一、总结与最佳实践
- 参考文档
一、为什么需要自定义登录态?
1.1 直接使用微信返回的数据有什么问题?
很多新手会问:既然微信已经帮我换好了 OpenID 和 session_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 的哈希计算中,可以实现:
- 防篡改 :改了
session_key或OpenID任意一个,哈希结果都不同 - 自动失效 :用户重新登录后
session_key变化,旧 Token 自然失效 - 多端互踢 :通过
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 三条"黄金法则"
-
后端绝对信任"计算",绝不信任"传输"
每次请求重算哈希 对比 Token,不匹配就拒绝。传入即校验,校验不通过即拒绝。
-
Redis 是登录态的"总闸门"
Token 有效性完全由 Redis 决定。删 Key → 用户掉线;更新
latest_session→ 老设备失效。一切以 Redis 为准。 -
前后端双重检查
后端做哈希校验 + 版本控制;前端做
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 小时
};
📚 参考文档
-
小程序登录官方文档
https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
-
会话密钥 session_key 有效性
-
Redis 持久化官方文档
-
JWT 官方文档
-
Node.js Crypto 文档