技术派项目的整个登录流程是基于微信公众号来实现的,整套登录流程的设计?自我实现这样一个流程可以怎样去做?
接下来将以一个工作中的一个功能相对完成的需求作为驱动,来设计确立实时方案来介绍。
方案设计
通常在产品的需求底层、评审之后,就到研发人员出方案设计,常见的方案设计有以下板块。
1、需求相关理解及目标
2、研发的设计方案
- 相对完整的设计方案
- 前后端交互方式,接口API约定
- 测试要点
3、排期
4、验收标准
5、上线计划
这里主要放在前两点
1、背景于目标
技术派是一个文章分享论坛,登录当然是基本的功能,后续操作都需要登录,发文、点赞、评论等。在这里我们主要目的是实现技术派的用户登录
2、设计方案
基于登陆这个需求场景,常用的登录方式有经典的用户名+密码的方式,近年来普及的手机号+验证码和扫码登录也比较流行。
这里给出不同的登录方式实现方案。
2.1用户名密码的方式登录
对于用户名密码方式登录,属于经典的实现方式,一般来讲,使用这种方式来时,除开基础的登陆之外,还需要搭配的用户注册、忘记密码、修改密码啊功能点等
上图分别给出,注册、登录、忘记密码重置流程示意图
基于这种方案,我们的用户表要考虑如下几个
关键信息
- userName:用于登录的用户名
- password:登录密码,注意db中不直接存储源码,常见的方案是将用户上传的密码加盐之后计算MD5保存。
- email/phone:主要用于忘记密码时,向用户发送修改的验证码or重置密码时的url,主要确定这个账号真时xxx在操作。
重点关注
- 密码注意不要存储明文
- 忘记密码时,需要给用户发送验证码
优点
用户注册成本低
流程清晰简单、容易理解
缺点
接口多,流程多(除登录还有注册、忘记密码、修改密码等),实现工作量相对较大
用户容易忘记密码,安全性没有其他高
手机号发送验证码时要花钱,邮箱发送验证码时容易被当作垃圾邮件拉黑
2.2验证码登录
验证码登陆的方式对用户而攀体验比较友好,也不用记住密码,这里验证码登录时手机号验证码登录,
流程如下;
从上面的流程示意图可以看出,用户表中核心存储手机号/邮箱即可
phone:采用手机号验证码的方式,村手机号即可
email:采用邮箱接收验证码的方式,存邮箱即可
挂件的动作有两步
1.首先输入手机号/邮箱,然后技术派发送验证码
2.登录:提交手机号/邮箱+验证码
优点
于用户而言操作比较简单,不用做密码相关的操作。
缺点
整个登录流程是分段的,当接收验证码时较慢,可能会阻塞较长时间
同样手机号接收验证码时费钱;邮箱接收验证码时用户体验不太好。
2.3扫码登录
关于扫码登录,对于pc站而言,安装对应的app,这个时标配。
基本流程
一般的扫码登录,前提要求是你已经是网站的用户了,安装对应的app且登录之后,给pc站点的登录新增一个免密的选择方式而已;和技术派场景有出入。
基于上面的操作示意图,核心关键点在于借助App的扫码操作,来识别用户的身份
优点
登录方式简单,成本低
缺点
要求用户下载app
实现先对于上面两个会有难度
2.4基于微信公号登录
本项目无app,采用的是扫码登录的变种,借助微信公众号来做。
虽然我这里登录时展示的是一个二维码,但实际上的操作是借助这个展示的过程,和前端构建一个半长链接,用于向公众号发送验证码之后,微信公众平台会将用户发送信息转发给技术派服务器,通过验证码来识别请求登录的用户身份,找到对应的半长链接,实现用户的自动登录跳转。
基于上面的方案,我们的用户表需要存储一个核心用户标识
uuid:微信公众平台返回的用于唯一标识
优点
对于用户而言登录方式简单,不用记忆密码,有微信号就行
缺点:
实现相对比较复杂
个人公众号不支持自定义二维码参数,因此还需要输入验证码这一步骤,操作相对麻烦一点,企业公众号可以扫码直接登录。
3、方案确定
最终我们选择基于微信公众号来登录
需要准备一个微信公众号,还有一个微信公众平台可以回调的服务器
实现策略
1.微信公众平台配置
我们实际使用的微信公众平台功能较少,主要就是一个接收用户发送的信息,所以需要的配置不多
直接登录后台,开启服务器相关配置
注意,微信接入平台接入时,需要进行一个token验证,即返回它传参的echostr对应的实现也比较简单
/**
* 微信的公众号接入 token 验证,即返回echostr的参数值
*
* @param request
* @return
*/
@GetMapping(path = "callback")
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.用户扫码登录
在前面的设计方案中,有一点没特别标注出来,就是用户点击登录之后,弹出一个微信公众号的二维码的同时,我们需要建立一个于前端的半长连接,主要就是用于后续的自动登录跳转
这里我们设计了两个接口,一个是获取登录的验证码,一个是建立半长连接
验证码获取
/**
* 获取登录的验证码
*
* @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对
* 其次就是确保五分钟内,不管刷新多少次,验证码都一样
*
* 解释:
*
* 该方法接收HTTP请求和响应对象作为参数,用于处理生成验证码并建立与设备ID和缓存相关的逻辑。
*
* initDeviceId(request, response) 函数用于初始化设备ID,该ID标识不同的设备。设备ID的生成方式在此处未给出,但可以假设它根据请求的一些信息计算得出。
*
* deviceCodeCache 和 verifyCodeCache 是缓存对象,可能是类似于 Guava 的缓存工具。deviceCodeCache 用于存储设备ID与生成的验证码之间的映射关系,而 verifyCodeCache 用于存储验证码与SseEmitter之间的映射关系。
*
* 代码从 deviceCodeCache 中获取与设备ID相关的验证码,如果没有找到则会生成新的验证码。
*
* 通过从 verifyCodeCache 中获取与该验证码相关的SseEmitter,可以判断是否已经建立了连接。
*
* 如果之前存在与该验证码关联的SseEmitter,意味着设备已经建立了连接,需要关闭旧连接以及从缓存中移除旧条目,以便后续重新建立连接。
*
* 最后,方法返回生成的验证码。
*
* 总之,这个方法的主要目的是生成验证码并管理设备ID以及缓存的关联。它确保了同一设备在五分钟内刷新页面时可以获得相同的验证码,同时在设备建立连接时会处理旧连接的关闭和缓存的更新。
*
* @param request HTTP请求对象,用于获取请求信息。
* @param response HTTP响应对象,用于处理响应。
* @return 生成的验证码。
*/
public String genVerifyCode(HttpServletRequest request, HttpServletResponse response) {
// 初始化设备ID,该ID用于标识不同的设备。
String deviceId = initDeviceId(request, response);
// 从设备验证码缓存中获取之前生成的验证码(如果有)。
String code = deviceCodeCache.getUnchecked(deviceId);
// 从验证代码缓存中获取之前与该验证码关联的SseEmitter(服务器发送事件)。
SseEmitter lastSse = verifyCodeCache.getIfPresent(code);
if (lastSse != null) {
// 这个设备之前已经建立了连接,则移除旧的,重新再建立一个; 通常是不断刷新登录页面,会出现这个场景
关闭旧连接,通常用于处理不断刷新登录页面等情况。
lastSse.complete();
从验证代码缓存中移除之前的缓存条目,以便重新建立连接。
verifyCodeCache.invalidate(code);
}
// 返回生成的验证码。
return code;
}
关于验证码的获取,做了一个兼容策略,同一个设备,不论访问多少次验证码都是同一个(刷新除外),所以我们做了两个缓存
deviceCodeCache:缓存deviceld设备与验证码之间的映射关系
verifyCodeCache:缓存验证码与半长连接之间的映射关系
半长连接建立
/**
* 客户端与后端建立扫描二维码的长连接
*
* @param id
* @return
*/
@GetMapping(path = "subscribe", produces = {org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE})
public SseEmitter subscribe(String id) throws IOException {
return qrLoginHelper.subscribe(id);
}
/**
* 保持与前端的长连接
* <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 reaCode = deviceCodeCache.getUnchecked(device);
// fixme 设置15min的超时时间, 超时时间一旦设置不能修改;因此导致刷新验证码并不会增加连接的有效期
SseEmitter sseEmitter = new SseEmitter(15 * 60 * 1000L);
verifyCodeCache.put(code, sseEmitter);
sseEmitter.onTimeout(() -> verifyCodeCache.invalidate(reaCode));
sseEmitter.onError((e) -> verifyCodeCache.invalidate(reaCode));
if (!Objects.equals(reaCode, code)) {
// 若实际的验证码与前端显示的不同,则通知前端更新
sseEmitter.send("initCode!");
sseEmitter.send("init#" + reaCode);
}
return sseEmitter;
}
上面就是一个简单的半长连接建立的过程;并会保存一个验证码与半场链接sseEmitter之间的映射关系;后续在登陆时,就可以通过验证码找到对应的SseEmitter,从而实现登录。
说明
当前上面的两个接口是搭配使用的
- 前端首先调用获取验证码的接口->这里将设备与验证码建立映射,并会释放之前建立的半长连接,返回验证码
- 基于验证码来建立半长连接-> 此时构建了验证码与半长连接的映射,因此后续登录时,直接可以通过验证码查到对应的连接客户端,从而实现自动登录。
那么上面这个设计为什么要拆分为两个接口?
这个由于历史原因,最开始的微信公众号登录采用的方案是用户关注公众号之后,输入关键字‘验证码/login’ ,然后技术派返回验证码给公众号,然后用户在登录得页面上输入这个验证码来实现登录的;
鉴于上面的操作流程比较繁琐,所以改成了现在这种操作方式;但是在实现上就没有重新设计,而是直接服用之前的方案。
3.前端调用姿势
上面两个接口主要是后端的接口设计,整个流程还缺少前端的支持,来看一下前端是如何实现的
核心实现如下
$('#loginModal').on('show.bs.modal', function () {
console.log("登录弹窗已展示!");
//这个就是点击技术派的登录按钮,显示二维码弹出时触发的逻辑
loginCode();
})
$('#refreshCode').click(() => {
refreshCode()
})
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 = '验证码有效期为五分钟,若过期后可刷新验证码';
if(autoRefresh) {
window.clearInterval(autoRefresh);
}
// 先关闭自动刷新验证码
// autoRefresh = setInterval(function () {
// refreshCode();
// }, 5 * 60 * 1000)
}
上面是前端js实现的,整个逻辑与后端接口的设计是搭配的。先获取验证码再建立连接。
4.微信公众号回调实现自动登录
上面的两步操作之后,技术派的前端用户操作与后台的基本逻辑就算完成了;
- 用户登录之后 ->与后端建立半长连接
接下来就是用户将验证码发送给公众号,然后公众号将用户输入转发给技术派后端注册的回调接口了。
回调接口如上,因为我们的公众号为个人公众号,所以图中的if逻辑我们走不到,重点查看下面的
WxHelper.buildResponseBody登录逻辑如下,其他的是自动回复内容,不用关心
上面区分了两步
1.用户注册,并生成用于身份识别的sessionId
2.找到对应的长连接,自动登录跳转
说明
上面的这套具体实现以实现的源码为准。
5.小结
基于上面这个设计思路以及关键的实现动作,整个基于微信公众号的登录方案就实现了。