SpringBoot+Websokect实现微信扫码自动登录

625 阅读6分钟

前言

现在不少平台pc网站端都有微信扫码登录方式,一般的微信扫码登录分微信授权登录和关注公众号实现自动登录,这篇文章主要介绍关注微信公众号的实具体现流程。Websokect 主要是扫码动作发生后,服务端要给客户端主动推送消息(也可http轮询请求服务端替换掉Websokect方式),小墨在这里用的是,前端订阅服务端websocket消息的方式,下面会介绍具体的实现逻辑。

公众号配置

登录公众号后台,配置接入URL和Token等

注意:还需要添加ip白名单,获取动态参数二维码的时候需要ip白名单

扫码登录流程

扫码登录的大致流程图

生成带参数二维码: 前端带auth参数请求 -> 获取access_token -> auth参数作为senceStr 请求二维码凭证ticket -> ticket换取登录二维码 -> 二维码链接返回给前端

扫码动作发生后: 扫码处理handler获取到senceStr -> 缓存取出auth中匹配 -> websocket推送给前端 -> 前端订阅匹配到消息 -> 登录成功 -> 跳转页面

微信公众号后台逻辑

SpringBoot配置:

pom.xml 文件

 <!--  微信官方的jar  -->
    <dependency>
        <groupId>com.github.binarywang</groupId>
        <artifactId>weixin-java-mp</artifactId>
        <version>3.9.0</version>
    </dependency>

yml 文件

#微信配置
wx:
  mp:
    configs:
      - appId: xxxx
        secret: xxxxxxxxx
        token: xxxxxxxxxx
        aesKey: xxxxxxxxxxxxx

yml文件中参数注入

@Data
@ConfigurationProperties(prefix = "wx.mp")
public class WxMpProperties {
    private List<MpConfig> configs;

    @Data
    public static class MpConfig {
        /**
         * 设置微信公众号的appid
         */
        private String appId;

        /**
         * 设置微信公众号的app secret
         */
        private String secret;

        /**
         * 设置微信公众号的token
         */
        private String token;

        /**
         * 设置微信公众号的EncodingAESKey
         */
        private String aesKey;
    }
}

具体代码实现

流程图:

微信推送给公众号的消息类型很多,不同的handler处理不同的事件event。

公众号接入:

/**
 * 接入公众号
 */
@Slf4j
@AllArgsConstructor
@RestController
@RequestMapping("/wechat/portal/{appid}")
public class WechatPortalController {

    private final WxMpService wxService;
    private final WxMpMessageRouter messageRouter;

    @PassToken
    @GetMapping(produces = "text/plain;charset=utf-8")
    public String authGet(@PathVariable String appid,
                          @RequestParam(name = "signature", required = false) String signature,
                          @RequestParam(name = "timestamp", required = false) String timestamp,
                          @RequestParam(name = "nonce", required = false) String nonce,
                          @RequestParam(name = "echostr", required = false) String echostr) {

        log.info("\n接收到来自微信服务器的认证消息:[{}, {}, {}, {}]", signature,
                timestamp, nonce, echostr);
        if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
            throw new IllegalArgumentException("请求参数非法,请核实!");
        }

        if (!this.wxService.switchover(appid)) {
            throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
        }

        if (wxService.checkSignature(timestamp, nonce, signature)) {
            return echostr;
        }

