Spring Security-16-开发QQ登录(下)

上文中,整个QQ登录的流程的代码已经写完了,可以启动项目试一下, 点QQ登录,效果如下:

image

可以看下QQ的文档, 就是回调地址不合法

这只是一个问题,在运行代码的过程中还会有几个问题,下面来看一下

回调地址

上面的问题就是回调地址不对, 在QQ互联里面,需要在自己应用中去配置一下回调地址,这里要注意,我们QQ登录跳转的请求是 /auth/qq 然后这个地址和回调的地址是一个,就是说登录跳转了 /auth/qq ,那么回调地址也是 /auth/qq

我现在在QQ互联里面配的回调是 http://www.pinzhi365.com/qqLogin/callback.do ,那么我跳转认证和回调的地址都应该是 /qqLogin/callback.do 而不是 /auth/qq

但是在 social 中 /auth 这段地址是 SocialAuthenticationFilter 中写死的, 那我们就需要去把他的配置给覆盖掉,下面看下如何实现
在此之前,还有一件事,就是在本地开发环境中,需要把这个域名映射到本地的项目,这个需要在 host 文件中,加入以下内容

1
127.0.0.1 www.pinzhi365.com

把项目的端口改成80

自定义social的url

我们之前在 SocialConfig 中配置了 SpringSocialConfigurer 这个类,然后在浏览器的配置中引入了, 点进去这个类看一下
image

它里面有一个 configure() 方法,在最后,把 SocialAuthenticationFilter 加到了security的过滤器链上面, 在此之前 调用了一个 postProcess() 方法,去做了一些操作, 我们可以重写这个方法,然后加一些默认的操作

新建一个 MySpringSocialConfigurer 继承 SpringSocialConfigurer ,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MySpringSocialConfigurer extends SpringSocialConfigurer {

private String filterProcessesUrl;

public MySpringSocialConfigurer(String filterProcessesUrl) {
this.filterProcessesUrl = filterProcessesUrl;
}

@Override
protected <T> T postProcess(T object) {
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
filter.setFilterProcessesUrl(filterProcessesUrl);
return (T) filter;
}
}

这里首先重写了一个 postProcess() 方法, 这个object参数其实就是 social 的过滤器, 所以首先拿到父类的处理结果,然后再加入我们自己的 url ,从构造里面传过来

然后 SocialConfig 中,要把之前的 SpringSocialConfigurer 替换成我们自己的 MySpringSocialConfigurer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

@Autowired
private SecurityProperties securityProperties;

@Bean
public SpringSocialConfigurer securitySocialConfigurer(){
String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
MySpringSocialConfigurer mySpringSocialConfigurer = new MySpringSocialConfigurer(filterProcessesUrl);
return mySpringSocialConfigurer;
}
}

这个路径是通过配置传过来的, 在 SocialProperties 中需要加一个属性

1
2
3
4
/**
* social 要拦截的请求地址
*/
private String filterProcessesUrl = "/auth";

默认还是 /auth ,但是在我们上面配置的回调中,是 /qqLogin 所以需要在配置文件中加入以下配置:

1
2
3
4
5
6
7
8
core:
security:
social:
filterProcessesUrl: /qqLogin
qq:
app-id: xxx
app-secret: xxxxxxx
providerId: callback.do

先是 filterProcessesUrl 配置成 /qqLogin 然后 providerId 配置为 callback.do, 这样回调配置就ok了, 在页面中的请求地址也是 /qqLogin/callback.do

1
<a href = '/qqLogin/callback.do'>QQ登录</a>

配置完上面的之后,启动项目,访问 www.pinzhi365.com/login.html ,然后使用QQ登录,效果如下:
image

现在,就是授权的界面,点击头像登录,这个时候会调回到登录页, 在控制台中会有这样的一段输出

1
2019-08-19 18:00:03.851  INFO 10952 --- [p-nio-80-exec-4] c.s.e.web.controller.BrowserController   : 引发跳转的请求是:http://www.pinzhi365.com/signin

这里可以看到,他并没有去获取到QQ的用户信息,而是到了一个/signup的请求

登录流程

下面先不去管QQ登录, 先看一下social的登录流程
image

总体的流程和之前的登录是一样的, 首先是拦截器拦截特定的登录请求, 然后通过 ConnectionFactory 创建一个没有经过认证的 Authentication 对象, 然后就是通过 AuthenticationProvider 去验证,去调用 SocialUserDetailsService 然后校验,最后返回一个已认证的 Authentication

在这个过程中 SocialAuthenticationService 会完成整个 OAuth2 的流程, 他会去调用我们的 ConnectionFactory 让然后里面有 ServiceProvider 等,最后拿到第三方的用户信息之后, 创建出来一个 Connection 对象, 封装成一个 SocialAuthenticationToken

回到上面的问题中, 我们在网站授权完成之后,条转到的是一个 /signup 的请求, 也就是再授权完成回调的时候出的问题,这个时候 OAuth2 的流程还没有走完, 也就是说问题出在 SocialAuthenticationService

看一下 OAuth2AuthenticationService 的源码, 这个类是继承 SocialAuthenticationService

image

这个 getAuthToken() 方法就是去获取第三方的授权的, 首先它会判断请求中有没有code这个参数, 因为我们系统中发起第三方登录请求和回调的地址都是一样的 , 所以有 code 参数的话,就是回调的,没有的话就是我们发起登录的

上面是没有 code 参数的处理, 最后会抛出一个异常, social 捕获到这个异常之后,就会重定向到QQ的授权页面

