使用java后端的springboot环境下实现网站接入QQ第三方登录

说明

基于引入了 Spring MVC 的 Spring boot 环境。

接入QQ的官方文档:传送门

获取接入资格从而获取网站的app_id和app_key等内容官网已经足够详尽,此处不再赘述。每一步要向QQ提供的哪个API网址发请求,要带什么参数等官网文档也已经介绍清楚,不再赘述。

重点在于完全使用java的后端技术调用QQ的第三方登录API完成整个登录流程——得到可以唯一标识一个QQ的openid——中,java代码该如何写,为什么,我遇到哪些坑。

在类资源文件夹 resources 下,存放一个 QQLogin.properties 文件,其中存放了整个过程中需要用到的参数。之后出现的QQLoginUtil.getQQLoginInfo(...)会从这个文件中读取对应内容。具体实现不是本文重点,可以参考这篇文章:java读取.properties配置文件的几种方法

官方文档第一节:准备工作_OAuth2.0

文档已经足够详尽。不过强调一下应用设置中的回调地址和我们之后使用的回调地址必须一模一样,不要想着我在回调地址中填一个/Handler,然后回调地址写/Handler/AHandler,这是不允许的。

官方文档第二节:放置“QQ登录”按钮_OAuth2.0

大部分是前端内容,本文主要讨论后端代码,故不多做讨论。但我们这里将点击QQ登录按钮的超链接定位发给我们自己的后端服务器的一个请求,因为整个过程都由我们的后端完成嘛。

这里贴一下我们后端的结构

@Controller
@RequestMapping("/login")
public class loginServlet {
    @RequestMapping("/loginByQQ")
    public void loginByQQ(HttpServletRequest req, HttpServletResponse resp) throws Exception {}

