基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya

太长不看版本

本文通过一个实际的具有一定商业价值的项目,展示了 API 优先的开发方法。通过薅羊毛的方式,落地了 Free Arch 架构。

背景和价值

通过微信公众号积累粉丝并进行商业活动宣传,是新媒体运营的常见方式。而系统对接微信登录,既能给用户带来便利,同时也能够给系统引流。但是传统或者标准的 PC 网页端微信扫码登录,用户扫码只需要做 OAuth 授权,不必关注公众号。但是对于运营者来说,更希望通过微信登录的用户,自动成为微信粉丝,实现系统中微信用户和公众号粉丝的一一对应。

所以,关注公众号即登录,将系统的微信用户和公众号粉丝等同起来,方便了运营同学。

另外,传统的微信扫码登录,需要在公众平台之外,额外再在开发平台申请一个应用,再和公众号做绑定,多了一个账号,就多了一份维护工作,增加了管理者的心智负担,还要多花钱,毕竟两个不同的账号需要单独缴费和认证。

以及,开发平台和公众平台的 openid 不一样,还需要通过 unionid 的机制做关联,增加了开发的心智负担和开发成本。

以上,通过关注公众号即登录的方案,都可以避免,和微信打交道的全程只需要 openid 即可。

Java Spring-Security

Spring Security 是一个专注在 Java 应用中提供认证和授权的框架。和所有 Spring 项目一样,Spring Security 的真正威力在于其极易扩展已满足定制化的需求,为认证和授权提供完整的和可扩展的支持。

Open API

Open API 即开放 API,也成为开放平台。它是服务型网站常见的一种应用,网站的服务商将自己的网站服务封装成一系列 API 开放出去,供第三方开发者使用,这种行为就叫做开放网站的 API,所开放的 API 就被称作 Open API。

Open API 规范始于 Swagger 规范,经过 Reverb Technologies 和 SmartBear 等公司多年的发展,Open API 计划拥有该规范。规范是一种与语言无关的格式,用于描述 Web 服务,应用程序可以解释生成的文件,这样才能生成代码、生成文档并根据其描述的服务创建模拟应用。

Swagger 的目标是为 API 定义一个标准的,与语言无关的接口,使人和计算机在看不到源码或者看不到文档或者不能通过网络流量检测的情况下能够发现和理解各种服务的功能。当服务通过 Swagger 定义,消费者通过少量的实现逻辑就能与远程服务互动。类似于低级编程接口,Swagger 去掉了调用服务时的很多猜测。

关注公众号即登录的流程设计

PC 网页站点实现微信登录时,需要通过用户使用微信扫描网页上展示的二维码,然后在手机上的微信授权登录。如何实现二维码的展示,并“感知”用户扫描事件,是需要解决的关键问题。传统的 OAuth 微信扫码登录,由于本质上是打开了一个微信官方的页面,因此不需要关注这其中的细节,但是不通过打开微信官方的页面,就需要自行设计这个展示和感知的能力了。

常规 PC 网页端微信扫码登录,PC 打开了微信官方的页面,由微信官方展示二维码,而手机扫描后,会在微信端跳出授权页面,用户确认后,微信官方二维码页面会重定向至开发者在开放平台设置好的回调页面,并将临时授权码作为查询字符串;而关注公众号即登录,则利用了微信的带参二维码功能,用户扫描这种二维码后,手机微信会展示开发者的微信公众号,同时将用户信息(主要是 openid)通过服务器端 API 调用,发送给开发者服务器。该过程没有二次确认,对于已关注过公众号的用户,直接发送扫描事件;对于新用户,需要新用户点击关注,才会发送该事件。这里的难点在于如何感知用户的扫描事件,以及保证服务器端 API 调用的安全(主要是确认调用者真的是来自微信而不是伪造的请求)。

