微信公众号扫码登录原理和实现

引言

想象一下,周五晚上,你打开电脑,打算刷一刷最新上线的剧集,突然弹出网站登录,哎呀,那个超级复杂的密码是什么来着?那一堆数字、字母和符号的大杂烩在我脑海中有好几个版本?能不能有一种简单的方式,不用密码就可以认真登录,这简直不要太棒?这时扫码登录出现了,它不仅方便而且更加安全。好比你向安保亮了一下你的 VIP 通行证,便放你通过。

微信作为国民级应用,微信扫码登录再常见不过了,它就像你的口袋里的万能钥匙,去哪儿都不怕。不过微信扫码登录也有多种方式,如扫码授权登录,扫码关注公众号登录等。这篇文章一起聊聊微信公众号二维码登录是怎么回事,它的工作流程是什么,它怎么保证你的身份安全。以及,如果你是一个开发者,如何在自己的网站上增加扫码登录。

公众号扫码登录优势

快捷方便

用户只需打开微信扫一扫,几秒钟内就能完成登录。这简化了传统的输入用户名和密码的繁琐过程,不过前提是你已经安装了必要的 APP。

增强安全性

扫码登录的身份认证在服务端完成,而且如微信公众号扫码登录这种方式,网站只需要拿到一个用户身份标识用于识别用户,不需要存储用户的额外信息。非常安全。

提升用户粘性

通过扫描二维码登录把用户引入公众号关注,在登录的同时还可以为公众号引流,提升用户粘性,同时公众号是一个非常方便的用户触达方式,未来新功能的发布可以及时送达用户。

公众号扫码登录实现原理

要想理清公众号扫码登录的实现原理,首先要知道在扫码登录过程中,有哪些参与方,它们之间的工作流程是怎么样的。这里的参与方有用户、浏览器、网站服务端、微信服务端四个参与方,总体的工作流程如下图,下面会进行详细介绍。

公众号扫码登录流程图

用户:用户是扫码登录的发起方,点击登录,然后扫描登录二维码。

浏览器:浏览器为用户展示二维码,然后不断的轮询扫码状态。

服务端:网站服务端需要向微信服务端获取携带 Ticket 信息的公众号二维码,在微信服务端回调时绑定用户身份信息。

微信服务端:用户扫码后,会请求到微信服务端,微信服务端会携带扫描的二维码的 Ticket 和用户身份标识回调网站服务端。

微信服务端回调网站服务端时,携带的用户身份信息其实只是一串无意义字符串,但是微信可以保证的是同一个微信用户扫码时携带的身份信息字符是相同的,以此识别用户。也因此公众号扫码登录用作身份认证非常安全。

开发准备工作

公众号

首先你要有用于扫码登录的微信公众号,微信公众平台提供了测试平台,可以直接生成测试公众号。

微信测试号:https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index

开发者文档

获取公众号二维码的过程需要参考微信公众号官方文档,下面几篇内容需要重点关注。

  1. 公众号接口接入指南

    链接:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html

  2. 获取 Access Token

    Access Token 是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用 Access Token 。每次获取有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的 Access Token 失效。

    链接:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html

  3. 生成带 Ticket 二维码

    使用该接口可以获得多个带不同场景值的二维码,用户扫描后,公众号可以接收到事件推送。

    链接:https://developers.weixin.qq.com/doc/offiaccount/Account_Management/Generating_a_Parametric_QR_Code.html

  4. 接收事件推送

    在用户扫码后微信服务端会回调网站服务端,开发者需要按照指定消息格式对消息进行验证处理。如获取二维码的 Ticket。

    链接:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_event_pushes.html

  5. 回复文本消息

    如果想要在用户扫码完成后自动响应如 “登录成功” 之类的提示语,需要参考此文档。

    链接:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html

网站服务端

想要微信服务端成功回调,服务端必须外网可以访问,同时微信限制了端口只能是 80 或 443 ,因此只有两种选择 。

  1. 拥有自己的云服务器(这里推荐我司阿里云服务器,如果只是学习体验,抢占式实例低配置一小时也就2毛钱左右,可以用于测试)。
  2. 用内网穿透软件生成外网代理(一般 80 端口都需要收费)。

具体开发

配置微信公众号

可以在微信测试号平台上配置用于测试。

链接:https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index

项目总体结构

项目使用 SpringBoot 3.2.3 + Java 21 进行开发,这里为了方便演示,在一个 Maven 项目中完成所有代码。本文步骤中只会给出关键代码部分,完整代码可以在文末的 GitHub 地址中找到。