    @RequestMapping("/loginByQQCallbackHandler")
    public void loginByQQCallbackHandler(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {}
}

所以我将QQ登录的超链接定位到/login/loginByQQ

官方文档第三节:使用Authorization_Code获取Access_Token

Step1:获取Authorization Code

这一步也没有什么好说的,官方文档把 ①请求发给谁②带什么参数③发完之后如何响应 这三点都说的清清楚楚了。所以代码中第一步是从配置文件中获取参数的值,然后通过 String 的 format方法 形成要发送的URL,这里多次使用+进行字符串拼接也可,但这样从语义上来讲更加好理解。然后服务器跳转到指定URL。

@RequestMapping("/loginByQQ")
    public void loginByQQ(HttpServletRequest req, HttpServletResponse resp) throws Exception {

        String response_type = QQLoginUtil.getQQLoginInfo("response_type");
        String client_id = QQLoginUtil.getQQLoginInfo("client_id");
        String redirect_uri = QQLoginUtil.getQQLoginInfo("redirect_uri");
        //client端的状态值。用于第三方应用防止CSRF攻击。
        String state = new Date().toString();
        req.getSession().setAttribute("state", state);

        String url = String.format("https://graph.qq.com/oauth2.0/authorize" +
                "?response_type=%s&client_id=%s&redirect_uri=%s&state=%s", response_type, client_id, redirect_uri, state);
                
        resp.sendRedirect(url);
    }

根据本文第二节展示的控制器路径,回调路径必定以/login/loginByQQCallbackHandler结尾,从而使这一步发出请求后,QQ那边带上说好的参数(以get的请求参数的形式)跳转回来后,会由loginByQQCallbackHandler方法来处理。

Step2:通过Authorization Code获取Access Token

首先获取 Authorization Code:String authorization_code = req.getParameter("code");

然后按照官方文档的要求完成我们的URL:`

if (authorization_code != null && !authorization_code.trim().isEmpty()) {
    //client端的状态值。用于第三方应用防止CSRF攻击。
    String state = req.getParameter("state");
    if (!state.equals(req.getParameter("state"))) {
        throw new RuntimeException("client端的状态值不匹配!");
    }
    String urlForAccessToken = getUrlForAccessToken(authorization_code);
}

我们先对之前官方文档提到的用来做前后校验的state参数进行校验,只有通过才允许下一步。

然后就是getUrlForAccessToken这个方法的具体实现:

public String getUrlForAccessToken(String authorization_code) {
        String grant_type = QQLoginUtil.getQQLoginInfo("grant_type");
        String client_id = QQLoginUtil.getQQLoginInfo("client_id");
        String client_secret = QQLoginUtil.getQQLoginInfo("client_secret");
        String redirect_uri = QQLoginUtil.getQQLoginInfo("redirect_uri");
        
        String url = String.format("https://graph.qq.com/oauth2.0/token" +
                        "?grant_type=%s&client_id=%s&client_secret=%s&code=%s&redirect_uri=%s",
                grant_type, client_id, client_secret, authorization_code, redirect_uri);
        
        return url;
    }

这些都没什么可说的。

之后就是跳转这个URL去获取 access_token,这里就是第一个坑了,按照官方文档,搞得好像这次我们跳转到这个获取 access_token 的URL后,腾讯那边会跳转我们设定的回调地址并带上我们需要的参数,就像之前获取 authorization code 一样。但完全不是这样的!!!你按照要求向这个获取 access_token 的URL发送请求后,对方并不会再跳转,而是直接返回你一个数据,希望你获得这个数据然后处理。这有点像前端JS的异步请求后后回调函数处理data。

所以在这里我们也要使用java后端去模拟客户端来发起这个请求,因此我使用了 Spring容器 中的 RestTemplate 模块,这部分代码如下:

RestTemplate restTemplate = (RestTemplate) applicationContext.getBean("RestTemplate");

可以看到我们是将这个 RestTemplate 对象注册在了 spring 容器中。注册具体代码就在 spring boot 的根配置文件中,具体代码如下:

@Bean(name="RestTemplate")
    @Autowired
    public RestTemplate getRestTemplate(RestTemplateBuilder restTemplateBuilder){
        RestTemplate restTemplate = restTemplateBuilder.build();
        return restTemplate;
    }

其中 RestTemplateBuilder 类对象由于 RestTemplate 是 Spring MVC 集成的模块,其有内置的配置并且已经足够我们使用,只需使用@Autowired注解注入即可。

至于怎么在 loginServlet 这个 Controller 中得到 Spring容器(applicationContext):在这个loginServlet类中定义了一个ApplicationContext类的成员变量然后使用@Autowired注解之后,Spring 会将容器自动注入给它。关于这件事是否有更优雅的实现,答案目前似乎是没有:请问 Springboot 有没有什么优雅的方法可以调用 getBean(String beanname) 来获取 Bean?

现在弄清楚了如何获取一个RestTemplate的实例,也得到了用来获取 access token 的URL,我们使用RestTemplate来发起GET请求并处理。

//第一次用服务器发起模拟客户端访问,得到的是包含access_token的字符串,其格式如下
//access_token=0FFD92ABD1DFD4F5&expires_in=7776000&refresh_token=04CE5D1F1E290B0974C5
String firstCallbackInfo = restTemplate.getForObject(urlForAccessToken, String.class);
String[] params = firstCallbackInfo.split("&");
String access_token = null;
for (String param : params) {
    String[] keyvalue = param.split("=");
    if(keyvalue[0].equals("access_token")){
        access_token = keyvalue[1];
        break;
    }
}

是的,正如官方文档说的那样,你得到的返回的data是access_token=0FFD92ABD1DFD4F5&expires_in=7776000&refresh_token=04CE5D1F1E290B0974C5这么个奇葩玩意儿!不是一个JSON,腾讯在做API这件事上真是领先业界一百年啊,这可能是五百年后人类从第四次世界大战再次恢复到电气时代后的最优秀的格式吧。
我这里处理它的思路是先用&分成三段,然后再将每段用=分成两个字符串,可以认为这两个字符串呈 key-value 关系,然后得到其中 key=access_token 的 value,也就是我们的目标了。

另外再提一下RestTemplate类的getForObject方法对JSON有很好的支持,你可以在第二个参数填入对应JSON的实体类的字节码,但如果不劳烦RestTemplate(不过其实内部是被 Spring boot 配置 Jackson 去解析的)的话,传String.class,就会得到原原本本的返回数据了。

Step3:(可选)权限自动续期,获取Access Token

我没用上。

官方文档第四节:获取用户OpenID_OAuth2.0

这次获取 data 的技术点基本上上一节都提到了,唯一的难点在于,这次返回的数据更加不好处理了。callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );这种JSONP的格式,腾讯你是真的牛逼。

先上前面获取这个返回数据的代码吧。

if (access_token != null && !access_token.trim().isEmpty()) {
    String url = String.format("https://graph.qq.com/oauth2.0/me?access_token=%s", access_token);
    //第二次模拟客户端发出请求后得到的是带openid的返回数据,格式如下
    //callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
    String secondCallbackInfo = restTemplate.getForObject(url, String.class);
}

接下来就是我们要如何处理这个得到的数据了,我的思路是使用正则表达式(正则表达式功能集成于JDK中),截取出{"client_id":"YOUR_APPID","openid":"YOUR_OPENID"}这段内容,然后用 spring boot 内部集成的 jackson 模块将其转换为一个 Map 对象后通过 get 方法得到,代码如下

//正则表达式处理
String regex = "\\{.*\\}";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(secondCallbackInfo);
if(!matcher.find()){
    throw new RuntimeException("异常的回调值: "+secondCallbackInfo);
}

//调用jackson
ObjectMapper objectMapper = new ObjectMapper();
HashMap hashMap = objectMapper.readValue(matcher.group(0), HashMap.class);

String openid = ((String) hashMap.get("openid"));

这里有两点值得一说

其一,为什么String regex = "\\{.*\\}";,正则表达式中有\\这东西呢?这时因为正则表达式中{}都是有意义的,非字符的,我们希望正则表达式把它们理解成字符,就需要对它们进行转义,所以这里需要一个转义符\,但\自身在java字符串中并不是字符,所以我们还要转义\自身,所以会出现\\

其二,matcher如果不经历matcher.find(),则就算有合适的匹配内容,也仍然不会有任何匹配能得到。所以matcher.find()是必须的,同时matcher.find()一次后再来一次,那完了,返回false。

至此,就完成了得到 openId 这个能唯一标识一个QQ的标识码,之后的数据库等操作就不属于我们的重点了,至于进一步地获取用户昵称啊,头像啊,结合官方文档和以上经历之后得到的经验,想来也不会有大问题了。那么本文到此结束。

学到的一些其它东西

因为要读取类资源路径下的 properties 文件,又不希望读取文件加载Properties对象的无关代码参入到方法中,我建立了一个工具类来集中这个过程。但面临一个问题:静态方法下无法使用this.class.getClassLoader().getResourceAsStream(...),因为this不出来。这时将其改为类的名字即可:QQLoginUtil.class.getClassLoader().getResourceAsStream(...)