下面通过阐述利用微信的带参二维码,通过接收微信服务器发送的消息来“感知”用户的扫描事件。首先是带参二维码的生成,它是通过调用微信官方的接口完成的。 微信公众平台提供了两种生成带参数二维码的接口,分别是一、临时二维码,有 过期时间,数量没有明确的上限;二、永久二维码,没有过期时间,但是最多只能生成 10 万个。显然,对于登录场景,适合采用临时二维码。本文的方案里, 过期时间设置为 1 分钟。如果用户在打开登录页面的 1 分钟内,都没有扫码,或者因为网络等原因扫码失败,那么就展示二维码过期,提示用户刷新二维码,这个体验和用户登录电脑版微信相似。其中调用生成二维码接口的关键是需要传递场景值,每个场景值会和一个尝试登录的请求相关,因此必须做到唯一。本文选择使用 UUID(或者称为 GUID)。UUID 由 128 位数字组成,其生成算法保证了 其极低的重复率,具体地说,如果以一秒钟生成十亿个 UUID 的速度连续生成一年,才会有 50%的机会产生一个重复 ID。下图为未登录用户成功扫码登录系统的流程图。

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第1张图片

由上图可以看出,开发者服务为尝试登录请求生成场景值后,会同时传递给微信服务和浏览器。这个场景值还会被后续查询用户扫描状态时被使用。如果用户不扫描,致使二维码过期,那么这个场景值将会被丢弃,被认为该尝试登录失败。从上图还可以看出,当用户扫描后,微信会自动进入开发者公众号页面,这为运营提供了很大的好处,因为公众号页面会展示历史图文信息,相比传统的用 户扫码后,展示的信息要丰富得多。另外可以看到,无论用户是否关注过公众号, 扫码后,微信服务都会向开发者服务推送用户的 openid 以及场景值。而且对于没有关注过公众号的新用户,还会自动关注,成为公众号新粉丝。这样就把系统 的微信用户账号和微信公众号分析的属性关联了起来。场景值被系统用来更新扫码状态,而 openid 用来关联或者创建账号。这一系列动作完成后,系统还可以通 过微信渠道向用户发送自定义的欢迎信息,这是传统微信登录方式很难做到的 (需要实现模板消息功能,但是模板消息的使用是受到严格监控和限制的,而在扫码后的消息回复则不受此限)。

其次是扫码状态的更新,当开发者服务器收到微信服务生成的二维码后,就 处于等待用户扫码的阶段,当收到微信服务通知用户扫码成功或者超时,开发者服务器应该通知用户端。因此这里需要一个即时消息服务。其状态转移如下图所示,一共有三种状态:一、扫码成功,收到微信服务通知的用户 openid,场景值;二、扫码失败,收到微信服务通知的失败原因;三、扫码超时,一段时 间没有收到微信服务的通知。

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第2张图片

开发者服务器端接收到微信服务通知或者超时后,需要通知客户端,一般有 三种技术方案,即轮询、长连接以及 Socket IO。由于普通轮询为了保持实时性, 会在短时间内发送大量的 HTTP 请求,不可取。而 Socket IO 实现较复杂,并且对服务器资源消耗过大,因此长连接方案是最适合的。在这种方案下,客户端向服务器端发送请求询问扫码状态,服务器只在扫码成功或者超时的情况下给予回应,其他情况会挂起连接。因此在超时前,一个客户端只会向服务器端发送一个查询请求,有效地减轻了服务器端连接压力。这里开发者服务会接受到客户端查询扫码状态和微信服务通知扫码结果的 HTTP 请求,两个请求到达的次序有可能 不同,在实现时需要注意。下面给出时序图:

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第3张图片

从上面的时序图可以看出,只需要对情况一进行查询请求的挂起。另外,向客户端返回扫码结果后,一定要将缓存记录清除,一方面更加安全,对重放的请求,因为查询不到扫码记录会直接回复超时;另一方面,可以及时释放内存,节省不必要的资源开销。由于生产环境往往不止单个应用实例,扫码状态需要缓存在独立于应用的缓存服务中,后续查询请求即使被另外的应用实例处理,也能返回正确的状态。当开发者服务收到扫码成功的结果后,就可以将微信的 openid 作 为第三方登录的 id,与自己的用户数据库中的用户做关联了。由于采用了关注公 众号即登录的流程,不再额外需要申请和维护微信开放平台账号,也不再需要处理 unionid 的映射。

