上一篇文章《Spring Security技术栈开发企业级认证与授权(十三)Spring Social集成第三方登录验证开发流程介绍》主要是介绍了
OAuth2
协议的基本内容以及Spring Social
集成第三方登录验证的基本流程。那么在前篇文章的基础上,我们在本篇文章中将介绍Spring Social
集成
我们继续将上一篇文章的图贴到这里,对着图片开发相应的模块。
在前一篇文章中介绍到,Spring Social
封装了OAuth
协议的标准步骤,我们只需要配置第三方应用的认证服务器地址即可,就可以获取到访问令牌Access Token
,拿着这个令牌就可以获取到用户信息了,QQ
互联的文档中介绍到,要正确获取到用户的基础信息之前,还需要通过Access Token
来获取到用户的OpenID
,这个OpenID
是每一个用户使用QQ
登录到你的系统都会产生一个唯一的ID
。如下图所示:
要获取到OpenID
, 需要访问下面的API
地址,带上正确的access_token
参数即可。
内容 | 说明 |
---|---|
请求URL | https://graph.qq.com/oauth2.0/me |
请求方法 | GET |
请求参数 | access_token |
返回内容 | callback( {“client_id”:“YOUR_APPID”,“openid”:“YOUR_OPENID”} ); |
正确访问API
,拿到返回内容之后,可以对内容进行解析,获取到OpenID
,然后再访问获取用户信息的接口,携带必需的参数,从而拿到用户的信息。获取用户信息,相关说明如下表所以:
内容 | 说明 |
---|---|
请求URL | https://graph.qq.com/user/get_user_info |
请求方法 | GET |
请求参数 | access_token=ACCESS_TOKEN&oauth_consumer_key=APP_ID&openid=OPENID |
返回内容 | 返回内容是JSON格式的字符串,具体字段和说明如下表所示 |
获取用户信息JSON
返回体说明:
参数说明 | 描述 |
---|---|
ret | 返回码 |
msg | 如果ret<0,会有相应的错误信息提示,返回数据全部用UTF-8编码 |
is_lost | 是否丢失,0否,1是 |
nickname | 用户在QQ空间的昵称 |
figureurl | 大小为30×30像素的QQ空间头像URL |
figureurl_1 | 大小为50×50像素的QQ空间头像URL |
figureurl_2 | 大小为100×100像素的QQ空间头像URL |
figureurl_qq_1 | 大小为40×40像素的QQ头像URL |
figureurl_qq_2 | 大小为100×100像素的QQ头像URL |
gender | 性别。 如果获取不到则默认返回"男" |
province | 省份 |
city | 城市 |
year | 出生年月 |
constellation | 星座 |
is_yellow_vip | 是否是黄钻,0否,1是 |
vip | 是否是QQ会员,0否,1是 |
yellow_vip_level | 黄钻等级 |
level | QQ等级 |
is_yellow_year_vip | 是否是黄钻年费会员,0否,1是 |
那么错误的返回体就很简单: { "ret":1002, "msg":"请先登录" }
。
那么这一些操作我们该如何在代码中体现呢?先来写一个获取用户信息的接口QQ
,代码如下:
package com.lemon.security.core.social.qq.api;
/**
* 获取QQ用户信息的接口
*
* @author jiangpingping
* @date 2019-02-05 11:30
*/
public interface QQ {
/**
* 获取QQ用户的信息
*
* @return QQ用户信息
*/
QQUserInfo getUserInfo();
}
其中实体类QQUserInfo
则是封装了从腾讯服务器获取到的用户基础信息,具体的代码如下所示:
package com.lemon.security.core.social.qq.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;
/**
* QQ用户信息
*
* @author jiangpingping
* @date 2019-02-05 11:32
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
public class QQUserInfo {
/**
* 用户的OpenId
*/
private String openId;
/**
* 返回码
*/
private Integer ret;
/**
* 返回消息,如果ret<0,会有相应的错误信息提示,返回数据全部用UTF-8编码
*/
private String msg;
/**
* 是否丢失0否,1是
*/
@JsonProperty("is_lost")
private Integer isLost;
/**
* 用户在QQ空间的昵称
*/
private String nickname;
/**
* 大小为30×30像素的QQ空间头像URL
*/
@JsonProperty("figureurl")
private String figureUrl30;
/**
* 大小为50×50像素的QQ空间头像URL
*/
@JsonProperty("figureurl_1")
private String figureUrl50;
/**
* 大小为100×100像素的QQ空间头像URL
*/
@JsonProperty("figureurl_2")
private String figureUrl100;
/**
* 大小为40×40像素的QQ头像URL
*/
@JsonProperty("figureurl_qq_1")
private String figureUrlQq40;
/**
* 大小为100×100像素的QQ头像URL。需要注意,不是所有的用户都拥有QQ的100x100的头像,但40x40像素则是一定会有
*/
@JsonProperty("figureurl_qq_2")
private String figureUrlQq100;
/**
* 性别。 如果获取不到则默认返回"男"
*/
private String gender;
/**
* 省份
*/
private String province;
/**
* 城市
*/
private String city;
/**
* 出生年份
*/
private String year;
/**
* 星座
*/
private String constellation;
/**
* 是否是黄钻,0否,1是
*/
@JsonProperty("is_yellow_vip")
private String isYellowVip;
/**
* 是否是会员,0否,1是
*/
private String vip;
/**
* 黄钻等级
*/
@JsonProperty("yellow_vip_level")
private String yellowVipLevel;
/**
* 等级
*/
private String level;
/**
* 是否是黄钻年费VIP,0否,1是
*/
@JsonProperty("is_yellow_year_vip")
private String isYellowYearVip;
}
上面的代码中,使用Jackson
将JSON
字符串序列化为QQUserInfo
实例对象的时候,将带有下划线的字段值映射到了对应的驼峰字段上,使用的Jackson
的@JsonProperty
注解来完成的。有了接口和实体类,我们自然需要写一个实现类,具体的信息获取代码都在实现类中。
package com.lemon.security.core.social.qq.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.social.oauth2.AbstractOAuth2ApiBinding;
import org.springframework.social.oauth2.TokenStrategy;
import java.io.IOException;
/**
* 获取QQ用户信息的实现类
*
* @author jiangpingping
* @date 2019-02-05 11:34
*/
@Slf4j
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ {
/**
* Open ID的获取链接,它需要传递令牌,也就是OAuth协议的前五步获取到的数据访问令牌
*/
private static final String URL_GET_OPEN_ID = "https://graph.qq.com/oauth2.0/me?access_token=%s";
/**
* 获取用户信息的链接:https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID
* 其中,access_token会被父类AbstractOAuth2ApiBinding处理,在请求之前,会被拼接到请求链接中,故这里删除即可
*/
private static final String URL_GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";
/**
* appId是腾讯要求的应用ID,需要开发者去QQ互联上申请,对应的参数字段是oauth_consumer_key
*/
private String appId;
/**
* openId是腾讯对应用和用户之间的关系管理的一个参数,用户在一个应用的openID唯一
*/
private String openId;
private ObjectMapper objectMapper = new ObjectMapper();
public QQImpl(String accessToken, String appId) {
// 这里的父类构造方法传入两个参数,第二个参数的意思是在构造方法中构建restTemplate的时候,将accessToken作为请求参数集成到请求链接中
// 父类的默认构造也就是一个参数的构造,默认行为是将参数放到了请求头中,这个就和QQ的API接口要求的传参方式不一样了
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
this.appId = appId;
// 获取openId
String url = String.format(URL_GET_OPEN_ID, accessToken);
String result = getRestTemplate().getForObject(url, String.class);
// 返回的数据结构体为:callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
this.openId = StringUtils.substringBetween(result, "\"openid\":\"", "\"}");
}
@Override
public QQUserInfo getUserInfo() {
String url = String.format(URL_GET_USER_INFO, appId, openId);
String result = getRestTemplate().getForObject(url, String.class);
log.info("获取到用户的信息为:{}", result);
try {
QQUserInfo userInfo = objectMapper.readValue(result, QQUserInfo.class);
// 这里需要将openId存储到userInfo中
userInfo.setOpenId(openId);
log.info("封装后的UserInfo为:{}", userInfo);
return userInfo;
} catch (IOException e) {
e.printStackTrace();
log.error("转换QQ用户信息失败:{}", e.getMessage());
throw new RuntimeException(e);
}
}
}
QQImpl
类中的注释写的很详细,读者一看就明白。这里还重点说明三点:
QQImpl
继承了AbstractOAuth2ApiBinding
,这在上一篇文章中也介绍了AbstractOAuth2ApiBinding
帮助我们完成了一些基础操作,方便我们快速开发。QQImpl
的构造方法中调用了父类AbstractOAuth2ApiBinding
的两个参数的构造方法,在父类的构造方法中,我们将第二个参数设置为TokenStrategy.ACCESS_TOKEN_PARAMETER
,这样在父类的构造方法中构建RestTemplate
对象的时候,就会将accessToken
放到请求参数中,如果调用一个参数的父类构造方法,那么它默认的行为是将accessToken
放到请求头中,这就和QQ
互联要求的请求方式不一样了。QQImpl
标注为Spring Bean
,这是因为Spring Bean
是单例的,这里的每一个用户应该对应一个QQImpl
对象。当用户选择QQ
登录的时候,就会去创建一个QQImpl
对象,在调用构造方法的时候,就会去事先设定好的链接获取该用户在应用中唯一的OpenID
,拿到OpenID
后就会调用getUserInfo
方法来获取用户信息。开发完获取用户的QQ
信息的接口后,那么接着开发QQServiceProvider
,OAuth2Operations
是不需要我们开发的,Spring Social
提供了OAuth2Template
,已经帮我们封装好了OAuth
协议规定的基础步骤,我们直接调用即可,在调用之前,需要配置好授权的URL
和获取Access Token
的URL
。
package com.lemon.security.core.social.qq.connect;
import com.lemon.security.core.social.qq.api.QQ;
import com.lemon.security.core.social.qq.api.QQImpl;
import org.springframework.social.oauth2.AbstractOAuth2ServiceProvider;
import org.springframework.social.oauth2.OAuth2Template;
/**
* QQ的Service Provider
*
* @author jiangpingping
* @date 2019-02-05 13:13
*/
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {
/**
* 引导用户授权的URL,获取授权码
*/
private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
/**
* 获取令牌的URL
*/
private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";
private String appId;
public QQServiceProvider(String appId, String appSecret) {
// 使用Spring Social的默认的OAuth2Template
super(new OAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN));
this.appId = appId;
}
@Override
public QQ getApi(String accessToken) {
return new QQImpl(accessToken, appId);
}
}
QQServiceProvider
的代码编写还是很简单的,AbstractOAuth2ServiceProvider
用到的泛型是API
的接口类型,在这里配置了授权的URL
和获取Access Token
的URL
,然后调用AbstractOAuth2ServiceProvider
的构造方法就可以获得了Access Token
的值,OAuth
协议中规定的参数传递等步骤都由Spring Social
提供的OAuth2Template
来完成了。也许你有一个疑问,在OAuth
协议中,在获取授权和获取Access Token
的时候都会设置一个参数redirect_uri
,但是我们并没有设置这个参数啊?Spring Social
是如何帮助我们设置的呢?这里暂时不回答这个问题,请接着往下阅读,后面将会为您解释这个参数设置问题。至此,我们已经开发完了与第三方服务提供商相关的代码,也就是第一幅图的最右边需要的代码。
从上一篇文章可知,Connection
是一个接口,它有一个实现类OAuth2Connection
,该实现类中封装了与用户相关的信息,这些信息,比如DisplayName
(显示名称),ProfileUrl
(主页地址),ImageUrl
(头像地址)等基本信息,这些信息是Spring Social
所规定的用户信息(固定字段),我们现在要做的就是将拿到的用户信息转换成OAuth2Connection
所封装的用户信息。生成Connection
实现类对象需要用到ConnectionFactory
工厂,而创建ConnectionFactory
对象就需要用到我们开发的QQServiceProvider
,还有一个ApiAdapter
实现类对象,前者我们已经开发好了,那么现在就需要开发ApiAdapter
的实现类,从ApiAdapter
这个名称可以看出,它就是一个适配器,负责将从第三方应用拿到的用户基础数据转换成OAuth2Connection
的封装的数据,但是进入ApiAdapter
的源码看到,我们并不是直接将数据转换成OAuth2Connection
封装的属性值,而是设置到ConnectionValues
中,后期的转换工作交给Spring Social
来完成。分析到这里,我们可以开始编写ApiAdapter
实现类的代码了,具体代码如下所示:
package com.lemon.security.core.social.qq.connect;
import com.lemon.security.core.social.qq.api.QQ;
import com.lemon.security.core.social.qq.api.QQUserInfo;
import org.springframework.social.connect.ApiAdapter;
import org.springframework.social.connect.ConnectionValues;
import org.springframework.social.connect.UserProfile;
/**
* @author jiangpingping
* @date 2019-02-05 15:05
*/
public class QQAdapter implements ApiAdapter<QQ> {
/**
* 这个方法用来判断QQ服务是否可用
*
* @param api API接口
* @return 是否可用
*/
@Override
public boolean test(QQ api) {
return true;
}
/**
* 将API中获取到的用户信息转换成创建Connection所需的值
*
* @param api 用户信息获取API
* @param values 创建Connection所需的值
*/
@Override
public void setConnectionValues(QQ api, ConnectionValues values) {
QQUserInfo userInfo = api.getUserInfo();
values.setDisplayName(userInfo.getNickname());
values.setImageUrl(userInfo.getFigureUrlQq40());
// QQ用户信息接口没有主页这个值
values.setProfileUrl(null);
values.setProviderUserId(userInfo.getOpenId());
}
@Override
public UserProfile fetchUserProfile(QQ api) {
return null;
}
@Override
public void updateStatus(QQ api, String message) {
}
}
这里主要是编写了setConnectionValues
方法的代码,将从QQ
获取到的数据封装到了ConnectionValues
中。现在有了QQServiceProvider
和QQAdapter
,那么就可以来开发ConnectionFactory
的实现类了,这里贴出代码:
package com.lemon.security.core.social.qq.connect;
import com.lemon.security.core.social.qq.api.QQ;
import org.springframework.social.connect.support.OAuth2ConnectionFactory;
/**
* @author jiangpingping
* @date 2019-02-05 17:15
*/
public class QQConnectionFactory extends OAuth2ConnectionFactory<QQ> {
/**
* QQ Connection Factory的构造方法
*
* @param providerId 第三方服务提供商的ID,如facebook,qq,wechat
* @param appId 第三方服务提供商给予的应用ID
* @param appSecret 第三方服务提供商给予的应用Secret
*/
public QQConnectionFactory(String providerId, String appId, String appSecret) {
super(providerId, new QQServiceProvider(appId, appSecret), new QQAdapter());
}
}
写到这里,主要的内容算是写完了,其中UsersConnectionRepository
这一块内容封装了对UserConnection
表的基础操作,是不需要我们开发的,我们要做的就是将JdbcUsersConnectionRepository
配置进来即可,主要代码如下:
package com.lemon.security.core.social;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.social.config.annotation.EnableSocial;
import org.springframework.social.config.annotation.SocialConfigurerAdapter;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.UsersConnectionRepository;
import org.springframework.social.connect.jdbc.JdbcUsersConnectionRepository;
import org.springframework.social.security.SpringSocialConfigurer;
import javax.sql.DataSource;
/**
* 社交配置类
*
* @author jiangpingping
* @date 2019-02-05 17:23
*/
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
private final DataSource dataSource;
@Autowired
public SocialConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
// 创建一个JDBC连接仓库,需要dataSource、connectionFactory加载器,对存到数据库中的加密策略,这里选择不做加密,信息原样存入数据库
// 这里创建的JdbcUsersConnectionRepository可以设置UserConnection表的前缀
return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
}
@Bean
public SpringSocialConfigurer lemonSocialSecurityConfig() {
return new SpringSocialConfigurer();
}
}
这里使用注解@EnableSocial
启用社交登录,并配置了JdbcUsersConnectionRepository
,代码中Encryptors.noOpText()
表示将用户信息以明文的方式存储到数据库中,也可以以加密的方式进行存储。并将SpringSocialConfigurer
的实例对象交给了Spring
来管理。最后将SpringSocialConfigurer
的对象注入到了BrowserSecurityConfig
中,并apply
到配置代码中(详情请关注码云上的代码chapter014),如下所示:
@Autowired
private SpringSocialConfigurer lemonSocialSecurityConfig;
http.apply(lemonSocialSecurityConfig);
现在需要写一些基础配置类,比如appId
、appSecret
以及providerId
等,这些内容必须支持开发者自定义,因为每个开发者的appId
、appSecret
肯定是不一样的,providerId
可以提供一个默认值,但是也得提供一个可配置的值。接下来写配置方面的内容。
我们开发一个配置类来接收来自配置文件中的值,定义配置类名称为QQProperties
,该类继承SocialProperties
,在SocialProperties
中,已经存在了appId
和appSecret
,QQProperties
继承了SocialProperties
,就相当于已经有了appId
和appSecret
两个属性,再添加一个providerId
属性即可,且设置默认值为qq
,代码如下:
package com.lemon.security.core.properties;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.autoconfigure.social.SocialProperties;
/**
* @author jiangpingping
* @date 2019-02-05 17:56
*/
@Getter
@Setter
public class QQProperties extends SocialProperties {
private String providerId = "qq";
}
由于我们当前开发的仅仅是QQ
登录,后面还会开发微信登录,这两者都是属于第三方登录,所以我们再封装一层属性,写一个SocialProperties
类,代码如下:
package com.lemon.security.core.properties;
import lombok.Getter;
import lombok.Setter;
/**
* @author jiangpingping
* @date 2019-02-05 17:59
*/
@Getter
@Setter
public class SocialProperties {
private QQProperties qq = new QQProperties();
}
然后再将代码private SocialProperties social = new SocialProperties();
加入到SecurityProperties
中,完整代码如下:
package com.lemon.security.core.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author lemon
* @date 2018/4/5 下午3:08
*/
@Data
@ConfigurationProperties(prefix = "com.lemon.security")
public class SecurityProperties {
private BrowserProperties browser = new BrowserProperties();
private ValidateCodeProperties code = new ValidateCodeProperties();
private SocialProperties social = new SocialProperties();
}
这样设置以后,我们就可以在application.properties
中设置appId
、appSecret
以及providerId
了,例如:
com.lemon.security.social.qq.appId=xxxxxx
com.lemon.security.social.qq.appSecret=xxxxxx
com.lemon.security.social.qq.providerId=xxxxxx
以上最后一个字段名称appId
可以替换为app-id
,appSecret
和providerId
同理,Spring
读取配置文件是支持横杠转换为驼峰形式的参数。
我们还需要写一个自动配置类,当检测到用户在application.properties
中配置了属性com.lemon.security.social.qq.appId
后,就应该将QQConnectionFactory
实例化,并交给Spring
来管理。也就是说,只要开发者开发的系统中配置了属性com.lemon.security.social.qq.appId
后,说明该系统就支持QQ
登录,那么就应该实例化QQConnectionFactory
,且该工厂类是单例的,负责创建与用户信息相关的Connection
。自动配置类的代码如下所示:
package com.lemon.security.core.social.qq.config;
import com.lemon.security.core.properties.QQProperties;
import com.lemon.security.core.properties.SecurityProperties;
import com.lemon.security.core.social.qq.connect.QQConnectionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.social.SocialAutoConfigurerAdapter;
import org.springframework.context.annotation.Configuration;
import org.springframework.social.connect.ConnectionFactory;
/**
* @author jiangpingping
* @date 2019-02-05 18:03
*/
@Configuration
@ConditionalOnProperty(prefix = "com.lemon.security.social.qq", name = "app-id")
public class QQAutoConfiguration extends SocialAutoConfigurerAdapter {
private final SecurityProperties securityProperties;
@Autowired
public QQAutoConfiguration(SecurityProperties securityProperties) {
this.securityProperties = securityProperties;
}
@Override
protected ConnectionFactory<?> createConnectionFactory() {
QQProperties qqProperties = securityProperties.getSocial().getQq();
return new QQConnectionFactory(qqProperties.getProviderId(), qqProperties.getAppId(), qqProperties.getAppSecret());
}
}
自动配置类写完了,整体的代码算是基本完成了。我们现在在lemon-security-browser
项目中的默认登录页面后面加上QQ
登录,页面代码如下:
<h2>社交登录</h2>
<!-- /auth是类SocialAuthenticationFilter规定的,/qq是providerId -->
<a href="/auth/qq"><img src="http://qzonestyle.gtimg.cn/qzone/vas/opensns/res/img/Connect_logo_3.png"></a>
页面显示的效果图如下:
这里的QQ
登录按钮地址为什么是/auth/qq
?这是因为Spring Social
对社交登录的拦截地址做了默认值,它拦截的请求地址就是/auth
,而后面的/qq
则是providerId
,这是默认规则。具体的默认定义可以去看Spring Social
的类SocialAuthenticationFilter
,它源代码最底部有一个常量DEFAULT_FILTER_PROCESSES_URL
,它的值就是/auth
,也就是说该拦截器会拦截/auth
的请求,并对其进行验证。现在我们启动项目,来验证一下QQ
登录的功能是否完善。我们在8080
端口启动demo
项目,然后直接访问默认的登录页面,并点击QQ
登录,我们跳转到了QQ
登录授权页面,如下所示:
我们发现回调地址是非法的,我们仔细观察地址栏的链接,我把它拷贝到这里:
https://graph.qq.com/oauth2.0/show?which=error&display=pc&error=100010&client_id=101547587&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth%2Fqq&state=e567fd76-6b53-4572-84e5-8a0e93defb47
从上面的地址可以看出来,redirect_uri
参数我们在之前并没有设置,这里很明显是Spring Social
帮助我们完成了这部分操作,这也就回答了之前遗留下来为什么不用我们自己设置redirect_uri
参数的问题。现在一起来分析一下这个redirect_uri
参数,它的值如下所示:
http%3A%2F%2Flocalhost%3A8080%2Fauth%2Fqq
这里的回调地址是经过编码后的地址,还原后就是:
http://localhost:8080/auth/qq
这地址不就是我们设置的QQ
登录的地址吗?对的,回调地址就是这个QQ
登录地址。但是为什么会出现这种“回调地址非法”
的问题呢?原因是因为回调地址和我们在QQ
互联平台上创建的应用的时候设置的回调地址不一致导致的,我在开发这一块的时候,设置的回调地址是http://www.itlemon.cn/auth/qq
,两者是不一致的,所以就会提示回调地址非法,由于我设置的http协议的回调地址,所以默认访问的是应用所在服务器的80
端口,所以我们需要将demo
项目的启动端口改成80
端口,然后再借助软件switchhosts
将本地www.itlemon.cn
指向127.0.0.1
,这样的话,访问http://www.itlemon.cn
就会映射到本地的应用上来,准备工作做好以后,我们再次启动项目,访问登录页面http://www.itlemon.cn/login.html
,点击QQ
登录,跳转页面如下图所示:
这就说明正确地到达了QQ
登录授权页面了,扫码就可以进行登录操作了。我现在扫码来授权一下,看看接下来会发生什么,扫码后如下图所示:
我明明授权了,为什么不是直接展示用户认证信息,而是出现这种未授权的信息呢?还有一个问题,那就是社交登录默认拦截的是/auth
,providerId
也默认是qq
,我该如何来实现自定义社交登录拦截地址呢?那么接下来我们一起来解决这两个问题。
首先解决自定义配置社交登录拦截路径的问题,我们在配置类SocialConfig
中实例化了一个SpringSocialConfigurer
的Spring Bean
,在这个Bean
中直接返回的是SpringSocialConfigurer
的实例对象,在这个类的configure
方法中,如下所示:
@Override
public void configure(HttpSecurity http) throws Exception {
ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
UsersConnectionRepository usersConnectionRepository = getDependency(applicationContext, UsersConnectionRepository.class);
SocialAuthenticationServiceLocator authServiceLocator = getDependency(applicationContext, SocialAuthenticationServiceLocator.class);
SocialUserDetailsService socialUsersDetailsService = getDependency(applicationContext, SocialUserDetailsService.class);
SocialAuthenticationFilter filter = new SocialAuthenticationFilter(
http.getSharedObject(AuthenticationManager.class),
userIdSource != null ? userIdSource : new AuthenticationNameUserIdSource(),
usersConnectionRepository,
authServiceLocator);
RememberMeServices rememberMe = http.getSharedObject(RememberMeServices.class);
if (rememberMe != null) {
filter.setRememberMeServices(rememberMe);
}
if (postLoginUrl != null) {
filter.setPostLoginUrl(postLoginUrl);
filter.setAlwaysUsePostLoginUrl(alwaysUsePostLoginUrl);
}
if (postFailureUrl != null) {
filter.setPostFailureUrl(postFailureUrl);
}
if (signupUrl != null) {
filter.setSignupUrl(signupUrl);
}
if (connectionAddedRedirectUrl != null) {
filter.setConnectionAddedRedirectUrl(connectionAddedRedirectUrl);
}
if (defaultFailureUrl != null) {
filter.setDefaultFailureUrl(defaultFailureUrl);
}
http.authenticationProvider(
new SocialAuthenticationProvider(usersConnectionRepository, socialUsersDetailsService))
.addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class);
}
在这个方法中,首先创建了一个SocialAuthenticationFilter
对象,最后将其加到了AbstractPreAuthenticatedProcessingFilter
这个过滤器之前,在加入之前,调用了postProcess
方法,而这个postProcess
方法是可以被覆盖掉的,在这里我们可以对SocialAuthenticationFilter
进行个性化处理,在个性化处理的过程中将社交登录的拦截路径设置到其中,我们在项目lemon-security-core的social
包下开发一个配置类,来覆盖一下postProcess
方法,代码如下:
package com.lemon.security.core.social;
import lombok.AllArgsConstructor;
import org.springframework.social.security.SocialAuthenticationFilter;
import org.springframework.social.security.SpringSocialConfigurer;
/**
* 配置社交登录的拦截路径
*
* @author jiangpingping
* @date 2019-02-12 19:33
*/
@AllArgsConstructor
public class LemonSpringSocialConfigurer extends SpringSocialConfigurer {
private String filterProcessesUrl;
@Override
@SuppressWarnings("unchecked")
protected <T> T postProcess(T object) {
// 获取父类的处理结果
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
filter.setFilterProcessesUrl(filterProcessesUrl);
return (T) filter;
}
}
写完这个代码以后,我们在SocialConfig
类中就不能在实例化SpringSocialConfigurer
了,而是要实例化我们自己写的那个LemonSpringSocialConfigurer
类了,在实例化之前,需要修改一些配置,SocialProperties
类修改后代码如下:
package com.lemon.security.core.properties;
import lombok.Getter;
import lombok.Setter;
/**
* @author jiangpingping
* @date 2019-02-05 17:59
*/
@Getter
@Setter
public class SocialProperties {
/**
* 这个属性是为了设置自定义社交登录拦截路径的
*/
private String filterProcessesUrl = "/auth";
private QQProperties qq = new QQProperties();
}
那么修改后的SocialConfig
类如下所示:
package com.lemon.security.core.social;
import com.lemon.security.core.properties.SecurityProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.social.config.annotation.EnableSocial;
import org.springframework.social.config.annotation.SocialConfigurerAdapter;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.UsersConnectionRepository;
import org.springframework.social.connect.jdbc.JdbcUsersConnectionRepository;
import org.springframework.social.security.SpringSocialConfigurer;
import javax.sql.DataSource;
/**
* 社交配置类
*
* @author jiangpingping
* @date 2019-02-05 17:23
*/
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
private final DataSource dataSource;
private final SecurityProperties securityProperties;
@Autowired
public SocialConfig(DataSource dataSource, SecurityProperties securityProperties) {
this.dataSource = dataSource;
this.securityProperties = securityProperties;
}
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
// 创建一个JDBC连接仓库,需要dataSource、connectionFactory加载器,对存到数据库中的加密策略,这里选择不做加密,信息原样存入数据库
// 这里创建的JdbcUsersConnectionRepository可以设置UserConnection表的前缀
return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
}
@Bean
public SpringSocialConfigurer lemonSocialSecurityConfig() {
String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
return new LemonSpringSocialConfigurer(filterProcessesUrl);
}
}
到这里,我们就解决了不能自定义拦截社交登录的路径问题了,但是要注意的是,当我们没有使用默认的/auth
拦截路径的时候,在配置文件中配置的路径一定要和在QQ
互联网站上创建的应用配置的回调地址一致,否则还会被提示“回调地址非法”
的错误。在这里,我把QQ
互联上登记的应用的回调地址改成了http://www.itlemon.cn/authentication/qq
,所以我需要在demo项目中添加一个配置com.lemon.security.social.filterProcessesUrl=/authentication
,并且将默认的登录页面QQ
登录按钮地址改成了/authentication/qq
。
使用手机授权登录以后,为什么会出现这个提示:
我们查看日志可以知道,我们在手机上点击登录以后,页面自动跳转到http://www.itlemon.cn/signin
这个链接上,因为我们没有对这个链接进行任何配置,所以默认需要认证后才可以访问,但是我们刚刚QQ登录就是一个授权登录行为,但是授权后却没有进入到系统中,还被系统拦截要求登录认证,这就说明在走OAuth认证过程中出现了问题,然后默认跳转到这个链接上进行重新认证,所以就出现了需要身份认证的提示。但是为什么会自动跳转到/signin
这个链接上呢?这就需要我们到Spring Social
的相关源码中找原因,在找原因之前,我们一起来分析一下Spring Social
集成QQ
登录的主要流程,熟悉流程之后,找原因也就方便很多了,这里贴出流程图如下所示:
类似于用户名密码、手机登录,这里的QQ
登录的核心原理是一模一样的,只是多了一点OAuth
的流程,分步骤讲解如下。
QQ
登录按钮的时候,链接/authentication/qq
会被SocialAuthenticationFilter
所拦截,该过滤器的内部获取了一个SocialAuthenticationService
实现类对象,默认是OAuth2AuthenticationService
,它会调用我们自己写的QQConnectionFactory
,而QQConnectionFactory
里有QQServiceProvider
,QQServiceProvider
里有OAuth2Template
来帮助我们完成OAuth
的基础步骤并拿到QQ
用户数据。Connection
以后,就会拿着这个Connection
数据来封装一个SocialAuthenticationToken
对象,并将这个对象标记为“未认证”
。SocialAuthenticationToken
传递到了AuthenticationManager
中,AuthenticationManager
会根据传入的Token
类型找到合适的AuthenticationProvider
来处理它,这里就会找到SocialAuthenticationProvider
来处理它,而SocialAuthenticationProvider
就会调用UserConnectionRepository
来从业务系统的数据库中来查找业务系统的用户。UserConnectionRepository
调用我们自己写的UserDetailService
的实现类(这里的实现类由于加入了第三方登录,已经进行了简单修改,这里不做介绍,读者可以看案例中的代码)来完成的,找到用户以后(找不到的情况待会详细说明,这里仅仅假设可以找到业务系统中的用户),将封装成SocialUserDetails
,并设置为“已认证”
,将认证结果存储到SecurityContext
中。这就是Spring Social
使用第三方服务提供商存储的用户信息进行认证的一个核心原理,和使用用户名和密码的方式唯一的区别是,用户名密码认证的数据来源是用户填写的登录表单,而QQ
登录的数据则来源于QQ
服务器,其他的核心步骤都是一模一样的。后面讲解的微信登录原理也是一样的。
分析完了Spring Social
开发第三方登录的原理以后,我们在源码中打断点,来找一下究竟是在认证过程中走OAuth
步骤中的哪一步出现了问题,导致链接跳转到了http://www.itlemon.cn/signin
上。我们依次在上图中的各个类或者接口的实现类的关键步骤上打断点,我们依次打断点,而不是一次性打完,我们跟着代码走,然后一步一步打断点。
我们进入到类SocialAuthenticationFilter
中,然后在其attemptAuthentication
方法合适位置打断点,如下图所示:
我们来分析一下上面的代码,第一个断点出,首先根据请求判断用户是否拒绝授权,如果用户拒绝授权,那么将抛出一个异常,紧接着封装一个Authentication
实现类对象,暂时为null
,第二个断点,其内部是从一个Map
中拿到ProviderId
,所以拿到的结果是一个包含qq
的Set
集合,第三个断点是从请求中获取到ProviderId
,我们的请求链接是/authentication/qq
,所以拿到的结果也是qq
,具体里面的实现逻辑也很简答,读者跟进去一看便知。紧接着就是一个判断,判断ProviderId
是否为空,判断从请求中获取到的ProviderId
是否为空,并且两者是否包含关系,如果都满足的话,那么该请求就是一个第三方登录认证的请求。第四个断点是获取一个SocialAuthenticationService
对象,第六个断点是开始尝试走认证流程,这个断点我们需要进入到方法中看一看。
上图中第一个断点是获取Token
,这个Token
是SocialAuthenticationToken
的对象,是认证过程中的数据载体,而不是我们之前所说的访问令牌Access Token
,这一点要注意。第一个断点我们需要进入到其中进行分析。第二个断点是从SecurityContext
中获取认证信息,以用来判断是否已经认证过了,如果没有认证,将进入到第三个断点方法中进行认证,第三个断点我们也需要进入到其中进行分析。首先来分析第一个断点:
我们进入到的是类OAuth2AuthenticationService
的getAuthToken
方法,该方法首先判断请求中是否带参数code
,我们都很清楚,在OAuth2
协议中,code
参数是用户授权后才能拿到,也就说在引导用户授权之前,是没有code
参数的,用户同意授权之后,会返回code
给我们的应用,然后我们的应用拿着code
去请求第三方授权服务器换取访问令牌Access Token
(如果对协议这一块不了解的,可以查看我前一篇文章),如果我们第一次访问,那么就就有code
这个值,那么它就会抛出一个异常,捕获到异常之后将我们的请求重定向到QQ
授权页面,等用户授权后,将会重定向到我们一开始的那个/authentication/qq
上,再次被拦截后,走到这里,此时链接上是带有code
值,这个时候就会走到else if
块中,这时候,就会拿到我们的code
去申请令牌,exchangeForAccess
就是OAuth2Template
的方法,里面封装申请令牌的必要参数并发送post
请求获取令牌,拿到令牌封装的AccessGrant
对象之后,就通过ConnectionFactory
去调用QQProviderService
来创建Connection
实现类对象,最后将这个Connection
数据封装成SocialAuthenticationToken
去接着走下面的认证流程。我们从代码中分析到,当我们点击QQ
登录的时候,走到这个类的第一个if
代码块就结束了,就进入了QQ
授权页面,然后我们扫码授权之后,就走到else if
代码块继续走下面的认证流程,这个时候,就与OAuth
协议没有关系了。
我们之前分析到的问题是点击授权后跳到了http://www.itlemon.cn/signin
上,然后被Spring Security
拦截,显示没有授权,说明并没有走接下来的认证流程了,而是在走OAuth
的流程就出现了问题。好了,我们不接着往下打断点了,就暂时打到这里,我们来启动项目,扫码授权,看看到底会出现上面问题。
我们点击QQ
登录后,请求到达了这里,目前页面还没有跳到QQ
授权页面,如下图所示:
我们让代码继续走,这时候,网页已经跳转到了授权页面。我们扫码授权,然后再次被SocialAuthenticationFilter
拦截并走到getAuthToken
方法中,这次一步一步走,看看会发生什么,授权后,此时code
就带有值了,如下图所示:
我们接着往下走,直到走到拿着code
去换取Access Token
并封装AccessGrant
的时候,发现这一步发生了异常,也就是直接跳到了catch
块中,我们一起看看到底发生了什么异常:
从图中可以看出,报的错是:Could not extract response: no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]
,错误中也就是说没有找到合适的Converter
来转换从QQ
服务器返回的内容,也就是说QQ
服务器返回来的内容无法被Spring Social
来转换,那么我们来看看Spring Social
默认的转换器和QQ
返回来的内容都是什么。
我们进入到exchangeForAccess
方法中,如下图所示:
首先是封装OAuth
协议规定的参数,然后就是发送了一个POST
请求,我们继续进入到postForAccessGrant
方法中一探究竟,它的代码只有一行,如下所示:
return extractAccessGrant(getRestTemplate().postForObject(accessTokenUrl, parameters, Map.class));
它首先是获取了RestTemplate
对象,RestTemplate
都是以JSON
交互数据的,也就是说它接受的类型是application/json
类型的数据,并将接收到的数据封装到一个Map
集合中。最后从Map
中提取access_token
,scope
和refresh_token
来封装AccessGrant
对象,也就是说,Spring Social
希望返回的是一个JSON
,但QQ
服务器真正返回的确实text/html
,所以在这里转换失败了,我紧接着QQ
互联文档看看QQ
服务器返回的数据格式,如下所示:access_token=FE04******CCE2&expires_in=7776000&refresh_token=88E4******BE14
,很明显,这不是一个JSON
数据。
我们还是回到OAuth2AuthenticationService
类的getAuthToken
方法里,那么在获取Access Token
的时候发生了数据转换异常,那么就会进入到getAuthToken的catch
代码块中,那么getToken
方法就会返回null
,那么SocialAuthenticationFilter
的attemptAuthService
方法的第一行代码就返回了null
,那么整个attemptAuthService
方法就会返回null
,那么该类的attemptAuthentication
方法就会抛出SocialAuthenticationException
的异常,那么接着就会进入到AbstractAuthenticationProcessingFilter
类的doFilter
方法中,并被其catch
代码块捕获,代码块中的代码如下如所示:
我们进入到unsuccessfulAuthentication
方法中,代码如下:
上图的最后一行代码是失败处理器在处理当前请求,我们回到SocialAuthenticationFilter
类中,SocialAuthenticationFilter
类的构造方法设置了失败处理器,我们一起来看看构造方法:
从断点出可以看出,DEFAULT_FAILURE_URL
的值正是“/signin”
,这也就解释了为什么我们在QQ
授权页面扫码授权之后,跳转到了“/signin”
,这是因为我们在获取Access Token
的过程中转换数据发生了异常,然后被SocialAuthenticationFilter
类的失败处理器处理了,重定向到了“/signin”
上,这也就导致了后面我们项目拦截了该请求,出现了如下画面:
我们通过分析源码,通过打断点的方式,找到了问题的原因所在,那么我们现在开始着手解决这个问题吧。在处理之前,我们一起来看看类OAuth2Template的postForAccessGrant
方法,它代码里通过调用getRestTemplate
方法来获取了RestTemplate
对象,那么我们进入到该方法中,如下所示:
在创建RestTemplate
对象的时候,我们从代码中可以看出,该方法仅仅只添加了三个数据转换器,分别是:FormHttpMessageConverter
、FormMapHttpMessageConverter
、MappingJackson2HttpMessageConverter
。前两个只能处理application/x-www-form-urlencoded
类型的数据和multipart/form-data
类型的数据的,而第三个是处理application/json
类型的数据的,这是不符合我们要求的,那么我们需要在写一个方法来覆盖它,我们拿到从父类创建好的RestTemplate
中添加一个StringHttpMessageConverter
,该Converter
就可以处理ContentType
为text/html
的数据,因为QQ
服务器返回来的数据形式是access_token=FE04******CCE2&expires_in=7776000&refresh_token=88E4******BE14
,它并不是JSON
数据,那么我们还需要重写postForAccessGrant
方法,这样我们就可以自定义处理access_token=FE04******CCE2&expires_in=7776000&refresh_token=88E4******BE14
类型的数据了,而不是直接将QQ
服务器返回来的数据当做JSON
来处理。我们在包connect
下再写一个类QQOAuth2Template
,代码如下所示:
package com.lemon.security.core.social.qq.connect;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.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;
/**
* @author jiangpingping
* @date 2019-02-17 00:03
*/
@Slf4j
public class QQOAuth2Template extends OAuth2Template {
public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
// 因为OAuth2Template的exchangeCredentialsForAccess方法,在封装OAuth协议的时候,默认不会带上client_id和client_secret
// 也就是说默认的useParametersForClientAuthentication值为false,所以这里需要改成true
setUseParametersForClientAuthentication(true);
}
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
// 添加一个StringHttpMessageConverter,他能处理text/html类型的数据
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
return restTemplate;
}
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String responseString = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);
log.info("获取access token的响应为:{}", responseString);
// QQ服务器返回的数据类型为access_token=FE04******CCE2&expires_in=7776000&refresh_token=88E4******BE14
String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseString, "&");
// 分割数据
String accessToken = StringUtils.substringAfterLast(items[0], "=");
Long expiresIn = new Long(StringUtils.substringAfterLast(items[1], "="));
String refreshToken = StringUtils.substringAfterLast(items[2], "=");
// 封装AccessGrant对象
return new AccessGrant(accessToken, null, refreshToken, expiresIn);
}
}
上述代码写完以后,我们还需要修改一下QQServiceProvider
的部分代码,在QQServiceProvider
的构造方法中,如下所示:
public QQServiceProvider(String appId, String appSecret) {
// 使用Spring Social的默认的OAuth2Template
super(new OAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN));
this.appId = appId;
}
现在需要修改为:
public QQServiceProvider(String appId, String appSecret) {
// 不能再使用Spring Social的默认的OAuth2Template,而需要我们自定义的QQOAuth2Template
super(new QQOAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN));
this.appId = appId;
}
当然,加入了社交登录以后,我们还需要重构一下UserDetailsServiceImpl
类,这个类主要是负责从数据库读取用户信息来封装UserDetails
对象,这里修改如下所示:
package com.lemon.security.web.authentication;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.social.security.SocialUser;
import org.springframework.social.security.SocialUserDetails;
import org.springframework.social.security.SocialUserDetailsService;
import org.springframework.stereotype.Component;
/**
* @author jiangpingping
* @date 2019-02-05 17:53
*/
@Component
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService, SocialUserDetailsService {
private PasswordEncoder passwordEncoder;
public UserDetailsServiceImpl() {
this.passwordEncoder = new BCryptPasswordEncoder();
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("表单登录用户名: {}", username);
return buildUser(username);
}
@Override
public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
log.info("社交登录用户ID:{}", userId);
return buildUser(userId);
}
private SocialUserDetails buildUser(String userId) {
// 这里可以根据用户名到数据库中查询用户,获得数据库中得到的密码(这里不进行查询操作,使用固定代码)
// 在实际的开发中,存到数据库的密码不是明文的,而是经过加密的
String password = "123456";
String encodedPassword = passwordEncoder.encode(password);
log.info("加密后的密码为: {}", encodedPassword);
// 这里查询该账户是否过期,这里使用固定代码,假设没有过期
boolean accountNonExpired = true;
// 这里查询该账户被删除,假设没有被删除
boolean enabled = true;
// 这里查询该账户认证是否过期,假设没有过期
boolean credentialsNonExpired = true;
// 查询该账户是否被锁定,假设没有被锁定
boolean accountNonLocked = true;
// 关于密码的加密,应该是在创建用户的时候进行的,这里仅仅是举例模拟
return new SocialUser(userId, encodedPassword,
enabled, accountNonExpired,
credentialsNonExpired, accountNonLocked,
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
我们再次重启demo
项目,点击QQ
登录,然后扫码授权,这时候,我们发现,又发生了刚才的那种情况:
这是为什么呢?我们观察项目的控制台,发现控制台打印出来的日志提示,我们的请求再次被重定向到了http://www.itlemon.cn/signup
上,这很明显是跳转到了一个注册的链接上,这也就让我们回想起以前使用QQ
登录一个新的网站的时候,网站的大部分操作都是在我们授权之后,跳转到了一个需要我们绑定该网站账号密码或者注册的页面,那么这个问题该如何解决呢?请关注我的下一篇文章。