        return "非法请求";
    }

    @PassToken
    @PostMapping(produces = "application/xml; charset=UTF-8")
    public String post(@PathVariable String appid,
                       @RequestBody String requestBody,
                       @RequestParam("signature") String signature,
                       @RequestParam("timestamp") String timestamp,
                       @RequestParam("nonce") String nonce,
                       @RequestParam("openid") String openid,
                       @RequestParam(name = "encrypt_type", required = false) String encType,
                       @RequestParam(name = "msg_signature", required = false) String msgSignature) {
        log.info("\n接收微信请求:[openid=[{}], [signature=[{}], encType=[{}], msgSignature=[{}],"
                        + " timestamp=[{}], nonce=[{}], requestBody=[\n{}\n] ",
                openid, signature, encType, msgSignature, timestamp, nonce, requestBody);

        if (!this.wxService.switchover(appid)) {
            throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
        }

        if (!wxService.checkSignature(timestamp, nonce, signature)) {
            throw new IllegalArgumentException("非法请求,可能属于伪造的请求!");
        }

        String out = null;
        if (encType == null) {
            // 明文传输的消息
            WxMpXmlMessage inMessage = WxMpXmlMessage.fromXml(requestBody);
            WxMpXmlOutMessage outMessage = this.route(inMessage);
            if (outMessage == null) {
                return "";
            }

            out = outMessage.toXml();
        } else if ("aes".equalsIgnoreCase(encType)) {
            // aes加密的消息
            WxMpXmlMessage inMessage = WxMpXmlMessage.fromEncryptedXml(requestBody, wxService.getWxMpConfigStorage(),
                    timestamp, nonce, msgSignature);
            log.debug("\n消息解密后内容为:\n{} ", inMessage.toString());
            WxMpXmlOutMessage outMessage = this.route(inMessage);
            if (outMessage == null) {
                return "";
            }

            out = outMessage.toEncryptedXml(wxService.getWxMpConfigStorage());
        }

        log.debug("\n组装回复信息:{}", out);
        return out;
    }

    private WxMpXmlOutMessage route(WxMpXmlMessage message) {
        try {
            return this.messageRouter.route(message);
        } catch (Exception e) {
            log.error("路由消息时出现异常!", e);
        }

        return null;
    }

}

事件注册:

@AllArgsConstructor
@Configuration
@EnableConfigurationProperties({WxMpProperties.class})
public class WxMpConfiguration {

    private final WxMpProperties properties;
    private final TextMsgHandler textMsgHandler;
    private final ScanHandler scanHandler;
    private final MsgHandler msgHandler;
    private final SubscribeHandler subscribeHandler;

    @Bean
    public WxMpService wxMpService() {
        final List<WxMpProperties.MpConfig> configs = this.properties.getConfigs();
        if (configs == null) {
            throw new RuntimeException("读取配置错误");
        }

        WxMpService service = new WxMpServiceImpl();
        service.setMultiConfigStorages(configs
                .stream().map(a -> {
                    WxMpDefaultConfigImpl configStorage = new WxMpDefaultConfigImpl();
                    configStorage.setAppId(a.getAppId());
                    configStorage.setSecret(a.getSecret());
                    configStorage.setToken(a.getToken());
                    configStorage.setAesKey(a.getAesKey());
                    return configStorage;
                }).collect(Collectors.toMap(WxMpDefaultConfigImpl::getAppId, a -> a, (o, n) -> o)));
        return service;
    }


    @Bean
    public WxMpMessageRouter messageRouter(WxMpService wxMpService) {
        final WxMpMessageRouter newRouter = new WxMpMessageRouter(wxMpService);

//        // 记录所有事件的日志 (异步执行)
//        newRouter.rule().handler(this.logHandler).next();
//
//        // 接收客服会话管理事件
//        newRouter.rule().async(false).msgType(EVENT).event(KF_CREATE_SESSION)
//                .handler(this.kfSessionHandler).end();
//        newRouter.rule().async(false).msgType(EVENT).event(KF_CLOSE_SESSION)
//                .handler(this.kfSessionHandler).end();
//        newRouter.rule().async(false).msgType(EVENT).event(KF_SWITCH_SESSION)
//                .handler(this.kfSessionHandler).end();

//        // 门店审核事件
//        newRouter.rule().async(false).msgType(EVENT).event(POI_CHECK_NOTIFY).handler(this.storeCheckNotifyHandler).end();
//
//        // 自定义菜单事件
//        newRouter.rule().async(false).msgType(EVENT).event(CLICK).handler(this.menuHandler).end();
//
//        // 点击菜单连接事件
//        newRouter.rule().async(false).msgType(EVENT).event(VIEW).handler(this.nullHandler).end();

//        // 关注事件
//        newRouter.rule().async(false).msgType(EVENT).event(SUBSCRIBE).handler(this.subscribeHandler).end();

//        // 取消关注事件
//        newRouter.rule().async(false).msgType(EVENT).event(UNSUBSCRIBE).handler(this.unsubscribeHandler).end();
//
//        // 上报地理位置事件
//        newRouter.rule().async(false).msgType(EVENT).event(EventType.LOCATION).handler(this.locationHandler).end();
//
//        // 接收地理位置消息
//        newRouter.rule().async(false).msgType(XmlMsgType.LOCATION).handler(this.locationHandler).end();

        // 关注事件
        newRouter.rule().async(false).msgType(EVENT).event(SUBSCRIBE).handler(this.subscribeHandler).end();
        // 扫码事件
        newRouter.rule().async(false).msgType(EVENT).event(WxConsts.EventType.SCAN).handler(this.scanHandler).end();

        // 文本处理
        newRouter.rule().async(false).msgType(WxConsts.XmlMsgType.TEXT).handler(this.textMsgHandler).end();

        // 默认
        newRouter.rule().async(false).handler(this.msgHandler).end();

        return newRouter;
    }


}