本节通过仔细研究微信公众号的开放 API 能力,梳理了一套非常规的微信扫码登录方案,通过严谨分析客户端、开发者服务和微信服务三者间的 HTTP 沟通时序,实现了对用户微信扫码的感知能力,为最终实现关注公众号即登录打下了可行性基础。

应用架构设计

rch 架构。

演示应用架构图大致如下

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第4张图片

即通过 Cloudflare,使用免费的网络防火墙服务。对于要实现的 Java Spring-Security 应用,部署在 Heroku 这个 PaaS 平台上,也是免费的。对于微信服务,我们使用微信官方提供的测试号,也是免费的。但是对于测试公众号,有个限制是只能有 100 个关注者。但是对于演示来说足够用了,相信本文的阅读量不会超过 100,如果超过,甚至还有打赏,产生了收入,那我就去注册一个真正的公众号!

在流程设计上提到对于扫码状态的查询,需要长链接以等待微信消息通知,至于微信发过来的消息存储方面,可以采用 Redis,也可以使用消息队列。对于 Redis 方案,也有对应的免费服务,但是本文采用了消息队列来实现,消息队列使用了 Pulsar,跟上时代。Pulsar 号称是下一代的消息队列方案,比 Kafka 有过之而无不及。

API first 开发方式

应用程序向云环境这一演变趋势为更好地集成服务和增加代码重用提供了机会,只要拥有一个接口,然后通过该接口,其他服务的应用程序就可以与你的应用程序进行交互,这是向其他人公开你的功能,但是,开发 API 不应该是在开发后才公开功能。

API 文档应该是构建应用程序的基础,这个原则正式 API first 开发的全部内容。你需要设计和创建描述新服务与外部世界之间交互的文档,一旦建立了这些交互,就可以开发代码逻辑来支持这些交互。它的好处是:

  • 团队在开发过程中更快地开始彼此交互。API 文档是应用程序与使用它的人之间的合同。

  • 内部开发可以在 API 合同背后进行,而不会干扰使用它的人的努力,计划使用你的应用程序的团队可以使用 API 规范来了解如何与你的应用程序进行交互,甚至在开发前,他们还可以使用文档创建用于测试其他应用程序的虚拟服务。

基于 Java Spring Security 的关注公众号即登录的实现及其关键代码

促销服务企业基本都会通过微信公众平台与用户互动,但是微信公众平台的限制在于,公众号不能主动找到用户和向用户主动发消息的,而只有用户主动先 关注公众号成为其粉丝,才能有互动的可能。用户扫码并关注微信公众号,是在手机端完成的。如何从应用层面“感知”到用户的操作,是实现中的主要难点。 同时,开发者服务层本身是无状态的,但是扫码流程又是有状态流转的,所以需要解决状态存储的问题。另外,开发者服务层需要同时与微信服务和前端页面打交道,这个过程中会有设计用户敏感信息的发送与接收,如何保证和验证数据来源的可信性和安全性就成了必须要考虑的问题。在具体实现前,需要申请微信公众号,并配置好相关参数。由于存在开发者服务和微信服务之间的消息传送,所以还需要在公众号后台配置好开发者服务接收消息的 URL,并同时配置好密钥字符串,微信服务发送消息是会使用这个密钥字符串对消息加密,并且只发送到开发者配置的 URL,同时这个 URL 必须为 https 协议的,这样即使数据包被第 三方截获,也不能做任何改动,如果将数据包转发,则接收方可以识别出消息已被篡改拒绝接收,由于采用了开发者配置的密钥加密了消息,因此第三方基本无法破译,从而保证了消息的安全。同时,还需要将从公众号后台获取的 AppID 和 AppSecret 配置到开发者服务(即本系统)中。

