微信扫码登录原理
先看看技术派网站的微信扫码登录:

整套登录流程是如何设计的呢?
来,给大家手把手教学。
不 BB,上文章目录:

01 方案设计
通常在产品的需求交底、评审之后,就到了我们研发人员出方案设计,常见的方案设计有以下几个板块
-
需求相关理解及目标 -
研发的设计方案
-
相对完整的设计方案 -
前后端交互方式、接口API约定 -
测试要点
-
排期 -
验收标准 -
上线计划
1.1 背景与目标
技术派作为一个文章分享论坛,当然登录就是基本的功能点了,很多的功能都要求只有登录之后才能继续执行相关操作,如发文、点赞、评论等。
所以我们的主要目的就是实现技术派的用户登录。
基于登录这个需求场景,常见的登录方式有最经典的用户名/密码方式,也有近些年来广为普及的手机号/验证码的登录方式以及扫码登录方式。
我们主要实现的功能点是支持技术派的登录,上面几种方式都可以支持,我们这里给出不同登录方式的实现方案。
1.2 用户名密码方式登录
对于用户名密码的登录方式,属于经典的实现方式,一般来讲,实用这种方式时,除了基础的登录之外,还需要有搭配的用户注册、忘记密码、修改密码等功能点。

如上图,分别给出注册、登录、忘记密码重置的流程示意图。
基于这种方案,我们的用户表中需要考虑下面几个关键信息:
-
userName: 用于登录的用户名; -
password: 登录密码,注意db中不直接存储源码,常见的方案是将用户上传的密码 加盐之后计算MD5保存; -
email/phone: 主要用于忘记密码时,向用户发送用于修改密码的验证码or重置密码的临时url(主要目的就是确定这个账号真的是xxx在操作)。
整个方案实现下来中规中矩,重点注意的关键点无非两个:
-
密码注意不要明文存储; -
忘记密码时,需要给用户发送验证码。
优点
-
用户注册成本低; -
流程清晰简单、易于理解。
缺点
-
接口多,流程多(除了登录还有注册、忘记密码、修改密码等操作),实现工作量相对较大; -
用户容易忘记密码,安全性没有其他的高; -
手机号发送验证码时要花钱;邮箱发送验证码时容易被当作垃圾邮件拉黑。
1.3 验证码登录
验证码的登录方式对用户而言体验是比较友好的,也不用记密码,当然也不会担心忘记密码了,我们一般说的验证码登录方式专指手机号登录,一般的操作流程如下:

从上面的流程示意图可以看出,用户表中核心存储手机号/邮箱即可。
-
phone: 采用手机号验证码的方式,存手机号即可; -
email: 采用邮箱接收验证码的方式,存邮箱即可。
挂件的动作有两步:
-
用户首先输入手机号/邮箱,然后请求技术派发送验证码; -
登录:提交手机号/邮箱 + 验证码。
优点
-
对用户而言操作比较简单,不用记密码,也不用担心忘记密码、重置密码。
缺点
-
整个登录流程是分段的,当接收验证码较慢时,可能会阻塞较长的时间; -
同样手机号接收验证码费钱;邮箱接收验证码对用户体验又不太好(特别是国内手机上使用邮箱的较少)。
1.4 扫码登录
关于扫码登录,对于pc站点而言可以说成了标配了;当然前提是安装了对应的app;详情可以参看。
-
技术派之扫码登录实现原理(已完成)
它的基本流程如下图:
一般的扫码登录,前提要求是你已经网站的用户了,安装对应的app且登录之后,给pc站点的登录新增一个免密的选择方式而已;与我们技术派的实际场景还是有出入的。
基于上面的操作示意图,核心关键点就在于借助APP的扫码操作,来识别用户的身份。
优点
-
登录方式简单,成本很低。
缺点
-
要求用户下载app; -
实现姿势相比于上面两个会更有难度一点点。
1.5 微信公众号登录
技术派当下没有app,也不确定之后会不会有(😂),我们采用的登录方式是上面扫码登录的变种,既然我没有app,那就借助微信的公众号来做
虽然我这里登录时展示的是一个二维码,但实际上的操作是借助这个展示的过程,和前端构建一个半长连接,当用于向公众号发送验证码之后,微信公众平台会将用户发送信息转发给技术派的服务器,通过验证码来识别请求登录的用户身份,找到对应的半长连接,实现用户的自动登录跳转