AbstractHandler:

public abstract class AbstractHandler implements WxMpMessageHandler {
}

关注SubscribeHandler:

/**
 * 关注事件
 */
@Component
@Slf4j
public class SubscribeHandler extends AbstractHandler {

    @Autowired
    private UserService userService;

    @Autowired
    private WebSocketService webSocketService;

    private final SimpMessagingTemplate messagingTemplate;

    public SubscribeHandler(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    @Override
    public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
                                    Map<String, Object> context, WxMpService wpService,
                                    WxSessionManager sessionManager) throws WxErrorException {
        log.info("新关注用户 OPENID: " + wxMessage.getFromUser());
        Integer uid = null;
        // 获取微信用户基本信息
        log.info(wpService.getAccessToken() + "=======" + wpService.getWxMpConfigStorage().getAppId());
        WxMpUser wxMpUser = wpService.getUserService().userInfo(wxMessage.getFromUser(), null);
        log.info("WxMpUser===" + JSON.toJSON(wxMpUser));
        switch (wpService.getWxMpConfigStorage().getAppId()) {
            case "appid":
                if (wxMpUser != null) {
                    TsUsers tsUsers = userService.getUserByUnionId(wxMpUser.getUnionId());
                    if (tsUsers == null) {
                        tsUsers = new TsUsers();
                        BeanUtils.copyProperties(wxMpUser, tsUsers);
                        tsUsers = userService.createUser(tsUsers);
                    }
                    String qrSceneStr = wxMpUser.getQrSceneStr();
                    log.info("getQrSceneStr:" + qrSceneStr);
                    // 带参数二维码处理
                	      String token = tokenService.generateToken(tsUsers);
                            // 发送websokect
                            String eventKey = qrSceneStr;
                            // 如果安装redis,这部分可以从redis中获取
                            String wsAuthToken = webSocketService.getWsAuthToken(eventKey);
                            if(StringUtils.isNotBlank(wsAuthToken) &&  wsAuthToken.equals(eventKey)) {
                                // 发送websokect
                                log.info("发送websokect token:" + token + " eventKey:" + eventKey);
                                messagingTemplate.convertAndSendToUser(eventKey,"/stomp/scanQrCode",token);
                                webSocketService.removeWsAuthToken(eventKey);
                            }
                }
                String content = "关注成功,欢迎~";
                return new TextBuilder().build(content, wxMessage, wpService.switchoverTo(wpService.getWxMpConfigStorage().getAppId()));
            default:
                log.info("暂未配置公众号信息!");

        }
        return null;

    }

}

扫码 ScanHandler:

/**
 * 扫码事件
 */
@Component
@Slf4j
public class ScanHandler extends AbstractHandler {

    @Autowired
    private UserService userService;

    @Autowired
    private TokenService tokenService;

    @Autowired
    private WebSocketService webSocketService;

    private final SimpMessagingTemplate messagingTemplate;