整体上看,要实现微信登录就需要拿到用户在微信端的唯一标志符 openid, 查找用户数据库看是否存在该用户,有的话直接登录,否则注册后登录。而要拿到用户的 openid,一般做法是通过微信网页的 OAuth 授权,但是缺点是不能给公众号引流。关注公众号即登录功能在统一移动端和桌面端的微信登录用户体验、 便利用户运营都起了非常重要的作用,可以增加微信粉丝、发送登录后消息等等。 要实现的功能目标是去除对微信开放平台的依赖,减少用户二次点击。因为已经有微信公众平台,所以系统应该尽量利用公众平台完成一切和微信相关的交互, 而用户主动扫码,已经是一个确认的行为,减少一次额外的点击,使得登录行为更加流畅。有上述功能目标分析,再结合流程设计中介绍的浏览器、开发者服务以及微信服务的交互流程可知,要拿到用户的 openid,只需用户扫码带参二维码, 用户扫码后会被导流到公众号。同时,如果用户关注公众号,或者已经关注过该 公众号,那么微信服务层会给开发这服务层发送用户的 openid 消息。所以要实现关注公众即登录,就要实现参数场景值的生成扫码状态存储状态查询消息收发的安全性等这几个关键点。

定义 API

使用 API 优先的方式开发,那么先定义一下接口。采用 Swagger 的 Yaml 格式

从 paths 字段可以看到一共定义了 3 个接口:

  • /mp-qr:用来展示二维码,并实现参数场景值的生成

  • /mp-qr-scan-status: 用来查询二维码扫码状态

  • /mp-message: 用来接收微信服务发来的消息,并将其保存至消息队列,存储扫码状态,并要保证消息的确来自微信服务

使用 Swagger 定义开放 API 的好处之一是 Schema 支持,这个定义在 components 字段的 schemas 下,完整的 Swagger 文档是:

openapi: "3.0.0"
info:
  version: 0.0.1
  title: Authenticate with Wechat MP!
servers:
  # Added by API Auto Mocking Plugin
  - description: SwaggerHub API Auto Mocking
    url: https://virtserver.swaggerhub.com/UniHeart/wechat-mp/0.0.1
  - url: http://localhost:8080
paths:
  /mp-qr:
    get:
      summary: Gets a temporary qr code with parameter
      operationId: mp-qr-url
      tags:
        - mp-qr
      responses:
        '200':
          description: Got the temporary qr code image link
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MpQR'
              example:
                expire_seconds: 60
                imageUrl: https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=gQGT7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycnE3QWw3b3JmazMxb2FMQnh3Y1UAAgTOrmVgAwQ8AAAA
                sceneId: 66afab27-c8fa-417d-a28a-95d5a977e1d3
                ticket: gQGT7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycnE3QWw3b3JmazMxb2FMQnh3Y1UAAgTOrmVgAwQ8AAAA
                url: http://weixin.qq.com/q/02rq7Al7orfk31oaLBxwc
  /mp-qr-scan-status:
    get:
      summary: Get the scanning status of qr code
      operationId: mp-qr-scan-status
      tags:
        - mp-qr
      parameters:
        - in: query
          name: ticket
          required: true
          description: the ticket for the qr code to query scanning status
          schema:
            type: string
            example: gQE48DwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAyb2U4U2wwb3JmazMxcS1kQ3h3YzgAAgSCjWZgAwQ8AAAA
      responses:
        '200':
          description: The scanning stqtus of qr code
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MpQRScanStatus'
              example:
                openId: oWFvUw5ryWycy8XoDCy1pV0SiB58
                status: SCANNED
  /mp-message:
    post:
      summary: Receive xml messages sent from wechat mp server
      operationId: mp-message
      tags:
        - mp-qr
      requestBody:
        description: wechat mp messages in xml format
        required: true
        content:
          application/xml:
            schema:
              $ref: '#/components/schemas/xml'
      responses:
        '200': 
          description: the message was well received

                
components:
  schemas:
    MpQR:
      type: object
      properties:
        expire_seconds: 
          type: integer
          format: int64
          example: 60
        imageUrl: 
          type: string
          example: https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=gQGT7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycnE3QWw3b3JmazMxb2FMQnh3Y1UAAgTOrmVgAwQ8AAAA
        sceneId: 
          type: string
          example: 66afab27-c8fa-417d-a28a-95d5a977e1d3
        ticket:
          type: string
          example: gQGT7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAycnE3QWw3b3JmazMxb2FMQnh3Y1UAAgTOrmVgAwQ8AAAA
        url:
          type: string
          example: http://weixin.qq.com/q/02rq7Al7orfk31oaLBxwc
    MpQRScanStatus:
      type: object
      properties:
        openId: 
          type: string
          example: oWFvUw5ryWycy8XoDCy1pV0SiB58
        status:
          type: string
          example: SCANNED
    xml:
      type: object
      properties:
        ToUserName:
          type: string
          example: oWfv
        FromUserName:
          type: string
          example: 1234
        CreateTime:
          type: number
          example: 1357290913
        MsgType:
          type: string
          example: Text
        Event:
          type: string
          example: subscribe
        EventKey:
          type: string
          example: qrscene_123123
        Ticket:
          type: string
          example: TICKET

