基于微信公众号登录
借助微信公众号来试实现社区登录。登录的时候展示的是一个二维码,但实际上的操作是借助这个展示的过程,和前端构建一个半长连接,当用户向公众号发送验证码之后,微信公众平台会将用户发送的消息转发给服务器,通过验证码来识别请求登录的用户身份,找到对应的半长连接,实现用户的自动登录跳转。
方案设计
基于上面的方案,用户表中需要存储一个核心的用户标识。
- 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 = "验证码过期了,刷新登录页面重试一下吧";
}
}
小结
还有一些细节,比如缓存结构的设计,用户名、头像自动生成选择策略,微信公众平台接入,半长连接等。