微信公众号扫码登录是怎么实现的?一篇讲懂 SSE、半长连接与验证码映射
很多网站都有“微信扫码登录”。
用户在电脑上打开二维码,用微信扫一扫,随后网页自动完成登录。
从用户角度看,这个过程非常简单:
打开登录页面
↓
微信扫码
↓
完成验证
↓
网页自动登录
但站在后端角度,会发现这里有一个非常关键的问题:
用户是在微信里完成操作的,电脑浏览器怎么知道“这个用户已经登录成功了”?
这就是整个扫码登录方案真正需要解决的问题。
在个人微信公众号能力有限的情况下,我们可以采用一种折中的实现:
微信公众号回调 + 验证码 + SSE 半长连接 + Session/Cookie
整个方案的核心思想其实非常简单:
使用验证码作为桥梁,把微信公众号中的微信用户和浏览器建立的 SSE 连接关联起来。
理解这句话,基本就理解了整个登录方案。
一、先看完整登录流程
先不要急着看代码,先把整个流程串起来。
假设用户打开网站登录页面。
后端首先给当前浏览器生成一个验证码:
836291
然后浏览器使用这个验证码和服务器建立 SSE 连接。
服务器保存:
836291 → 当前浏览器的 SSE 连接
接下来用户扫码进入微信公众号,并向公众号发送:
836291
微信服务器收到消息以后,会回调我们的服务器。
服务器于是知道:
微信用户:user_A
输入验证码:836291
接着根据验证码查找:
836291
↓
SseEmitter
↓
浏览器A
这样服务器就知道:
微信里的 user_A,对应的正是正在等待登录的浏览器 A。
服务器完成用户注册/登录,生成 Session 或其他登录凭证,再通过 SSE 主动通知浏览器:
login#登录凭证
浏览器收到消息以后保存 Cookie,刷新页面。
登录完成。
完整流程:
浏览器打开登录页面
↓
后端生成验证码
↓
836291
↓
浏览器建立 SSE 连接
↓
服务器保存
836291 → SseEmitter-A
↓
用户扫码进入公众号
↓
发送 836291
↓
微信服务器回调后端
↓
后端拿到:
微信用户 + 836291
↓
根据 836291 查找 SSE
↓
找到浏览器 A
↓
创建/查询用户
↓
生成登录态
↓
SSE 推送 login
↓
浏览器保存 Cookie
↓
自动登录成功
这里实际上有两条完全不同的链路。
第一条:
浏览器
↓
验证码
↓
SSE连接
第二条:
微信用户
↓
公众号
↓
微信服务器
↓
回调接口
↓
验证码
最后:
验证码
/ \
/ \
微信用户 浏览器
\ /
\ /
后端
↓
SSE推送
↓
自动登录
所以一定要记住:
验证码就是连接“微信用户”和“浏览器”的桥梁。
二、第一步:接入微信公众号
要实现这套方案,首先得让微信服务器能够找到我们的服务器。
因此需要进入微信公众平台后台配置服务器地址,例如:
https://example.com/wx/callback
以后用户给公众号发送消息时:
用户
↓
微信公众号
↓
微信服务器
↓
我们的 /wx/callback
微信服务器就可以把用户发送的消息转发给我们的后端。
不过第一次配置服务器地址时,微信需要先确认:
这个服务器真的是你的吗?接口真的能正常访问吗?
因此需要进行一次服务器验证。
三、微信公众号的 Token 验证
配置服务器地址时,微信会向我们的接口发送一个 GET 请求。
请求中会携带一些参数,例如:
signature
timestamp
nonce
echostr
其中:
echostr
可以简单理解为微信生成的一个随机字符串。
服务器需要按照微信规定的方式使用:
token
timestamp
nonce
进行签名验证。
验证成功以后,再把:
echostr
原样返回。
例如:
@GetMapping("callback")
@ResponseBody
public String check(HttpServletRequest request) {
String echoStr =
request.getParameter("echostr");
if (StringUtils.isNotEmpty(echoStr)) {
return echoStr;
}
return "";
}
不过要注意:
真正上线时不能只返回 echostr。
还应该验证:
signature
timestamp
nonce
确保这个请求确实来自微信服务器。
所以正确的理解应该是:
微信服务器
↓
发送验证请求
↓
我们的服务器
↓
验证 signature
↓
验证通过
↓
返回 echostr
↓
接入成功
四、接收微信公众号回调
服务器验证完成以后,就可以真正接收微信公众号消息了。
这里有一个需要特别注意的地方:
微信公众号默认使用 XML 进行消息通信。
例如用户向公众号发送:
836291
微信服务器可能给我们的后端发送:
<xml>
<ToUserName><![CDATA[公众号]]></ToUserName>
<FromUserName><![CDATA[user123]]></FromUserName>
<CreateTime>1655700579</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[836291]]></Content>
</xml>
这里最重要的是:
FromUserName
Content
可以简单理解成:
FromUserName = 谁发的消息
Content = 发了什么
于是:
FromUserName = user123
Content = 836291
就可以理解为:
微信用户 user123 输入了验证码 836291。
Spring Boot 可以通过:
@PostMapping(
path = "callback",
consumes = {
"application/xml",
"text/xml"
},
produces = "application/xml;charset=utf-8"
)
public BaseWxMsgResVo callback(
@RequestBody WxTxtMsgReqVo msg) {
// 处理微信公众号消息
return null;
}
接收微信发送过来的 XML。
到这里,微信这一侧已经通了。
但是新的问题来了。
服务器现在只知道:
微信用户 user123
输入了
836291
它怎么知道:
836291 对应的是哪一个正在登录的浏览器?
这就要引出整个方案最重要的部分:
SSE 半长连接。
五、为什么需要“半长连接”?
先想一个问题。
假设用户已经在微信里完成登录验证。
服务器已经知道:
登录成功!
但是电脑浏览器并不知道。
怎么通知它?
如果使用普通 HTTP,一般是:
浏览器 → 请求服务器
服务器 → 返回结果
请求结束
例如:
GET /login/code
浏览器请求验证码:
浏览器:给我一个验证码。
服务器:836291。
结束。
后面用户什么时候扫码,服务器没办法通过刚才已经结束的请求主动通知浏览器。
最简单的解决办法是什么?
轮询。
六、不使用 SSE:轮询怎么做?
浏览器可以每隔两秒问服务器:
登录了吗?
例如:
GET /login/status?code=836291
于是:
浏览器 → 登录了吗?
服务器 → 没有。
2秒以后……
浏览器 → 登录了吗?
服务器 → 没有。
2秒以后……
浏览器 → 登录了吗?
服务器 → 登录成功。
这当然可以实现。
但是存在一个明显的问题:
大量请求其实都是没有意义的。
假设用户一分钟以后才扫码。
前端两秒请求一次:
一分钟 ≈ 30次请求
其中前面 29 次可能全部都是:
没有登录
所以我们真正想要的是:
浏览器不要一直问,让服务器在登录成功以后主动告诉浏览器。
这就是 SSE 可以解决的问题。
七、先理解:短连接、长连接和半长连接
这里需要把几个概念区分清楚。
1. 短连接
最容易理解。
客户端 → 请求
服务器 → 响应
连接结束
例如:
GET /login/code
请求验证码。
服务器返回:
836291
这次通信结束。
2. 长连接
所谓长连接,可以简单理解成:
客户端和服务器建立连接以后,不立即断开,而是继续保持,用于后续通信。
典型代表就是:
WebSocket。
WebSocket 建立连接以后:
客户端 ⇄ 服务端
客户端可以主动给服务器发消息:
客户端 → 服务端
服务器也可以主动给客户端发消息:
服务端 → 客户端
而且双方可以独立进行通信。
所以 WebSocket 属于典型的:
全双工双向通信。
可以把它想象成打电话。
两个人建立通话以后:
A ⇄ B
双方都可以说话,也都可以听。
八、那什么是“半长连接”?
这里先说明:
“半长连接”并不是一个严格标准的网络协议名称。
它更多是这个项目为了方便理解 SSE 而使用的一种形象说法。
SSE 全称:
Server-Sent Events
也就是:
服务器发送事件。
浏览器首先向服务器发起一个 HTTP 请求:
浏览器 ──建立连接──> 服务器
但是服务器不会像普通 HTTP 那样:
返回数据
↓
立即结束
而是会一直保持这个连接。
之后服务器有消息的时候,可以不断通过这个连接推送:
浏览器 <──── scan ────── 服务器
浏览器 <──── refresh ─── 服务器
浏览器 <──── login ───── 服务器
因此连接建立以后,主要的数据方向是:
服务器 → 浏览器
而不是:
客户端 ⇄ 服务端
所以项目里把它形象地叫做:
半长连接。
九、“半”到底是什么意思?
这里特别容易产生误解。
它不是说:
连接时间只有长连接的一半
也不是:
HTTP连接只建立了一半
而是为了和 WebSocket 的双向通信做区别。
可以这么记:
WebSocket
客户端 ⇄ 服务端
双向通信
全双工
而 SSE:
客户端 ──建立连接──> 服务端
客户端 <──持续推送── 服务端
建立连接以后,主要由:
服务端 → 客户端
主动发送数据。
所以更准确的技术描述应该是:
SSE 是基于 HTTP 的服务器单向事件推送机制。
而“半长连接”只是项目中的形象说法。
十、为什么扫码登录特别适合 SSE?
因为扫码登录的需求非常特殊。
浏览器并不需要一直给服务器发送消息。
浏览器真正需要的是等待几个状态:
等待扫码
等待验证
等待登录
服务器可能通知:
scan
表示:
二维码已经扫描
或者:
refresh#123456
表示:
验证码刷新了
或者:
login#xxx
表示:
登录成功
所以整个通信需求主要是:
服务器
↓
浏览器
而不是:
服务器
⇅
浏览器
因此没有必要为了这么简单的需求引入完整的 WebSocket 双向通信。
SSE 就非常合适。
十一、生成登录验证码
现在回到具体实现。
用户打开登录页面以后,前端首先请求:
GET /login/code
后端生成一个验证码。
例如:
836291
代码大致如下:
@GetMapping("/login/code")
public ResVo<QrLoginVo> qrLogin(
HttpServletRequest request,
HttpServletResponse response) {
QrLoginVo vo = new QrLoginVo();
vo.setCode(
qrLoginHelper.genVerifyCode(
request,
response
)
);
return ResVo.ok(vo);
}
但是这里还有一个问题。
如果用户疯狂刷新页面怎么办?
假设每刷新一次就生成验证码:
第一次:
123456
第二次:
654321
第三次:
888888
第四次:
952700
那么服务器可能不断创建新的验证码以及相关缓存。
所以项目又增加了一层:
deviceId
十二、为什么需要 deviceId?
deviceId 可以理解为:
当前浏览器设备的临时身份标识。
服务器维护:
deviceId → 验证码
例如:
device-A → 836291
这样同一个设备在验证码有效期内不断访问:
/login/code
都可以得到:
836291
而不是不断生成新的验证码。
因此系统中出现了第一个缓存:
deviceCodeCache
保存:
deviceId → code
例如:
device-A
↓
836291
它主要解决:
设备和验证码之间的稳定映射问题。
十三、建立 SSE 半长连接
拿到验证码以后,前端继续建立 SSE 连接。
例如请求:
GET /subscribe?id=836291
后端创建:
SseEmitter
例如:
public SseEmitter subscribe()
throws IOException {
String deviceId =
ReqInfoContext
.getReqInfo()
.getDeviceId();
String realCode =
deviceCodeCache
.getUnchecked(deviceId);
SseEmitter sseEmitter =
new SseEmitter(
15 * 60 * 1000L
);
verifyCodeCache.put(
realCode,
sseEmitter
);
return sseEmitter;
}
这里真正关键的是:
verifyCodeCache.put(
realCode,
sseEmitter
);
因为它建立了:
验证码 → SSE连接
例如:
836291
↓
SseEmitter-A
也就意味着:
以后只要拿到 836291,我就可以找到正在等待登录的浏览器 A。
十四、为什么需要两个缓存?
现在整个设计就清晰了。
第一个:
deviceCodeCache
保存:
deviceId → code
例如:
device-A → 836291
主要作用:
防止用户频繁刷新页面导致验证码不断变化。
第二个:
verifyCodeCache
保存:
code → SseEmitter
例如:
836291 → SseEmitter-A
主要作用:
微信回调拿到验证码以后,可以根据验证码找到对应浏览器。
因此整个映射关系:
deviceId
↓
验证码
↓
SseEmitter
↓
浏览器
例如:
device-A
↓
836291
↓
SseEmitter-A
↓
浏览器A
这是整个方案最核心的数据结构之一。
十五、为什么刷新页面要处理旧 SSE?
假设浏览器 A 已经建立了:
836291 → SseEmitter-1
然后用户刷新页面。
新的页面又建立:
836291 → SseEmitter-2
如果旧连接不释放,就可能出现:
836291
↓
到底应该通知哪个连接?
所以重新建立 SSE 时,通常需要把之前的连接关闭:
SseEmitter oldSse =
verifyCodeCache.getIfPresent(realCode);
if (oldSse != null) {
oldSse.complete();
}
然后保存新的:
verifyCodeCache.put(
realCode,
sseEmitter
);
最终保证:
一个验证码
↓
一个当前有效的 SSE 连接
这样关系就不会乱。
十六、SSE 连接也不能永久保存
SSE 虽然叫“长连接”,但不能真的永远挂着。
例如可以设置:
new SseEmitter(
15 * 60 * 1000L
);
也就是:
15分钟
超时以后:
sseEmitter.onTimeout(() -> {
verifyCodeCache.invalidate(realCode);
sseEmitter.complete();
});
如果连接异常:
sseEmitter.onError(e -> {
verifyCodeCache.invalidate(realCode);
sseEmitter.complete();
});
这样可以避免:
浏览器已经关闭
↓
服务器还一直保存旧连接
↓
缓存越来越多
十七、前端如何建立 SSE?
浏览器原生提供:
EventSource
可以非常方便地建立 SSE。
例如:
function buildConnect(code) {
const subscribeUrl =
"/subscribe?id=" + code;
const source =
new EventSource(subscribeUrl);
}
这一步执行以后:
浏览器
│
│ HTTP请求
▼
/subscribe
│
▼
服务器创建 SseEmitter
连接不会马上关闭。
而是:
浏览器
│
│
│ SSE保持
│
│
服务器
等待服务器主动推送消息。
十八、前端如何接收服务器消息?
通过:
source.onmessage = function(event) {
const text = event.data;
console.log(
"receive: " + text
);
};
假设服务器发送:
scan
前端可以:
if (text === "scan") {
stateTag.innerText = "已扫描";
}
如果服务器发送:
refresh#952700
前端更新验证码:
if (text.startsWith("refresh#")) {
const newCode =
text.substring(8).trim();
codeTag.innerText = newCode;
}
如果服务器发送:
login#xxx
说明:
登录成功
于是:
if (text.startsWith("login#")) {
document.cookie =
text.substring(6);
source.close();
window.location.reload();
}
最终完成自动登录。
十九、真正的自动登录发生在哪里?
现在来到整个流程最关键的地方。
假设浏览器 A 的验证码是:
836291
服务器已经保存:
device-A
↓
836291
↓
SseEmitter-A
用户现在扫码进入公众号,并发送:
836291
微信服务器回调:
POST /wx/callback
携带:
FromUserName = wx-user-A
Content = 836291
服务器于是知道:
微信用户A
↓
输入了
↓
836291
接下来查询:
SseEmitter emitter =
verifyCodeCache
.getIfPresent("836291");
查到:
836291
↓
SseEmitter-A
↓
浏览器A
到这里最关键的一步完成了:
微信用户 A 和浏览器 A 被关联起来了。
靠的是什么?
不是 WebSocket。
不是 Session。
也不是 Cookie。
而是:
验证码。
二十、创建用户登录态
知道微信用户是谁以后,后端就可以处理正常的登录逻辑。
例如根据微信用户标识查询:
这个微信用户是否已经注册?
如果没有:
创建用户
例如初始化:
用户名
头像
用户信息
如果已经存在:
直接查询用户
接下来创建:
Session
或者其他登录凭证。
例如:
SESSION=abc123
然后服务器通过之前找到的:
SseEmitter-A
发送:
emitter.send(
"login#SESSION=abc123"
);
消息就顺着之前一直保持的 SSE 连接:
服务器
│
│ login#SESSION=abc123
▼
浏览器A
前端收到以后:
document.cookie =
text.substring(6);
然后:
window.location.reload();
浏览器重新请求页面时携带 Cookie。
服务器识别 Session。
最终:
登录成功
二十一、把完整流程重新走一遍
现在再看整个流程,就非常简单了。
第一步:打开登录页面
浏览器
↓
GET /login/code
↓
服务器
服务器根据 deviceId 获取验证码:
device-A → 836291
返回:
836291
第二步:建立半长连接
浏览器:
GET /subscribe?id=836291
服务器创建:
SseEmitter-A
保存:
836291 → SseEmitter-A
此时:
浏览器A
│
│ SSE连接
│
服务器
连接一直保持。
第三步:用户扫码
用户:
扫描公众号二维码
进入公众号。
然后发送:
836291
第四步:微信回调
微信服务器:
POST /wx/callback
告诉我们的服务器:
微信用户:user-A
验证码:836291
第五步:验证码匹配浏览器
服务器查询:
836291
↓
SseEmitter-A
于是知道:
user-A
↓
对应
↓
浏览器A
第六步:创建登录态
服务器:
查询/注册用户
↓
生成 Session
↓
SESSION=abc123
第七步:SSE 推送
服务器:
SseEmitter-A.send(
"login#SESSION=abc123"
)
浏览器收到:
login#SESSION=abc123
第八步:自动登录
浏览器:
保存 Cookie
↓
关闭 SSE
↓
刷新页面
↓
携带 Cookie
↓
服务器识别用户
↓
登录成功
到这里整个流程结束。
二十二、为什么这里不用 WebSocket?
这也是面试很容易继续追问的问题。
WebSocket 当然可以实现。
但是我们分析需求:
扫码登录过程中,浏览器真正需要发送多少实时消息?
基本没有。
主要都是服务器通知浏览器:
已扫码
验证码更新
登录成功
所以需求本质上是:
服务器
↓
浏览器
而 WebSocket 提供的是:
服务器
⇅
浏览器
能力更强,但这个场景未必需要。
因此使用 SSE:
实现简单
基于 HTTP
浏览器原生支持 EventSource
适合服务器单向通知
已经完全够用了。
所以:
扫码登录
支付结果通知
订单状态更新
任务执行进度
AI流式输出
消息提醒
这些主要由服务器主动推送状态的场景,都可以考虑 SSE。
而:
在线聊天
多人实时协作
实时游戏
双向实时控制
这种双方都需要频繁主动发送数据的场景,更适合 WebSocket。
二十三、SSE 和 WebSocket 到底怎么选?
可以简单记:
| 对比 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端主要向客户端推送 | 双向 |
| 协议基础 | HTTP | WebSocket 协议 |
| 浏览器使用 | EventSource | WebSocket |
| 实现复杂度 | 相对简单 | 相对复杂 |
| 自动重连 | EventSource 原生支持 | 通常自己处理 |
| 扫码登录 | 很适合 | 可以,但能力偏多 |
| 在线聊天 | 不太合适 | 很适合 |
所以不要简单理解成:
WebSocket 比 SSE 高级
正确的理解应该是:
根据业务通信模型选择合适的技术。
二十四、为什么项目最开始设计成两个接口?
现在我们有:
/login/code
负责:
获取验证码
以及:
/subscribe
负责:
建立 SSE
为什么不能一次完成?
从纯技术角度:
当然可以重新设计。
之所以拆成两个接口,很大程度上是历史原因。
早期的登录流程可能是:
用户关注公众号
↓
公众号生成验证码
↓
用户回到网站
↓
手动输入验证码
↓
完成登录
后来觉得操作太麻烦,于是改成:
网站生成验证码
↓
用户发给公众号
↓
SSE自动通知网页
↓
自动登录
但是为了复用以前的代码,没有彻底重构,于是最终保留:
获取验证码接口
+
建立 SSE 接口
这也是现实项目非常常见的现象:
代码结构不一定都是从零设计出来的,很多时候是随着业务不断迭代演化出来的。
二十五、这个方案有什么安全问题?
这部分非常重要。
假设:
用户A验证码:666
用户B验证码:888
服务器:
666 → SseEmitter-A
888 → SseEmitter-B
如果微信用户 A 不小心或者恶意发送:
888
服务器查询:
888
↓
SseEmitter-B
那么:
微信用户A
可能就会登录到:
浏览器B
这显然存在风险。
本质原因是:
验证码实际上承担了一次性临时登录凭证的作用。
如果验证码只有:
666
888
999
这种非常简单的数字,那么非常容易:
猜测
碰撞
误输入
恶意尝试
二十六、正式项目应该怎么优化?
至少应该做到:
验证码足够随机
↓
有效期足够短
↓
只能使用一次
↓
登录成功立即失效
↓
限制错误尝试次数
↓
接口限流
↓
校验微信回调签名
除此之外,更成熟的方案可以使用:
随机 state
随机 ticket
一次性 token
二维码唯一标识
而不是:
666
888
999
这种简单验证码。
比如:
8F2K9X7P
或者直接使用足够随机的一次性 token。
核心原则是:
用于关联浏览器和微信用户的凭证必须足够随机、短期有效、一次性消费。
二十七、缓存还要注意什么?
除了验证码安全,还有缓存生命周期。
例如:
deviceId → code
不能永久保存。
code → SseEmitter
更不能永久保存。
否则用户关闭浏览器以后:
浏览器没了
↓
SseEmitter还在
↓
验证码还在
↓
缓存不断堆积
因此应该设置:
验证码 TTL
SSE 超时时间
登录成功主动删除
异常主动删除
连接关闭主动清理
也就是说,完整生命周期应该是:
创建验证码
↓
建立 SSE
↓
等待用户扫码
↓
登录成功
↓
验证码失效
↓
SSE关闭
↓
缓存删除
而不是只考虑:
怎么创建
不考虑:
怎么销毁
二十八、最终架构图
把所有内容压缩到一张图里:
浏览器
│
│ ① 获取验证码
▼
后端服务器
│
│ deviceId → code
▼
836291
│
│ ② 建立 SSE
▼
code → SseEmitter
│
│
│ 半长连接保持
│
│
用户 ──③扫码──> 微信公众号
│
│ ④发送 836291
▼
微信服务器
│
│ ⑤ XML 回调
▼
后端服务器
│
│ 获取微信用户
│ +
│ 获取验证码
▼
836291
│
│ ⑥查询缓存
▼
SseEmitter
│
│ ⑦生成 Session
│
│ ⑧推送 login
▼
浏览器
│
│ 保存 Cookie
▼
登录成功
如果只允许记住一个数据结构,就记住:
deviceId
↓
code
↓
SseEmitter
如果只允许记住一条业务链路,就记住:
微信用户
↓
发送验证码
↓
微信回调
↓
根据验证码找到 SSE
↓
找到对应浏览器
↓
生成登录态
↓
SSE通知浏览器
↓
自动登录
二十九、面试时怎么讲?
如果面试官问:
你们微信公众号扫码登录是怎么实现的?
可以这样回答:
我们的实现主要是微信公众号回调、验证码映射和 SSE。用户打开登录页面以后,服务端首先根据当前设备生成一个临时验证码,然后浏览器基于这个验证码和服务端建立 SSE 连接,后端维护验证码到 SseEmitter 的映射。用户扫码进入公众号并发送验证码以后,微信服务器会回调我们的接口,我们根据微信用户标识识别用户,同时根据验证码找到对应的 SseEmitter,也就是找到正在等待登录的浏览器。完成用户注册或登录并生成 Session 后,再通过 SSE 把登录结果主动推送给浏览器,浏览器保存登录态并刷新页面,从而完成自动登录。
如果面试官继续问:
什么是半长连接?
回答:
项目里的半长连接实际上指的是 SSE,它并不是严格意义上的标准网络术语。浏览器先通过 HTTP 和服务端建立一个持续保持的连接,建立以后主要由服务端主动向客户端推送事件。相比 WebSocket 的全双工双向通信,SSE 更偏向服务端到客户端的单向推送,所以项目里形象地把它叫做半长连接。
继续问:
为什么不用 WebSocket?
回答:
因为扫码登录主要需要服务端通知浏览器扫码状态和登录结果,不需要双方频繁进行双向实时通信。SSE 基于 HTTP,浏览器原生支持 EventSource,实现成本更低,而且能够满足服务端主动推送的需求,所以这个场景下使用 SSE 更轻量。
继续问:
为什么不用轮询?
回答:
轮询也可以实现,但浏览器需要不断请求登录状态,会产生很多无效请求。SSE 建立连接以后,用户真正扫码登录成功时再由服务器主动推送结果,可以减少无意义请求,同时登录状态变化也能更及时地通知浏览器。
继续问:
两个缓存分别是干什么的?
回答:
deviceCodeCache 保存 deviceId 到验证码的映射,主要解决用户刷新页面时验证码频繁变化的问题;verifyCodeCache 保存验证码到 SseEmitter 的映射,主要用于微信公众号回调拿到验证码以后,快速找到对应的浏览器 SSE 连接。
最后如果问:
这个设计有什么问题?
回答:
最大的问题之一是验证码安全。如果验证码过短或者容易猜,本质上可能出现验证码碰撞或者冒用,所以正式系统应该使用高随机性、短有效期、一次性消费的临时凭证,同时增加尝试次数限制、接口限流和微信回调签名校验。另外 SSE 和验证码缓存也需要设置合理的超时时间,并在登录成功、连接异常或者超时后及时清理。
三十、总结
微信公众号扫码登录看起来涉及很多东西:
微信公众号
微信回调
XML
验证码
deviceId
缓存
SSE
SseEmitter
Session
Cookie
JavaScript
第一次看源码很容易陷进去。
但把细节全部拿掉以后,真正核心的设计只有三件事。
第一件事:微信怎么找到我们的服务器?
靠:
微信公众号回调
第二件事:微信用户怎么和浏览器对应起来?
靠:
验证码
服务器维护:
验证码 → SSE连接
第三件事:服务器怎么主动告诉浏览器登录成功?
靠:
SSE
也就是项目中所说的:
半长连接
所以最终可以把整个方案压缩成一句话:
浏览器先通过验证码与服务端建立 SSE 映射,用户在微信公众号中发送验证码后,微信服务器回调业务后端,后端根据验证码找到对应的 SSE 连接,完成用户身份识别和登录态创建,再通过 SSE 主动通知浏览器,从而实现微信公众号扫码后的自动登录。
真正理解这句话以后,再回头看:
deviceCodeCache
verifyCodeCache
SseEmitter
EventSource
callback
Session
Cookie
这些代码就不再是一堆零散的技术点,而只是围绕这条登录链路展开的具体实现。