基于上面的方案,我们的用户表中需要存储一个核心的用户标识
-
uuid: 微信公众平台返回的用于唯一标识
优点
-
对于用户而言登录方式简单,无需记忆密码、用户名,有微信号即可 -
对于学习技术派项目的小伙伴而言,又可以学到一个有意思的知识点
缺点
-
实现方式相对前面的复杂一点 -
个人公众号不支持自定义二维码参数,因此还需要输入验证码这一步骤,操作麻烦了一点(企业公众号就可以实现扫码之后直接自动登录,无需输入验证码)
1.6 方案选型
上面给了 4 个方案,显然我们最终选择的是第四个基于微信公众号来登录
因此你需要准备的就是一个微信公众号,其次就是一台微信公众平台可以回调的服务器
02 实现方式
2.1 微信公众平台配置
因为我们实际使用的微信公众平台功能较少,主要就是一个接收用户的发送信息,所以需要的配置也不多
直接登录后台,开启服务器相关配置

注意,微信公众平台接入时,需要进行一个token验证,即返回它传参的echostr
对应的实现也比较简单

/**
* 微信的公众号接入 token 验证,即返回echostr的参数值
*
* @param request
* @return
*/
@GetMapping(path = "callback")
@ResponseBody
public String check(HttpServletRequest request) {
String echoStr = request.getParameter("echostr");
if (StringUtils.isNoneEmpty(echoStr)) {
return echoStr;
}
return "";
}
除此之外,另外已给需要实现的就是接收微信公众平台的回调,注意微信公众号采用的是xml进行通讯(说实话这个真有点蛋疼)
我们需要实现的接口如下(后文给出详设)
/**
* fixme: 需要做防刷校验
* 微信的响应返回
* 本地测试访问: curl -X POST 'http://localhost:8080/wx/callback' -H 'content-type:application/xml' -d '<xml><URL><![CDATA[https://hhui.top]]></URL><ToUserName><![CDATA[一灰灰blog]]></ToUserName><FromUserName><![CDATA[demoUser1234]]></FromUserName><CreateTime>1655700579</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[login]]></Content><MsgId>11111111</MsgId></xml>' -i
*
* @param msg
* @return
*/
@PostMapping(path = "callback",
consumes = {"application/xml", "text/xml"},
produces = "application/xml;charset=utf-8")
public BaseWxMsgResVo callBack(@RequestBody WxTxtMsgReqVo msg) {
}
2.2 用户扫码登录
在前面的方案设计中,有一点没有特别的标注出来,那就是用户点击登录之后,弹出一个微信公众号的二维码的同时,我们需要建立一个与前端的半长连接,主要目的就是用于后续的自动登录跳转
这里我们设计了两个接口,一个是获取登录的验证码,一个是建立半长连接
验证码获取
com.github.paicoding.forum.web.front.login.rest.LoginRestController#qrLogin

