项目源码地址 https://github.com/nieandsun/security
1. /signin 错误展示
上篇文章虽然解决了redirect uri is illegal(100010)
错误,但是扫码进行授权后,发现仍然不能完成第三方登陆的流程。而是会报出下图所示的错误,通过后端打印日志可以看到服务请求了XXX/signin,而在项目里并未对该url进行放行,因此前端页面展示出这样的错误是很好理解的。那为什么在进行QQ扫码授权后,会跳到该url上呢?这需要去springsocial源码中去寻求答案。
2 springsocial源码解读
从用户点击QQ登陆 —》到程序引导用户进入QQ授权页面 —》到用户在QQ授权页面进行扫码授权—》到程序拿着获取到的用户信息与我们自己数据库里的用户信息进行关联+校验 —》再到校验成功并构建一个标识为校验成功的 Authentication对象 的整个过程springsocial/springsecurity所使用到的主要类大致
如下图。我会在本文和下一篇文章尽量比较详细地介绍一下这整个流程。
2.1 AbstractAuthenticationProcessingFilter
这个Filter我在《spring-security入门6—表单登陆认证原理源码解析》这篇文章里详细地介绍过,它其实是一个抽象类,利用模版模式
定义了认证的整个整体框架,但是具体的认证过程、认证成功后的行为和认证失败后的行为需要子类进行具体实现。其伪代码如下:
try{
//尝试进行登陆认证---抽象方法具体实现交给交给继承的子类,
//在springsocial的流程里就是SocialAuthenticationFilter
//用户名+密码登陆的流程里为UsernamePasswordAuthenticationFilter
Authentication authResult = attemptAuthentication(request, response);
}catch(认证失败){
//这里会讲两个认证失败相关的逻辑
// (1)点击QQ登陆,将用户引导到QQ授权页面时
// (2)用户在QQ授权页面进行扫码,最终未成功登陆而是访问XXX/signin路径的逻辑
unsuccessfulAuthentication(request, response, failed);
}
//认证成功
// -->将认证成功的Authentication对象放到线程的SecurityContext对象中
// -->走记住我相关的逻辑等
successfulAuthentication(request, response, chain, authResult);
2.2 SocialAuthenticationFilter
SocialAuthenticationFilter和用户名+密码认证校验方式时用到的UsernamePasswordAuthenticationFilter一样,也是AbstractAuthenticationProcessingFilter的子类。当请求的URL为SocialAuthenticationFilter指定要拦截的URL时springsecurity/springsocial就会走SocialAuthenticationFilter中的attemptAuthentication((request, response))
进行认证登陆。其伪代码如下:
- attemptAuthentication — 认证校验的准备工作
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (detectRejection(request)) {
if (logger.isDebugEnabled()) {
logger.debug("A rejection was detected. Failing authentication.");
}
throw new SocialAuthenticationException("Authentication failed because user rejected authorization.");
}
Authentication auth = null;
Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds();
//获取到ProviderId,就是我们在yml配置文件里指定的ProviderId
String authProviderId = getRequestedProviderId(request);
if (!authProviders.isEmpty() && authProviderId != null && authProviders.contains(authProviderId)) {
//根据ProviderId获取相应的SocialAuthenticationService
SocialAuthenticationService<?> authService = authServiceLocator.getAuthenticationService(authProviderId);
//真正进行认证校验的方法
auth = attemptAuthService(authService, request, response);
if (auth == null) {
throw new AuthenticationServiceException("authentication failed");
}
}
return auth;
}
- attemptAuthService — 定义了真正认证校验的整体框架
private Authentication attemptAuthService(final SocialAuthenticationService<?> authService, final HttpServletRequest request, HttpServletResponse response)
throws SocialAuthenticationRedirectException, AuthenticationException {
//这里的token其实是用从QQ获取的用户信息(被封装到一个Connection对象里)进一步封装后得到的未被校验的对象
//类比一下,在用户名+密码登陆时也会有一个未校验的token
final SocialAuthenticationToken token = authService.getAuthToken(request, response);
if (token == null) return null;
Assert.notNull(token.getConnection());
//即Authentication auth=SecurityContextHolder.getContext().getAuthentication();
//即先去SecurityContextHolder里看看有没有Authentication对象
Authentication auth = getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
//进行密码是否过期,用户是否可用等校验,如校验成功,返回一个标识为校验成功的Authentication对象
return doAuthentication(authService, request, token);
} else {
//暂时不进行追究
addConnection(authService, request, token, auth);
return null;
}
}
2.3 OAuth2AuthenticationService
2.3.1 getAuthToken方法 — 走Oauth2协议流程很重要的一个方法
OAuth2AuthenticationService对上面attemptAuthService
方法调用的 getAuthToken方法进行了具体实现,其主要代码如下:
public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException {
//获取授权码
String code = request.getParameter("code");
//用户点击QQ登陆,我们的程序里将用户引导到QQ授权页面时请求的URL(Oauth2协议中获取授权码的过程) 和 用户在QQ授权页面
//进行扫码授权后,QQ服务器回调我们服务的URL是相同的
// 两者不同的就是我们引导用户到QQ授权页面时的请求里没有授权码,而用户点击授权QQ回调时会携带者授权码
//因此下面其实就是在走用户点击QQ登陆,我们的程序将用户引导到QQ授权页面的逻辑
if (!StringUtils.hasText(code)) {
OAuth2Parameters params = new OAuth2Parameters();
params.setRedirectUri(buildReturnToUrl(request));
setScope(request, params);
params.add("state", generateState(connectionFactory, request));
addCustomParameters(params);
//拼装成访问QQ授权页面的路径,并throw出去
throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params));
} else if (StringUtils.hasText(code)) {
try {
//从request中拿出redirect_uri
String returnToUrl = buildReturnToUrl(request);
//拿着redirect_uri、授权码等去QQ服务器交换accessToken
AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
// TODO avoid API call if possible (auth using token would be fine)
//获取到从QQ拿到的封装成Connection对象的用户信息
Connection<S> connection = getConnectionFactory().createConnection(accessGrant);
//将Connection对象进一步封装成SocialAuthenticationToken对象,便于2.2中的attemptAuthService方法进行校验
return new SocialAuthenticationToken(connection, null);
} catch (RestClientException e) {
logger.debug("failed to exchange for access", e);
return null;
}
} else {
return null;
}
}
2.3.2 用户点击QQ登陆,引导用户到QQ授权页面的具体逻辑
通过2.3.1可以知道,当用户点击QQ登陆时,会走到没有code的if语句块
中,在这个语句块里其实就做了一件事,就是拼接访问QQ授权页面的路径
(注意,这里拼接路径的规则和QQ互联上获取Authorization Code指明的规则是一致的),并将该路径封装到SocialAuthenticationRedirectException
异常中抛出去。那将异常抛出去之后怎么就会访问到了QQ授权页面呢?这里就不得不提一下springsocial所定义的处理异常的类SocialAuthenticationFailureHandler了。首先抛出的异常会一路向上抛到AbstractAuthenticationProcessingFilter里,然后在该Filter里会捕获到抛出的异常,并根据异常类型,指定用SocialAuthenticationFailureHandler来进行处理,SocialAuthenticationFailureHandler的源码如下:
public class SocialAuthenticationFailureHandler implements AuthenticationFailureHandler {
//异常处理器
private AuthenticationFailureHandler delegate;
public SocialAuthenticationFailureHandler(AuthenticationFailureHandler delegate) {
this.delegate = delegate;
}
//具体处理异常的方法
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
//处理SocialAuthenticationRedirectException异常的逻辑
if (failed instanceof SocialAuthenticationRedirectException) {
//将异常中的URL取出,并进行重定向----》这里就是引导用户到QQ授权页面的奥秘!!!
response.sendRedirect(((SocialAuthenticationRedirectException) failed).getRedirectUrl());
return;
}
//处理其他springsocial认证失败的逻辑-----》就包含了本文标题中所说的/signin错误
delegate.onAuthenticationFailure(request, response, failed);
}
}
2.3.3 /signin错误的具体原因
2.3.3.1 为什么会跳转(重定向)到/signin路径
通过断点调试可以发现,用户在QQ页面进行扫码授权后,会走到2.3.1中的else if语句块
,但是在运行到下面的方法时就会报错了。
AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
报错信息为:
Could not extract response: no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]
异常会被catch住(可以注意一下,这里的异常为RestClientException,它其实是请求相关的异常),catch住之后,只是打印了一个logger信息,然后返回了一个null。
—》这里返回null
—》2.2中的attemptAuthService方法就会返回null
—》然后2.2中attemptAuthentication方法里获得的auth就是null,当auth为null时就会抛出SocialAuthenticationException
异常
—》AbstractAuthenticationProcessingFilter会捕捉到该异常,然后根据异常类型,指定用SocialAuthenticationFailureHandler(即2.3.2中所说的SocialAuthenticationFailureHandler)来进行处理
—》走delegate.onAuthenticationFailure(request, response, failed);
语句进行处理
—》真正的处理该异常的具体实现在SimpleUrlAuthenticationFailureHandler类里,重定向到XXX/signin的具体逻辑就在该类里。(感兴趣的童鞋可以自己追踪着看一下,这里不再赘述!!!)
2.3.3.2 为什么获取accessToken会抛出RestClientException异常
想要知道具体的原因必须要深入到报错的语句中,exchangeForAccess方法具体的实现(OAuth2Template类中
)如下:
- exchangeForAccess方法 — 预备工作:拼装请求参数
public AccessGrant exchangeForAccess(String authorizationCode, String redirectUri, MultiValueMap<String, String> additionalParameters) {
//拼接请求参数
MultiValueMap<String, String> params = new LinkedMultiValueMap<String, String>();
//注意这里的useParametersForClientAuthentication要为true才会将client_id 和 client_secret拼接为请求参数
//QQ互联(https://wiki.connect.qq.com/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token)上
//明确指出获取accessToken这两个参数为必传参数,因此要想办法保证该值在这里必须为true
if (useParametersForClientAuthentication) {
params.set("client_id", clientId);
params.set("client_secret", clientSecret);
}
params.set("code", authorizationCode);
params.set("redirect_uri", redirectUri);
params.set("grant_type", "authorization_code");
if (additionalParameters != null) {
params.putAll(additionalParameters);
}
//真正发送请求获取accessToken
return postForAccessGrant(accessTokenUrl, params);
}
- postForAccessGrant —
RestClientException异常发生的地方
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
//方法很简单,就是利用restTemplate发送一个rest请求,然后将结果交给 extractAccessGrant函数进行构建AccessGrant对象
//RestClientException异常就发生在这里
return extractAccessGrant(getRestTemplate().postForObject(accessTokenUrl, parameters, Map.class));
}
到底为什么会发生这个异常呢?通过异常信息Could not extract response: no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]其实可以很清楚的知道,是因为当前的RestTemplate没有适合的HttpMessageConverter 去处理reponse为Map,content type为 [text/html]的返回信息。看一下OAuth2Template中创建RestTemplate对象的方法,可以发现,它只加了如下三个HttpMessageConverter,这三个HttpMessageConverter并不能处理content type为 [text/html]的返回信息,因此走到上面的postForAccessGrant方法会抛出RestClientException异常。
protected RestTemplate createRestTemplate() {
ClientHttpRequestFactory requestFactory = ClientHttpRequestFactorySelector.getRequestFactory();
RestTemplate restTemplate = new RestTemplate(requestFactory);
List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>(2);
converters.add(new FormHttpMessageConverter());
converters.add(new FormMapHttpMessageConverter());
converters.add(new MappingJackson2HttpMessageConverter());
restTemplate.setMessageConverters(converters);
restTemplate.setErrorHandler(new LoggingErrorHandler());
if (!useParametersForClientAuthentication) {
List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
if (interceptors == null) { // defensively initialize list if it is null. (See SOCIAL-430)
interceptors = new ArrayList<ClientHttpRequestInterceptor>();
restTemplate.setInterceptors(interceptors);
}
interceptors.add(new PreemptiveBasicAuthClientHttpRequestInterceptor(clientId, clientSecret));
}
return restTemplate;
}
3 /signin错误解决方式
找到问题的具体原因,我们就可以着手去解决该问题了,其实解决方式也比较简单,就是我们只需要写一个类,继承OAuth2Template,并重写createRestTemplate方法,给RestTemplate对象加上一个可以处理content type为 [text/html]的HttpMessageConverter就可以了。在此之前还要说明一点,OAuth2Template类里postForAccessGrant 发送post请求获取accessToken期待的返回值为Map,但通过QQ互联官网可以看到获取accessToken返回的是如下的一个字符串,并不是一个json,那就无法直接转成Map了,因此我们还需要重写一下postForAccessGrant方法。
access_token=FE04************************CCE2&expires_in=7776000&refresh_token=88E4************************BE14。
- 继承了OAuth2Template的实体类
/**
*
*/
package com.nrsc.security.core.social.qq.connect;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.social.oauth2.AccessGrant;
import org.springframework.social.oauth2.OAuth2Template;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.Charset;
@Slf4j
public class QQOAuth2Template extends OAuth2Template {
public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
//只有这样才会将client_id 和 client_secret拼接为请求参数
setUseParametersForClientAuthentication(true);
}
/**
* 通过QQ互联(https://wiki.connect.qq.com/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token)
* 可以知道获取accessToken这一步其实获取到的是一个字符串,而OAuth2Template里的postForAccessGrant方法默认获取的类型
* 为一个Map(获取的信息为json格式才可直接转成Map),因此需要重写postForAccessGrant方法,将获取到的accessToken等
* 信息封装到AccessGrant对象里
* @param accessTokenUrl
* @param parameters
* @return
*/
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);
log.info("获取accessToke的响应:" + responseStr);
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);
}
/**
* 重写createRestTemplate方法,使其加上可以处理content type为 [text/html]的HttpMessageConverter
* @return
*/
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
return restTemplate;
}
}
- 用我们自己的QQOAuth2Template对象来构建ServiceProvider对象
package com.nrsc.security.core.social.qq.connect;
import com.nrsc.security.core.social.qq.api.QQ;
import com.nrsc.security.core.social.qq.api.QQImpl;
import org.springframework.social.oauth2.AbstractOAuth2ServiceProvider;
/**
* @author : Sun Chuan
* @date : 2019/8/5 23:47
* Description:利用利用Api和Oauth2Operation对象组装ServiceProvider对象
*/
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {
private String appId;
/**
* 对应于OAuth2协议第一步,即将用户导向QQ认证授权页面的url
*/
private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
/**
* 对应于Oauth2协议中拿着授权码获取Access Token这一步的url
*/
private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";
/**
* 必须要有一个构造,否则会报错
*
* @param appId
* @param appSecret
*/
public QQServiceProvider(String appId, String appSecret) {
/**
* 用我们自己的QQOAuth2Template对象来构建ServiceProvider对象
*/
super(new QQOAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN));
this.appId = appId;
}
@Override
public QQ getApi(String accessToken) {
return new QQImpl(accessToken, appId);
}
}
4 测试
断点调试可以看到获取accessToken的方法不会再报错了,且已经获取到了用户信息
5 下篇文章预告
上面虽然解决了/signin错误,但是接下来还会报一个XXX/signup错误,也就是说我们QQ登陆的步骤仍然没有开发完。其实在不看源码的情况下,我觉得这个错误就应该可以被想到了,因为我在上篇文章《springsocial/oauth2—第三方登陆之QQ登陆4【redirect uri is illegal(100010)错误解决方式】》写完之后其实就已经想到了此问题的存在。
什么问题呢?
其实大家可以想想我们的系统里现在还没有一个用户与QQ获取到的信息进行关联过,那springsocial/springsecurity怎么拿着获取到的用户信息,与我们业务表里的用户信息进行关联并进行认证校验呢?
下篇文章将着力去解决该问题!!!
6 小希望
现在公司已经开始备战双11了,我所在的小组最近加班很多,导致写博客的时间几乎没有了,希望这种情况会在接下来有所好转,也希望自己可以顶住压力,更好地提升自己!!!