下面是项目总体结构:

├── pom.xml
└── src
    └── main
        ├── java
        │   └── com
        │       └── wdbyte
        │           └── weixin
        │               ├── SpringBootApp.java
        │               ├── config
        │               │   └── JwtFilter.java   # JWT 身份认证拦截器
        │               ├── controller
        │               │   ├── WeixinServerController.java # 微信服务端调用接口
        │               │   └── WeixinUserController.java  # 浏览器调用接口
        │               ├── model
        │               │   ├── ApiResult.java     
        │               │   ├── ReceiveMessage.java  # 微信消息封装类
        │               │   └── WeixinQrCode.java # 微信二维码 Ticket 封装类
        │               ├── service
        │               │   ├── WeixinUserService.java # 微信调用处理类
        │               │   └── impl
        │               │       └── WeixinUserServiceImpl.java
        │               └── util
        │                   ├── AesUtils.java # AES 加密工具类
        │                   ├── ApiResultUtil.java
        │                   ├── HttpUtil.java # HTTP 工具类
        │                   ├── JwtUtil.java # JWT 工具类
        │                   ├── KeyUtils.java 
        │                   ├── WeixinApiUtil.java # 微信 API 工具类,如获取 AccessToken
        │                   ├── WeixinMsgUtil.java # 微信消息工具类
        │                   ├── WeixinQrCodeCacheUtil.java # 微信二维码Ticket缓存
        │                   └── XmlUtil.java 
        └── resources
            ├── application.properties #配置文件
            ├── static
            └── templates

WeixinServerControllerWeixinUserController 暴漏了三个 API。

/weixin/check :用于对接微信服务端,接收微信服务端的调用。

/user/qrcode: 用于获取二维码图片信息

/user/login/qrcode: 用于校验是否扫描成功,成功则返回身份认证后的 JWT 字符串。

项目公众号信息配置

application.properties 中配置公众号所需的配置。

server.port=
weixin.appid=
weixin.appsecret=
weixin.token=

ase.util.secret=
key.jwt.secret=

验证签名

开发者提交信息后,微信服务器向填写的 URL 发送 Get 请求,携带参数如下:

参数 描述
signature 微信加密签名,signature结合了开发者填写的token参数和请求中的timestamp参数、nonce参数。
timestamp 时间戳
nonce 随机数
echostr 随机字符串

开发者需要对 signature 进行校验,判断是否来自微信服务器,公众号相关的其他事件如消息、关注、扫码等一样会回调配置的 URL ,只不过这时是 POST 请求。

验签逻辑查看微信文档:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html

Java 实现如下:

// com.wdbyte.weixin.service.impl.WeixinUserServiceImpl.java

@Value("${weixin.token}")
private String token;

@Override
public void checkSignature(String signature, String timestamp, String nonce) {
    String[] arr = new String[] {token, timestamp, nonce};
    Arrays.sort(arr);
    StringBuilder content = new StringBuilder();
    for (String str : arr) {
        content.append(str);
    }
    String tmpStr = DigestUtils.sha1Hex(content.toString());
    if (tmpStr.equals(signature)) {
        log.info("check success");
        return;
    }
    log.error("check fail");
    throw new RuntimeException("check fail");
}

获取 Access Token

获取带有 Ticket 的公众号二维码之前,需要先获取公众号的 Access Token,这是调用微信公众号所有接口的前提。 Access Token 每日调用次数有限,应该进行缓存。

// com.wdbyte.weixin.util.WeixinApiUtil.java
@Value("${weixin.appid}")
public String appId;

@Value("${weixin.appsecret}")
public String appSecret;

private static String ACCESS_TOKEN = null;
private static LocalDateTime ACCESS_TOKEN_EXPIRE_TIME = null;

/**
 * 获取 access token
 *
 * @return
 */
public synchronized String getAccessToken() {
    if (ACCESS_TOKEN != null && ACCESS_TOKEN_EXPIRE_TIME.isAfter(LocalDateTime.now())) {
        return ACCESS_TOKEN;
    }
    String api = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" + appId + "&secret="
        + appSecret;
    String result = HttpUtil.get(api);
    JSONObject jsonObject = JSON.parseObject(result);
    ACCESS_TOKEN = jsonObject.getString("access_token");
    ACCESS_TOKEN_EXPIRE_TIME = LocalDateTime.now().plusSeconds(jsonObject.getLong("expires_in") - 10);
    return ACCESS_TOKEN;
}

生成登录二维码

使用 Access Token 获取二维码 Ticket 用来换取二维码图片。