/**
* 获取登录的验证码
*
* @return
*/
@GetMapping(path = "/login/code")
public ResVo<QrLoginVo> qrLogin(HttpServletRequest request, HttpServletResponse response) {
QrLoginVo vo = new QrLoginVo();
vo.setCode(qrLoginHelper.genVerifyCode(request, response));
return ResVo.ok(vo);
}
// 核心实现就是验证码那里
/**
* 加一层设备id,主要目的就是为了避免不断刷新页面时,不断的往 verifyCodeCache 中塞入新的kv对
* 其次就是确保五分钟内,不管刷新多少次,验证码都一样
*
* @param request
* @param response
* @return
*/
public String genVerifyCode(HttpServletRequest request, HttpServletResponse response) {
String deviceId = initDeviceId(request, response);
String code = deviceCodeCache.getUnchecked(deviceId);
SseEmitter lastSse = verifyCodeCache.getIfPresent(code);
if (lastSse != null) {
// 这个设备之前已经建立了连接,则移除旧的,重新再建立一个; 通常是不断刷新登录页面,会出现这个场景
lastSse.complete();
verifyCodeCache.invalidate(code);
}
return code;
}
关于验证码的获取,做了一个兼容策略,同一个设备,不访问多少次验证码都是同一个(刷新除外),所以我们做了两个缓存
-
deviceCodeCache: 缓存 deviceId 设备 与验证码之间的映射关系 -
verifyCodeCache: 缓存 code验证码 与 半长连接之间的映射关系
半长连接建立
com.github.paicoding.forum.web.front.login.view.LoginViewController#subscribe

/**
* 客户端与后端建立扫描二维码的长连接
*
* @param code
* @return
*/
@GetMapping(path = "subscribe", produces = {org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE})
public SseEmitter subscribe(String code) throws IOException {
return qrLoginHelper.subscribe(code);
}
/**
* 保持与前端的长连接
* <p>
* 直接根据设备拿之前初始化的验证码,不直接使用传过来的code
*
* @param code
* @return
*/
public SseEmitter subscribe(String code) throws IOException {
HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
HttpServletResponse res = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
String device = initDeviceId(req, res);
String realCode = deviceCodeCache.getUnchecked(device);
// fixme 设置15min的超时时间, 超时时间一旦设置不能修改;因此导致刷新验证码并不会增加连接的有效期
SseEmitter sseEmitter = new SseEmitter(15 * 60 * 1000L);
verifyCodeCache.put(code, sseEmitter);
sseEmitter.onTimeout(() -> verifyCodeCache.invalidate(realCode));
sseEmitter.onError((e) -> verifyCodeCache.invalidate(realCode));
if (!Objects.equals(realCode, code)) {
// 若实际的验证码与前端显示的不同,则通知前端更新
sseEmitter.send("initCode!");
sseEmitter.send("init#" + realCode);
}
return sseEmitter;
}
上面就是一个简单的半长连接建立过程;并会保存一个验证码与半长连接sseEmitter之间的映射关系;后续在登录时,就可以通过验证码找到对应的SseEmitter,从而实现登录
说明
当前上面的两个接口是搭配使用
-
前端首先调用获取验证码接口 -> 这里将设备与验证码建立映射,并会释放之前建立的半长连接,返回验证码 -
基于验证码来建立半长连接 -> 此时构建了 验证码与半长连接的映射,因此后续登录时,直接可以通过验证码查到对应的连接客户端,从而实现自动登录
那么上面这个设计为什么要拆分为两个接口?
-
这个是由于历史原因,最开始的微信公众号登录采用的方案是用户关注公众号之后,输入关键字 验证码/login
,然后技术派返回验证码给公众号,然后用户在登录的页面上输入这个验证码来实现登录的; -
鉴于上面这个流程操作比较繁琐,所以我们改成了现在的这种操作方式;但是在实现上就没有重新设计,而是直接复用了之前的方案 (这也是现实中的项目,在经过一系列的迭代之后,逐渐往屎山发展的重要原因) -
有兴趣的小伙伴可以尝试优化一下这个流程
2.3 前端调用姿势
上面两个接口主要是后端的接口设计,整个流程当然还缺不了前端的支持,我们看一下前端是如何实现的
src/main/resources/templates/components/layout/navbar.html