创建工程

通过官网的指引,即可创建出一个 Spring Security 模版工程,创建好后,在 build.gradle 文件中,增加一些依赖,主要有

  • implementation "io.swagger.parser.v3:swagger-parser:2.0.20" 和 implementation 'org.springdoc:springdoc-openapi-ui:1.5.2'

    用来对接预先定义好的 API 文档,并自动生成相关代码

  • implementation "org.openapitools:jackson-databind-nullable:0.2.1"

    用来处理 json 反/序列化时的 null 值

  • implementation 'com.fasterxml.jackson.core:jackson-databind:2.10.0' 和 implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.10.1'

    用来处理 xml,因为微信服务发送过来的消息是 XML 格式的,而 Spring 工程默认是不会解析 XML 的 payload 的。如果不加入这个依赖,会导致接受消息的 controller 报 415 方法不允许的错误。

    题外话,关于这个 415 错误,一定要往 Request Body 的解析上定位,否则你会浪费不必要的时间去找原因,比如这位同学:

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第5张图片

  • implementation 'org.apache.pulsar:pulsar-client:2.6.3'

    用来和 pulsar 打交道

然后再在 build.gradle 文件里添加一个任务,用来根据最新的 Swagger 文档生成相关的类型代码等:

// generates the spring controller interfaces from openapi spec in src/main/resources/service.yaml
openApiGenerate {
    generatorName = "spring"
    inputSpec = "$projectDir/swagger-output/swagger.yaml"
    outputDir = "$buildDir/generated"
    apiPackage = "com.uniheart.wechatmpservice.api"
    invokerPackage = "com.uniheart.wechatmpservice"
    modelPackage = "com.uniheart.wechatmpservice.models"
    configOptions = [
            dateLibrary: "java8",
            interfaceOnly: "true",
    ]
}

这样每次文档有更新,就只需要在项目目录下跑一下命令:

./gradlew openApiGenerate

注意,我们采用了 Swagger Hub 来更新 API 文档,它有个 Sync 功能,可以在每次文档改动后点击一下,就会自动提交一个改动推送到你的 git 仓库。

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第6张图片

由于使用了 swagger 相关的依赖,它自带了 Swagger UI,所以即使在不能访问 Swagger Hub 的情况下,也可以直接访问项目本身 Host 的 Swagger UI

配置路由

Spring-Security 项目模版默认做了一些配置,我们需要额外添加几个,主要是放通我们的 API,以及 swagger 相关的路由,这在 WebSecurityConfig 里完成,主要代码如下:

 

package com.uniheart.securing.web.wechat.mp;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/", "/home").permitAll()
                .antMatchers("/mp-qr", "/mp-qr").permitAll()
                .antMatchers("/mp-qr-scan-status", "/mp-qr-scan-status").permitAll()
                .antMatchers(HttpMethod.POST, "/mp-message").permitAll()
                .antMatchers("/v3/api-docs", "/v3/api-docs").permitAll()
                .antMatchers("/swagger-ui", "/swagger-ui").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .permitAll()
                .and()
                .logout()
                .permitAll();

        http.csrf().disable();
    }
}

实现二维码的展示

本示例应用效果是登录页面除了可以输入用户名和密码登录外,还会显示一个二维码,扫码后即登录成功,并且在页面上显示一个欢迎信息:

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第7张图片

扫码登录成功后,可以看到 Cookie 多了一个 JSESSIONID 项:

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第8张图片

要实现二维码的展示,由于采用了 Swagger 生成轮廓代码,这里只需要添加一个新的 Controller 去实现预先定义好的 MpQrApi 即可:

package com.uniheart.securing.web.wechat.mp;