// com.wdbyte.weixin.util.WeixinApiUtil.java
private static String QR_CODE_URL_PREFIX = "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=";

/**
 * 二维码 Ticket 过期时间
 */
private static int QR_CODE_TICKET_TIMEOUT = 10 * 60;


/**
 * 获取二维码 Ticket
 *
 * https://developers.weixin.qq.com/doc/offiaccount/Account_Management/Generating_a_Parametric_QR_Code.html
 *
 * @return
 */
public WeixinQrCode getQrCode() {
    String api = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + getAccessToken();
    String jsonBody = String.format("{\n"
        + "  \"expire_seconds\": %d,\n"
        + "  \"action_name\": \"QR_STR_SCENE\",\n"
        + "  \"action_info\": {\n"
        + "    \"scene\": {\n"
        + "      \"scene_str\": \"%s\"\n"
        + "    }\n"
        + "  }\n"
        + "}", QR_CODE_TICKET_TIMEOUT, KeyUtils.uuid32());
    String result = HttpUtil.post(api, jsonBody);
    log.info("get qr code params:{}", jsonBody);
    log.info("get qr code result:{}", result);
    WeixinQrCode weixinQrCode = JSON.parseObject(result, WeixinQrCode.class);
    weixinQrCode.setQrCodeUrl(QR_CODE_URL_PREFIX + URI.create(weixinQrCode.getTicket()).toASCIIString());
    return weixinQrCode;
}
class WeixinQrCode {
    private String ticket;
    private Long expireSeconds;
    private String url;
    private String qrCodeUrl;
}

响应内容格式如下:

{
  "ticket": "gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm3sUw==",
  "expire_seconds": 60,
  "url": "http://weixin.qq.com/q/kZgfwMTm72WWPkovabbI"
}

其中 Ticket 就是二维码凭证,用户扫码后微信会把此 Ticket 回调给网站服务端。可以在下面的链接后面拼上 Ticket 换取二维码图片。

https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=

登录二维码

扫码回调与身份绑定

用户扫码后,微信服务端会把二维码鞋带的 Ticket 和用户的身份标识作为消息内容回调到网站服务器。

格式如下:

<xml>
  <ToUserName><![CDATA[toUser]]></ToUserName>
  <FromUserName><![CDATA[FromUser]]></FromUserName>
  <CreateTime>123456789</CreateTime>
  <MsgType><![CDATA[event]]></MsgType>
  <Event><![CDATA[subscribe]]></Event>
  <EventKey><![CDATA[qrscene_123123]]></EventKey>
  <Ticket><![CDATA[TICKET]]></Ticket>
</xml>

其中 FromUserName 是用户身份标识,EventKeyqrscene_标识扫码,Ticket 则是二维码的 Ticket。至此,服务端就可以识别出二维码是被哪个用户扫码了。绑定 Ticket 和用户身份标识。

注:FromUserName 是唯一的用户身份标识,同一个用户每次扫描的 FromUserName 相同。

浏览器轮询扫描状态

浏览器鞋带 Ticket 信息不断的轮询 /user/login/qrcode 接口查看 Ticket 是否被扫描成功,如果通过 Ticket 可以查到用户身份标识,说明二维码被扫描成功,返回用户信息。登录完成。

扫码登录测试

浏览器不断的轮询 https://api.wdbyte.com/user/login/qrcode?ticket=Ticket值 获取扫码状态。

二维码尚未扫描,则返回:

{
  "code": -1,
  "data": "check faild",
  "message": "error"
}

微信扫码关注公众号。

微信扫码

扫码成功后轮询接口会响应 JWT 格式的身份信息,这里使用了 AES 对 JWT 进行了加密。

{
  "code": 200,
  "data": "mihzE8Z1Y9t2EoppNSzzytV4TOgn+Nc50ORZjsW/oVkxchL4EzGA6rr1tQ0Q7J24Ipm4otjCYf95Nu8JbV31Q/ImKvlta3f5bgvOdWSlO2tNvOwqgzBSItABohbCLVLxjGCci4VtNaEFgQjoDjc1uhwP/GCSohVFc7csO9SxpOm8HKtlRhATjwPrtiQ9iLErfsUs27I0k5OHp55AzuQOYCvza//i3wk8nlv/MDkk7y1nvsZkllyKQGHPB4Ulcraz",
  "message": "success"
}

至此,登录完成。

完整代码: github.com/niumoo/JavaNotes/tree/master/springboot/springboot-weixin-qrcode-login

一如既往,文章中代码存放在 Github.com/niumoo/javaNotes.