大家好呀,我是楼仔。
实际体验过技术派的小伙伴应该知道,整个登录是基于微信公众号来实现的,我们先一睹为快。
那么整套登录流程是怎么设计的呢?
如果让我们自己来实现这么一个流程,如何去做?
现在方式有没有更好的替换方案?
下面我们将以一个功能相对完整的需求作为驱动,通过标准的方案设计、实时方案,给大家手把手教学。
不 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 总结
当前的微信公众号登录的后端代码中,实现了两种登录方案:
- 当前在使用的在公众号中输入验证码策略;
- 已经废弃的从公众号获取验证码,然后再技术派的输入框中输入验证码登录的方案。
所以会发现在整个实现策略中,有一些冗余的操作,有兴趣的小伙伴可以将后面的登录方式给干掉,优化一下整个写法。
最后再回顾一下效果:
补充说明:
既然上面提到了两种方案,就再多说一句
- 当前选用的方案,实际上有个缺陷,我们为了简化使用者的操作,对于验证码做了一个优先级,最初生成的都是一些非常好记的如666,888,999等数字,所以存在帮别人登录的可能性
关于整个登录流程,这篇文章其实想说两点
- 一个需求设计方案怎么写 (当具体实现有多种策略时,要对比各自的优劣,然后给出自己的选择,再向上汇报进行方案评审)
- 整体流程的实现方案
- 技术细节有很多,需要大家深挖一下
- 半长连接
- 微信公众平台接入
- xml/json一个项目中如何同时支持
- 缓存结构设计
- 用户名、头像自动生成选择策略
- 用户身份识别方案,session/cookie
- 重定向
- ...
知识星球
目前技术派已经整理出 89 篇文章(已完成 83 篇
,✅表示已经完成),为了方便大家学习,文章标题后面追加了 2 个标签,分别为“🌟新人必看”和“👍强烈推荐”,方便大家查阅,妥妥细节控~~
技术派教程是星球推出的主打服务,推出的「技术派」开源项目,已收获 1000+ Star,除此之外,还包括其它多项福利,详见 技术派知识星球 。
原价 129 元,送大家一张 30 元优惠券,券后仅 99 元。
说明:楼仔的「技术派」星球,和沉默王二的「Java程序员进阶之路」星球合并了,之前是发的“技术派”的星球优惠券,大家可以直接进入二哥的星球,除了以上所说的内容,还能享受更多福利。
如果觉得不满意,支持 3 天无理由退款哈~~
回复