前言
现在不少平台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();
}