import com.uniheart.securing.web.wechat.mp.services.MpServiceBean;
import com.uniheart.wechatmpservice.api.MpQrApi;
import com.uniheart.wechatmpservice.models.MpQR;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

@RestController
public final class WechatMpApiController implements MpQrApi {
    @Autowired
    private MpServiceBean mpServiceBean;

    @Override
    public ResponseEntity mpQrUrl() {
        return new ResponseEntity<>(this.mpServiceBean.getMpQrCode(), HttpStatus.OK);
    }
}

可见核心业务逻辑在 MpServiceBean 中,代码如下:

package com.uniheart.securing.web.wechat.mp.services;

import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.uniheart.securing.web.wechat.mp.Constants;
import com.uniheart.wechatmpservice.models.MpQR;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UnknownFormatConversionException;

@Component
public class MpServiceBean {
    private final HttpClient httpClient;

    @Value("${weixin-qr-code-creation-endpoint:default-test-value}")
    private String qrCodeCreateUrl;

    @Value("${weixin-token-endpoint:default-test-value}")
    private String weixinAccessTokenEndpoint;

    public String getQrCodeCreateUrl() {
        return this.qrCodeCreateUrl;
    }

    public MpServiceBean() {
        this.httpClient = HttpClient.newHttpClient();
    }

    public MpServiceBean(HttpClient client, String qrCodeCreateUrl, String tokenEndpoint) {
        this.httpClient = client;
        this.qrCodeCreateUrl = qrCodeCreateUrl;
        this.weixinAccessTokenEndpoint = tokenEndpoint;
    }

    public void setQrCodeCreateUrl(String url) {
        this.qrCodeCreateUrl = url;
    }

    public void setWeixinAccessTokenEndpoint(String url) {
        this.weixinAccessTokenEndpoint = url;
    }

    Logger logger = LoggerFactory.getLogger(MpServiceBean.class);

    public MpQR getMpQrCode() {
        var mpTokenManager = new MpTokenManager(this.weixinAccessTokenEndpoint);

        URI uri = URI.create(this.qrCodeCreateUrl + mpTokenManager.getAccessToken().accessToken);

        logger.info("Getting qr code with " + uri);

        var payload = WeixinQrCodeRequestPayload.getRandomInstance();

        HttpRequest request =
                HttpRequest.newBuilder().POST(HttpRequest.BodyPublishers.ofString(payload.toJson())).uri(uri).build();

        try {
            HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            WeixinErrorResponse errorResponse = new Gson().fromJson(response.body(), WeixinErrorResponse.class);
            WeixinTicketResponse ticketResponse = new Gson().fromJson(response.body(), WeixinTicketResponse.class);

            if (ticketResponse.ticket != null) {
                return new MpQR().ticket(ticketResponse.ticket).imageUrl(ticketResponse.url).expireSeconds(ticketResponse.expiresInSeconds).url(ticketResponse.url).sceneId(String.valueOf(payload.action_info.scene.scene_id));
            }

            if (errorResponse.errcode == (40001)) {
                return new MpQR().ticket("test").imageUrl(Constants.FALLBACK_QR_URL);
            }

            throw new UnknownFormatConversionException(response.body());
        } catch (InterruptedException ie) {
            System.err.println("Exception = " + ie);
            ie.printStackTrace();

            return new MpQR().ticket("interrupted").imageUrl(Constants.FALLBACK_QR_URL);
        } catch (Exception ex) {
            System.err.println("Exception = " + ex);
            ex.printStackTrace();
            return new MpQR().ticket("error").imageUrl(Constants.FALLBACK_QR_URL);
        }
    }
}

代码比较长,不逐行解释了,建议对照项目中的测试代码一起看,主要是调用微信的 API,并根据响应走到不同的逻辑分支。以上的关键在于 WeixinQrCodeRequestPayload.getRandomInstance(),会生成场景值。场景值以及带参二维码,因为每个登录请求尝试都是独立发生的, 所以应该是全局唯一;为了防止恶意者攻击,这个场景值应该具有不可猜性。前面介绍其可以使用 UUID 来满足这两点里给出一种简便的实现,即根据当前时间来计算出一个场景值,由于精确到纳秒,所以很难重复。

package com.uniheart.securing.web.wechat.mp.services;