    public ScanHandler(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    @Override
    public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map<String, Object> map,
                                    WxMpService wxMpService, WxSessionManager wxSessionManager) throws WxErrorException {
        String eventKey = wxMessage.getEventKey();
        log.info("扫码 eventKey: " + eventKey);
        // 扫码事件处理
        log.info("扫码 OPENID: " + wxMessage.getFromUser());
        Integer uid = null;
        // 获取微信用户基本信息
        WxMpUser wxMpUser = wxMpService.getUserService().userInfo(wxMessage.getFromUser(), null);
        log.info("WxMpUser===" + JSON.toJSON(wxMpUser));
        log.info("QrSceneStr():" + wxMpUser.getQrSceneStr());
        String qrSceneStr = wxMpUser.getQrSceneStr();
        switch (wxMpService.getWxMpConfigStorage().getAppId()) {
            case "appid":  
                if (wxMpUser != null && eventKey.startsWith("login_")) {
                    TsUsers tsUsers = userService.getUserByUnionId(wxMpUser.getUnionId());
                    if(tsUsers != null) {
                        String token = tokenService.generateToken(tsUsers);
                        eventKey = StringUtils.replace(eventKey, "login_", "");
                        String wsAuthToken = webSocketService.getWsAuthToken(eventKey);
                        if(StringUtils.isNotBlank(wsAuthToken) &&  wsAuthToken.equals(eventKey)) {
                            // 发送websokect
                            log.info("发送websokect token:" + token + " eventKey:" + eventKey);
                            messagingTemplate.convertAndSendToUser(eventKey,"/stomp/scanQrCode",token);
                            webSocketService.removeWsAuthToken(eventKey);
                            // 公众号消息
                            String content_1 = "扫码登录成功,欢迎回来~";
                            return new TextBuilder().build(content_1, wxMessage, wxMpService.switchoverTo(wxMpService.getWxMpConfigStorage().getAppId()));
                        }
                    }
                }
                break;
            default:
                log.info("暂未配置公众号信息!");
        }
        return null;
    }

}

生成带参数二维码

  @Override
    public String getLoginQrCode(String appid,String wsAuthToken) {
        try {
            String sceneStr = "login_" + wsAuthToken;
            WxMpQrcodeService qrcodeService = this.wxMpService.switchoverTo(appid).getQrcodeService();
            WxMpQrCodeTicket ticket = qrcodeService.qrCodeCreateTmpTicket(sceneStr, 10 * 60);
            String path = wxMpService.getQrcodeService().qrCodePictureUrl(ticket.getTicket());
            log.info("getLoginQrCode path: " + path);
            return path;
        } catch (WxErrorException e) {
            e.printStackTrace();
        }
        return "";
    }

Websokect后台逻辑

Springboot配置

pom.xml文件

<!-- websocket -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

连接订阅流程:

具体代码实现

websocket 文件夹下包含3个类: websocket常量、WebsocketConfig核心配置类、WebSocketService缓存存储

/**
 * @desc: websocket常量
 **/
public class WsConstants {

    // stomp端点地址
    public static final String WEBSOCKET_PATH = "/websocket";

    // websocket前缀
    public static final String WS_PERFIX = "/ws";

    // 消息订阅地址常量
    public static final class BROKER {
        // 点对点消息代理地址
        public static final String BROKER_QUEUE = "/queue/";
        // 广播消息代理地址
        public static final String BROKER_TOPIC = "/topic";
    }

}

/**
 * @desc: websocket核心配置类
 **/
