微信公众号自动登录方案

基于微信公众号登录

借助微信公众号来试实现社区登录。登录的时候展示的是一个二维码,但实际上的操作是借助这个展示的过程,和前端构建一个半长连接,当用户向公众号发送验证码之后,微信公众平台会将用户发送的消息转发给服务器,通过验证码来识别请求登录的用户身份,找到对应的半长连接,实现用户的自动登录跳转。

方案设计

在这里插入图片描述
基于上面的方案,用户表中需要存储一个核心的用户标识。

  • uuid:微信公众平台返回的用于唯一标识

优点

  • 对于用户而言登录方式简单,无需记忆密码,用户名,有微信号即可

缺点

  • 实现方式相对复杂 ;
  • 个人公众号不支持自定义二维码参数,因此需要输入验证码。(企业公众号可以实现扫码之后直接自动登录,无需输入验证码);

方案准备

一个微信公众号;
一台微信公众平台可以回调的服务器。

方案实现

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对
     * 其次就是确保五分钟内,不管刷新多少次,验证码都一样
     *
     * @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验证码半长连接之间的映射关系

半长连接建立:
在这里插入图片描述

    /**
     * 客户端与后端建立扫描二维码的长连接
     *
     * @param code
     * @return
     */
    @GetMapping(path = "subscribe", produces = {org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE})
    public SseEmitter subscribe(@RequestParam(name = "id") 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,从而实现登录。
说明
验证码获取和半长连接建立这两个接口是搭配使用:

  • 前端首先调用获取验证码接口->这里将设备与验证码建立映射,并会释放之前建立的半长连接,返回验证码
  • 基于验证码来建立半长连接,此时构建了验证码与半长连接的映射,因此后续登录时,直接可以通过验证码查到对应的连接客户端,从而实现登录。

3. 前端调用
核心实现如下

    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')}]]) {
            $("#mockLogin").attr('data-verify-code', code);
            $("#mockLogin2").attr('data-verify-code', code);
          }
        }
      })
    }
        /**
     * 建立半长连接,用于实现自动登录
     * @param code
     */
    function buildConnect(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.text(newCode);
          stateTag.text("已刷新");
          if ([[${!#strings.equals(global.env, 'prod')}]]) {
            $("#mockLogin").attr('data-verify-code', newCode);
            $("#mockLogin2").attr('data-verify-code', newCode);
          }
        } else if (text === 'scan') {
          // 二维码扫描
          stateTag.text('已扫描');
        } else if (text.startsWith('login#')) {
          // 登录格式为 login#cookie
          if(autoRefresh) {
            window.clearInterval(autoRefresh);
          }
          console.log("登录成功,保存cookie", text)

          document.cookie = text.substring(6);
          source.close();
          refreshPage();

        } else if (text.startsWith("init#")) {
          const newCode = text.substring(5).trim();
          codeTag.text(newCode);
          console.log("初始化验证码: ", newCode);
        }
      };

      source.onopen = function (evt) {
        console.log("开始订阅");
      }
      source.onerror = function (evt) {
        console.log("连接错误,重新开始", evt)
        buildConnect(code);
      }
      codeTag.text(code);

      if(autoRefresh) {
        window.clearInterval(autoRefresh);
      }
      // 先关闭自动刷新验证码
      // autoRefresh = setInterval(function () {
      //     refreshCode();
      // }, 5 * 60 * 1000)
    }

4. 微信公众号回调实现自动登录
用户登录之后 ——》与后端建立半长连接
接下来就是用户将验证码发送给公众号,然后公众号将用户输入转发给技术派后端注册的回调接口

    /**
     * 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) {
        String content = msg.getContent();
        if ("subscribe".equals(msg.getEvent()) || "scan".equalsIgnoreCase(msg.getEvent())) {
            String key = msg.getEventKey();
            if (StringUtils.isNotBlank(key) || key.startsWith("qrscene_")) {
                // 带参数的二维码,扫描、关注事件拿到之后,直接登录,省却输入验证码这一步
                // fixme 带参数二维码需要 微信认证,个人公众号无权限
                String code = key.substring("qrscene_".length());
                String verifyCode = sessionService.autoRegisterAndGetVerifyCode(msg.getFromUserName());
                qrLoginHelper.login(code, verifyCode);
                WxTxtMsgResVo res = new WxTxtMsgResVo();
                res.setContent("登录成功");
                fillResVo(res, msg);
                return res;
            }
        }

        BaseWxMsgResVo res = wxHelper.buildResponseBody(msg.getEvent(), content, msg.getFromUserName());
        fillResVo(res, msg);
        return res;
    }

登录逻辑如下

 // 微信公众号登录
        if (CodeGenerateUtil.isVerifyCode(content)) {
            String verifyCode = sessionService.autoRegisterAndGetVerifyCode(fromUser);
            if (qrLoginHelper.login(content, verifyCode)) {
                textRes = "登录成功";
            } else {
                textRes = "验证码过期了,刷新登录页面重试一下吧";
            }
        }

小结

还有一些细节,比如缓存结构的设计,用户名、头像自动生成选择策略,微信公众平台接入,半长连接等。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

YuannaY

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值