import com.google.gson.Gson;
import com.uniheart.securing.web.wechat.mp.Now;
import org.joda.time.Instant;

public class WeixinQrCodeRequestPayload {
    public String action_name;
    public ActionInfo action_info;
    public int expire_seconds;

    public String toJson() {
        return new Gson().toJson(this);
    }

    public static WeixinQrCodeRequestPayload getRandomInstance() {
        var timestamp = Now.instant();

        var ret = new WeixinQrCodeRequestPayload();
        ret.action_name = "QR_SCENE";
        ret.expire_seconds = 604800;
        ret.action_info = new ActionInfo();
        ret.action_info.scene = new Scene();
        ret.action_info.scene.scene_id = timestamp.getEpochSecond() + timestamp.getNano();

        return ret;
    }
}

class ActionInfo{
    public Scene scene;
}

class Scene {
    public long scene_id;
}

实现微信消息的接收

消息接收后还需要存储起来,Redis 方案的实现这里给出利用 pulsar 的具体实现。

package com.uniheart.securing.web.wechat.mp.services;

import com.google.gson.Gson;
import com.uniheart.wechatmpservice.models.Xml;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.apache.pulsar.client.api.*;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class MpMessageService {
    Logger logger = LoggerFactory.getLogger(MpMessageService.class);

    private final String pulsarUrl;
    private final String pulsarToken;
    private final String pulsarTopic;

    public MpMessageService(@Value("${pulsar-service-url}") String pulsarUrl, @Value("${pulsar-auth-token}") String pulsarToken, @Value("${pulsar-producer-topic}") String pulsarTopic) {
        this.pulsarUrl = pulsarUrl;
        this.pulsarToken = pulsarToken;
        this.pulsarTopic = pulsarTopic;
    }

    public void saveMessageTo(Xml message) throws PulsarClientException {
        var client = PulsarClient.builder().serviceUrl(pulsarUrl).authentication(AuthenticationFactory.token(pulsarToken)).build();
        var producer = client.newProducer().topic(pulsarTopic).create();
        producer.send(new Gson().toJson(message).getBytes());
        producer.close();
        client.close();
    }

    public synchronized Xml getMessageFor(String ticket) throws PulsarClientException {
        var client = PulsarClient.builder().serviceUrl(pulsarUrl).authentication(AuthenticationFactory.token(pulsarToken)).build();
        var consumer = client.newConsumer().topic(pulsarTopic).subscriptionName("my-subscription").subscribe();
        var xml = new Xml().fromUserName("empty");

        var received = false;

        var count = 0;

        do {
            var msg = consumer.receive(1, TimeUnit.SECONDS);
            count++;
            if (msg != null) {
                var json = new String(msg.getData());

                try {
                    xml = new Gson().fromJson(json, Xml.class);

                    received = xml.getTicket().equals(ticket);

                    if(received){
                        consumer.acknowledge(msg);
                    }
                } catch (Exception ex) {
                    logger.error("Failed to parse json: " + json);
                    xml.fromUserName(json);

                    consumer.acknowledge(msg);
                }
            }
        } while (!received && count < 30);

        consumer.close();
        client.close();

        return xml;
    }
}

以上服务封装了保存和获取方法,消息接收的 Controller 调用起保存消息的方法:

package com.uniheart.securing.web.wechat.mp;

import com.uniheart.securing.web.wechat.mp.services.MpMessageService;
import com.uniheart.wechatmpservice.api.MpMessageApi;
import com.uniheart.wechatmpservice.models.Xml;
import io.swagger.annotations.ApiParam;
import org.apache.pulsar.client.api.PulsarClientException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@RestController
public class WechatMessageController implements MpMessageApi {
    Logger logger = LoggerFactory.getLogger(WechatMessageController.class);

    private final MpMessageService mpMessageService;

    public WechatMessageController(MpMessageService mpMessageService) {
        this.mpMessageService = mpMessageService;
    }