核心实现如下
$('#loginModal').on('show.bs.modal', function () {
console.log("登录弹窗已展示!");
// 这个就是点击技术派的登录按钮,显示二维码弹出时触发的逻辑
loginCode();
})
function loginCode() {
$.ajax({
url: "/login/code", dataType: "json", type: "get", success: function (data) {
const code = data['result']['code'];
// 首先请求验证码,然后基于验证码来建立半长连接
buildConnect(code);
if ([[${!#strings.equals(global.env, 'prod')}]]) {
document.getElementById('mockLogin').setAttribute('data-verify-code', code);
document.getElementById('mockLogin2').setAttribute('data-verify-code', code);
}
}
})
}
/**
* 建立半长连接,用于实现自动登录
* @param code
*/
function buildConnect(code) {
const stateTag = document.getElementById('state');
const codeTag = document.getElementById('code');
const subscribeUrl = "/subscribe?id=" + code;
const source = new EventSource(subscribeUrl);
source.onmessage = function (event) {
let text = event.data;
console.log("receive: " + text);
if (text.startsWith('refresh#')) {
// 刷新验证码
const newCode = text.substring(8).trim();
codeTag.innerText = newCode;
stateTag.innerText = '已刷新';
stateTag.style.display = 'block';
if ([[${!#strings.equals(global.env, 'prod')}]]) {
document.getElementById("mockLogin").setAttribute('data-verify-code', newCode);
document.getElementById("mockLogin2").setAttribute('data-verify-code', newCode);
}
} else if (text === 'scan') {
// 二维码扫描
stateTag.innerText = '已扫描';
stateTag.style.display = 'block';
} else if (text.startsWith('login#')) {
// 登录格式为 login#cookie
if(autoRefresh) {
window.clearInterval(autoRefresh);
}
console.log("登录成功,保存cookie", text)
document.cookie = text.substring(6);
source.close();
if (window.location.pathname === "/login") {
// 登录成功,跳转首页
window.location.href = "/";
} else {
// 刷新当前页面
window.location.reload();
}
} else if (text.startsWith("init#")) {
const newCode = text.substring(5).trim();
codeTag.innerText = newCode;
console.log("初始化验证码: ", newCode);
}
};
source.onopen = function (evt) {
console.log("开始订阅");
}
source.onerror = function (evt) {
console.log("连接错误,重新开始", evt)
buildConnect(code);
}
codeTag.innerText = code;
stateTag.innerText = '验证码有效期为五分钟,若过期后可刷新验证码';
}
上面是前端js的实现,写得不咋样,有兴趣的小伙伴可以重写一下;整个逻辑与后端的接口设计是搭配的;先获取验证码再建立连接;完全是可以省略前面的一步操作的
2.4 回调实现自动登录
上面的两步操作之后,技术派的前端用户操作与后台的逻辑基本上就算是完成了;
用户登录之后 -> 与后端建立半长连接
接下来就是用户将验证码发送给公众号,然后公众号将用户输入转发给技术派后端注册的回调接口了

回调接口如上,因为我们的公众号为个人公众号,所以图中的 if
逻辑我们走不到,有企业公众号的小伙伴则可以进入到这个流程;我们重点查看下面的 wxHelper.buildResponseBody
登录逻辑如下,其他的是自动回复内容,不用关心

上面区分了两步
-
用户注册,并生成用于身份识别的sessionId -
找到对应的半长连接,自动登录跳转
说明
-
上面的这套具体实现以实际的源码为准,这里不过多细说

03 总结
当前的微信公众号登录的后端代码中,实现了两种登录方案:
-
当前在使用的在公众号中输入验证码策略; -
已经废弃的从公众号获取验证码,然后再技术派的输入框中输入验证码登录的方案。
所以会发现在整个实现策略中,有一些冗余的操作,有兴趣的小伙伴可以将后面的登录方式给干掉,优化一下整个写法。
体验入口:https://paicoding.com
扫码领红包微信赞赏
支付宝扫码领红包