微信扫码登录原理

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

图片

整套登录流程是如何设计的呢?

来,给大家手把手教学。

不 BB,上文章目录:

图片

01 方案设计

通常在产品的需求交底、评审之后,就到了我们研发人员出方案设计,常见的方案设计有以下几个板块

  1. 需求相关理解及目标
  2. 研发的设计方案
  • 相对完整的设计方案
  • 前后端交互方式、接口API约定
  • 测试要点
  1. 排期
  2. 验收标准
  3. 上线计划

1.1 背景与目标

技术派作为一个文章分享论坛,当然登录就是基本的功能点了,很多的功能都要求只有登录之后才能继续执行相关操作,如发文、点赞、评论等。

所以我们的主要目的就是实现技术派的用户登录。

基于登录这个需求场景,常见的登录方式有最经典的用户名/密码方式,也有近些年来广为普及的手机号/验证码的登录方式以及扫码登录方式。

我们主要实现的功能点是支持技术派的登录,上面几种方式都可以支持,我们这里给出不同登录方式的实现方案。

1.2 用户名密码方式登录

对于用户名密码的登录方式,属于经典的实现方式,一般来讲,实用这种方式时,除了基础的登录之外,还需要有搭配的用户注册、忘记密码、修改密码等功能点。

图片

如上图,分别给出注册、登录、忘记密码重置的流程示意图。

基于这种方案,我们的用户表中需要考虑下面几个关键信息:

  • userName: 用于登录的用户名;
  • password: 登录密码,注意db中不直接存储源码,常见的方案是将用户上传的密码 加盐之后计算MD5保存;
  • email/phone: 主要用于忘记密码时,向用户发送用于修改密码的验证码or重置密码的临时url(主要目的就是确定这个账号真的是xxx在操作)。

整个方案实现下来中规中矩,重点注意的关键点无非两个:

  • 密码注意不要明文存储;
  • 忘记密码时,需要给用户发送验证码。

优点

  • 用户注册成本低;
  • 流程清晰简单、易于理解。

缺点

  • 接口多,流程多(除了登录还有注册、忘记密码、修改密码等操作),实现工作量相对较大;
  • 用户容易忘记密码,安全性没有其他的高;
  • 手机号发送验证码时要花钱;邮箱发送验证码时容易被当作垃圾邮件拉黑。

1.3 验证码登录

验证码的登录方式对用户而言体验是比较友好的,也不用记密码,当然也不会担心忘记密码了,我们一般说的验证码登录方式专指手机号登录,一般的操作流程如下:

图片

从上面的流程示意图可以看出,用户表中核心存储手机号/邮箱即可。

  • phone: 采用手机号验证码的方式,存手机号即可;
  • email: 采用邮箱接收验证码的方式,存邮箱即可。

挂件的动作有两步:

  1. 用户首先输入手机号/邮箱,然后请求技术派发送验证码;
  2. 登录:提交手机号/邮箱 + 验证码。

优点

  • 对用户而言操作比较简单,不用记密码,也不用担心忘记密码、重置密码。

缺点

  • 整个登录流程是分段的,当接收验证码较慢时,可能会阻塞较长的时间;
  • 同样手机号接收验证码费钱;邮箱接收验证码对用户体验又不太好(特别是国内手机上使用邮箱的较少)。

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

登录逻辑如下,其他的是自动回复内容,不用关心

图片

上面区分了两步

  1. 用户注册,并生成用于身份识别的sessionId
  2. 找到对应的半长连接,自动登录跳转

说明

  • 上面的这套具体实现以实际的源码为准,这里不过多细说
图片

03 总结

当前的微信公众号登录的后端代码中,实现了两种登录方案:

  • 当前在使用的在公众号中输入验证码策略;
  • 已经废弃的从公众号获取验证码,然后再技术派的输入框中输入验证码登录的方案。

所以会发现在整个实现策略中,有一些冗余的操作,有兴趣的小伙伴可以将后面的登录方式给干掉,优化一下整个写法。

体验入口:https://paicoding.com

扫码领红包

微信赞赏支付宝扫码领红包

发表回复

后才能评论