    @Override
    public ResponseEntity mpMessage(@ApiParam(value = "wechat mp messages in xml format", required = true) @Valid @RequestBody Xml xml) {
        try {
            this.mpMessageService.saveMessageTo(xml);
            logger.info("saved info: " + xml);
            return new ResponseEntity<>(HttpStatus.OK);
        } catch (PulsarClientException e) {
            e.printStackTrace();
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
}

实现扫码状态查询

从以上实现可以看出,接收到微信服务的消息通知后,会同时保存两个信息,即保存被扫描的二维码对应的用户标识 openid,以及更新该二维码的扫码状态为已扫描。这个消息很重要,如前所述, 我们对客户端的扫码状态查询请求使用了长连接方案。查询扫码状态的部分比较复杂,因为这里把将用户登录的逻辑也放在这里了。即查询到对应的二维码被扫描后,在返回扫码成功前,新建一个 HTTP 上下文,将登录的用户实例化出来:

package com.uniheart.securing.web.wechat.mp;

import com.uniheart.securing.web.wechat.mp.services.MpMessageService;
import com.uniheart.wechatmpservice.api.MpQrScanStatusApi;
import com.uniheart.wechatmpservice.models.MpQRScanStatus;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.RestController;

import java.security.Principal;
import java.util.ArrayList;
import java.util.List;

@RestController
public final class WechatMpQRScanStatusApiController implements MpQrScanStatusApi {
    private final MpMessageService mpMessageService;

    public WechatMpQRScanStatusApiController(MpMessageService mpMessageService) {
        this.mpMessageService = mpMessageService;
    }

    @Override
    public ResponseEntity mpQrScanStatus(String ticket) {
        try {
            var xml = this.mpMessageService.getMessageFor(ticket);

            if(xml.getFromUserName().equals("empty")){
                return new ResponseEntity<>(new MpQRScanStatus().openId(""), HttpStatus.REQUEST_TIMEOUT);
            }

            var user = new Object() {};

            List authorities = new ArrayList<>();
            authorities.add(new SimpleGrantedAuthority("WechatMP"));


            Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(authentication);

            return new ResponseEntity<>(new MpQRScanStatus().openId(xml.getFromUserName()).status("SCANNED"), HttpStatus.OK);
        } catch (Exception ex) {
            ex.printStackTrace();
            return new ResponseEntity<>(new MpQRScanStatus().openId(ex.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
}

这样就实现了服务器端的三个开放 API。服务器端还有些逻辑,比如对微信的 Access Token 的管理等等

实现客户端逻辑

服务器端的 API,最终要由客户端来调用,这里的客户端逻辑,为了实现最小代码改动,直接写了原生 JavaScript 添加在了模版项目的 html 文件里(login.html),没有使用任何前端工程框架,直接手写了两个 ajax,完成:

        function queryScanStatus(ticket) {
            var req = new XMLHttpRequest();

            req.onreadystatechange = function () {
                if(req.readyState === 4 && req.status === 200) {
                    const json = JSON.parse(req.responseText);

                    if (json.status === 'SCANNED') {
                        location.href = '/hello';
                    }else{
                        alert('发生错误(也许是超时了)!')
                    }
                }
            };

            req.open("GET", "/mp-qr-scan-status?ticket=" + ticket);
            req.send();
        }

        function showQRCodeImage() {
            var req = new XMLHttpRequest();
            req.onreadystatechange = function () {
                if (req.readyState === 4 && req.status === 200) {
                    const json = JSON.parse(req.responseText);

                    document.getElementById('wechat-mp-qr').setAttribute('src', 'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=' + encodeURIComponent(json.ticket));

                    queryScanStatus(json.ticket);
                }
            };

            req.open("GET", "/mp-qr", true);
            req.send();
        }

        showQRCodeImage();

总结

本文通过一个实际的具有商业价值的项目,展示了 API 优先的开发方法。通过薅羊毛的方式,落地了 Free Arch 架构。

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第9张图片

 

有些小伙伴不知道本文内容和更多相关学习资料的请点赞收藏+评论转发+关注我,后面会有很多干货。我有一些面试题、架构、设计类资料可以说是程序员面试必备!所有资料都整理到网盘了,需要的话欢迎下载!私信我回复【000】即可免费获取

基于 Java Spring Security 的关注微信公众号即登录的设计与实现 ya_第10张图片

 

你可能感兴趣的:(Java,后端,java,架构,数据库,spring,面试)