下面的是有 code 参数的处理, 这里会调用我们的 ConnectionFactory 创建 Connection 最后返回 SocialAuthenticationToken 对象

我们授权之后,QQ重定向回来,也就是说, 上面的问题是创建 SocialAuthenticationToken 的过程中出现了问题

在回调的时候打个断点看一下出的问题
image

如图这里的异常是:

1
Could not extract response: no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]

这里 QQ 返回的信息的 content type 是 [text/html] 的, 这里是使用 ConnectionFactory 去调用 OAuthOperationsexchangeForAccess() 方法出现的问题, 这里的 OAuthOperations 我们使用的是默认的 OAuth2Template

看一下 OAuth2Template 中的 exchangeForAccess() 方法,如下:
image

这个方法最后调用了一个 postForAccessGrant() 方法
image

这个方法就是用他自己的 RestTemplate 调用第三方的接口,然后将返回来的数据转成一个 Map 对象,这就需要返回来的数据是一个json格式的数据,contentType 是 application/json ,但是我们上面QQ返回来的是 text/html 的, 所以就会报错,捕获到异常之后返回一个空

在上层的 SocialAuthenticationFilter 中,判断token是否为空, 是空的话也返回空,如下图:
image

最终,会调用 social 的失败处理器,会重定向到一个默认的登录页面
image

这个默认的页面就是 /signup

问题定位到之后,看一下如何解决

自定义 OAuth2Template

先看一下 OAuth2Template 中的 createRestTemplate() 方法, 如下:
image

这里其实就是他自己的 RestTemplate 没有加处理 text/html 的 converter,那么我们需要写一个自定义的 RestTemplate ,让他可以去处理 text/html 的返回

还有一个问题, 他的 RestTemplate 拿到返回值以后,会转成一个Map类型的数据,然后调用了一个 extractAccessGrant() 方法,组成一个 AccessGrant 对象返回,代码如下:
image

但是在QQ的返回,其实是下面这样的一个字符串:

1
access_token=FE04************************CCE2&expires_in=7776000&refresh_token=88E4************************BE14

然后我们需要把这个拆出来.组合成一个 AccessGrant 对象返回去

最后再看一下 exchangeForAccess() 方法
image

这里有个属性是 useParametersForClientAuthentication ,只有这个属性是true的时候,请求参数里面才会带上 client_idclient_secret 这两个参数是QQ要求必传的,所以我们还需把 useParametersForClientAuthentication 设置成true

好了,要做的功能就是这几个,然后新建一个 QQOAuth2Template 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Slf4j
public class QQOAuth2Template extends OAuth2Template {

public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
// 设置 useParametersForClientAuthentication 为true
setUseParametersForClientAuthentication(true);
}

@Override
protected RestTemplate createRestTemplate() {
// 拿到父类创建的结果
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
return restTemplate;
}

@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);

log.info("获取accessToken的响应:{}", responseStr);

// 返回格式是 access_token=FE04************************CCE2&expires_in=7776000&refresh_token=88E4************************BE14
// 拆分组装成 AccessGrant
String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");

String accessToken = StringUtils.substringAfterLast(items[0], "=");
Long expiresIn = new Long(StringUtils.substringAfterLast(items[1], "="));
String refreshToken = StringUtils.substringAfterLast(items[2], "=");

return new AccessGrant(accessToken, null, refreshToken, expiresIn);
}
}

  • restTemplate 中,添加了处理 text/html 的处理器
  • 重写了 postForAccessGrant() 自定义解析QQ的返回值
  • 构造中设置 useParametersForClientAuthentication 为true

ServiceProvider 修改

在我们的 QQServiceProvider 中把之前的 OAuth2Template 替换成我们自己的

1
2
3
4
public QQServiceProvider(String appId, String secret) {
super(new QQOAuth2Template(appId, secret, URL_AUTHORIZE, URL_ACCESS_TOKEN));
this.appId = appId;
}

最后启动项目,用QQ登录, 控制台输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
2019-08-19 22:48:35.253  INFO 3160 --- [p-nio-80-exec-2] c.s.e.c.a.social.qq.api.QQImpl           : QQ获取用户信息结果:{
"ret": 0,
"msg": "",
"is_lost":0,
"nickname": "周。",
"gender": "男",
"province": "浙江",
"city": "杭州",
// ... 省略部分信息
}

2019-08-19 22:48:35.282 INFO 3160 --- [p-nio-80-exec-3] c.s.e.c.v.filter.ValidateCodeFilter : 进入验证码校验的filter.....
2019-08-19 22:48:35.296 INFO 3160 --- [p-nio-80-exec-6] c.s.e.c.v.filter.ValidateCodeFilter : 进入验证码校验的filter.....
2019-08-19 22:48:35.316 INFO 3160 --- [p-nio-80-exec-6] c.s.e.web.controller.BrowserController : 引发跳转的请求是:http://www.pinzhi365.com/signup

这里这次会跳转到一个 /signup 的请求上去,就是默认的注册请求,这个之后再说

总结

回顾下登录的流程:

  1. 访问第三方登录的请求,没有携带code参数
  2. social 会重定向到第三方的授权页面
  3. 用户授权完毕之后, 第三方会回调原地址, 并且携带code参数
  4. social 检测到了code 参数之后, 调用 QQImpl 去交换 accessToken
  5. 走自定义的 OAuthTemplate 去拿到token
  6. 组装返回一个 AccessGrant 对象
  7. 获取用户的信息,组装成未认证的 Authentication 对象
  8. 最后就是 security 的认证流程了

本文代码传送门