@Configuration
@EnableWebSocketMessageBroker
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {
    
    /**
     * 注册stomp端点
     * @param registry stomp端点注册对象
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint(WsConstants.WEBSOCKET_PATH)
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    /**
     * 配置消息代理
     *
     * @param registry 消息代理注册对象
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {

        // 配置服务端推送消息给客户端的代理路径 /queue/   /topic
        registry.enableSimpleBroker(WsConstants.BROKER.BROKER_QUEUE, WsConstants.BROKER.BROKER_TOPIC);

        // 定义点对点推送时的前缀为 /queue
        registry.setUserDestinationPrefix(WsConstants.BROKER.BROKER_QUEUE);

        // 定义客户端访问服务端消息接口时的前缀 /ws
        registry.setApplicationDestinationPrefixes(WsConstants.WS_PERFIX);
    }
}


/**
 * @desc: 内存存储可用redis代替
 **/
@Service
public class WebSocketService {
    private final Map<String, Object> map = new ConcurrentHashMap<>();

    /**
     * 将webSocket的token与http的认证token绑定在一起,这样便可以互相都找的到了
     * @param wsAuthToken webSocket token
     * @param xAuthToken http token
     * 两个值目前是一样的,弄成不一样的值,安全性能更高点(我这里偷个懒)
     */
    public void bindToXAuthToken(String wsAuthToken, String xAuthToken) {
        this.map.put(wsAuthToken, xAuthToken);
    }

    /**
     * key 获取 value
     * @param wsAuthToken
     * @return
     */
    public String getWsAuthToken(String wsAuthToken) {
        return this.map.get(wsAuthToken).toString();
    }

    /**
     * 推送成功后,移除
     * @param key
     */
    public void removeWsAuthToken(String key){
        this.map.remove(key);
    }
}

核心内容讲解:

1、@EnableWebSocketMessageBroker:用于开启stomp协议,这样就能支持@MessageMapping注解,类似于@requestMapping一样,同时前端可以使用Stomp客户端进行通讯;

2、registerStompEndpoints实现:主要用来注册端点地址、开启跨域授权、增加拦截器、声明SockJS,这也是前端选择SockJS的原因,因为spring项目本身就支持;

3、configureMessageBroker实现:主要用来设置客户端订阅消息的路径(可以多个)、点对点订阅路径前缀的设置、访问服务端@MessageMapping接口的前缀路径、心跳设置等;

WebSocketService 存储业务:

有装redis的建议存储在redis中,我这里没装,先存储在ConcurrentHashMap中了。

面向前端的contoller:

@RestController
@RequestMapping("/wxMsg")
@Slf4j
public class WxMsgController {

    private final SimpMessagingTemplate messagingTemplate;

    public WxMsgController(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    @Autowired
    private WebSocketService webSocketService;

    /**
     * 发送广播消息
     * -- 说明:
     *       1)、@MessageMapping注解对应客户端的stomp.send('url');
     *       2)、用法一:要么配合@SendTo("转发的订阅路径"),去掉messagingTemplate,同时return msg来使用,return msg会去找@SendTo注解的路径;
     *       3)、用法二:要么设置成void,使用messagingTemplate来控制转发的订阅路径,且不能return msg,个人推荐这种。
     *
     * @param msg 消息
     */
    @MessageMapping("/send")
    public void sendAll(@RequestParam String msg) {

        log.info("[发送消息]>>>> msg: {}", msg);

        // 发送广播消息给客户端
        //messagingTemplate.convertAndSend(WsConstants.BROKER.BROKER_TOPIC, msg);
        // 发送点对点消息给客户端
        messagingTemplate.convertAndSendToUser("userId","/stomp/hello",msg);
    }

    @MessageMapping("/bind")
    public void bind(@Payload String xAuthToken, @RequestParam String wsAuthToken) {
        webSocketService.bindToXAuthToken(wsAuthToken, xAuthToken);
    }

}

前端订阅逻辑

前端用的是reactjs框架,vuejs或者angluarjs也都OK~

安装依赖

npm install sockjs-client stompjs --save

连接订阅

初始化-> 连接 -> 绑定 -> 订阅-> 接收消息:

wsInit() {
  const uuid = window.sessionStorage.getItem('ws-auth-token') ? window.sessionStorage.getItem('ws-auth-token') : uuid();
  window.sessionStorage.setItem('ws-auth-token', uuid);
  // 模块组件onInit,异步执行,也可以防止cycle
  setTimeout(() => {
    const socket = new SockJS(url.websocket(), null, { timeout: 15000});
    stompClient = Stomp.over(socket); // 覆盖sockjs使用stomp客户端
    stompClient.connect({},  (frame) => {
      console.log('连接成功 frame: ' + frame)
      this.wsBind(uuid);
      this.subscribe(uuid);
      this.getLoginQrCode(uuid);
      // 异常时进行重连
    },  error => {
      console.log('connect error: ' + error)
    }
                       )
  });
}
wsBind(uuid) {
  stompClient.send('/ws/bind', {
    "ws-auth-token": uuid
  }, uuid);

}
subscribe (uuid) {
  stompClient.subscribe(`/queue/${uuid}/stomp/scanQrCode`, response => {
    console.log("queue responese", response.body);
    if(response.body === uuid){
      this.disconnect()
      // 跳转页面
    }
  })
}
getLoginQrCode(uuid) {
  http.get(url.getLoginQrCode(uuid))
    .then((res) => {
      if (res.status === 200) {
        this.setState({
          qrcodePath: res.data
        })
      }
    })
}
disconnect() {
  if (stompClient != null) {
    // 断开连接
    stompClient.disconnect(() =>{
      // 有效断开的回调
      console.log("断开连接....")
    });
  }
}

// 如果前端需要发消息给服务器可以用这个方法
send() {
   stompClient.send("/ws/send", {}, "hello~");
}

// 执行
  componentDidMount(){
    this.wsInit();
  }

效果展示