接着上文,谈到了表单登录和手机验证码登录,有时候我们需要使用第三方登录,那么这个和 SS 如何结合呢?
OAth
OAuth简介
我们为了使用 QQ、微信、微博用户名和密码来获取用户,简单的说可以使用用户的用户名和密码进入第三方系统,然后抓取信息。泄漏自私的账号密码这样的事用户肯定不干,那么如何让用户既不泄漏自己的密码又能把自己的第三方账号的某些信息提供到我们系统呢?这就得使用 OAuth,他使用的是一个 Token 来进行访问第三方系统,第三方提供这个 Token 只能提供部分服务,而且这个 Token 是有时效性的,在使用 Token 之前也是需要用户确认的。
OAuth 的角色和术语
1、Provider:服务提供商,象 QQ、微信等,他们存储了用户的信息,能够提供 Token;
2、Resource Owner:资源所有者,用户的自有数据,数据虽然存放在 QQ 等平台,但数据拥有者是用户;
3、第三方 Client:也就是想要获取QQ等提供商存储的用户信息的这些应用;
4、Authorization Server:认证服务器,认证用户身份并且产生令牌;
5、Resource Server:资源服务器,保留用户数据的地方,验证令牌;
OAuth 流程
其中第二步骤同意授权有四种实现:
授权码模式(目前使用最多)、密码模式、客户端模式、简化模式
授权码模式的流程(主流模式)
其中第二步是导向服务提供商,防止伪造同意。第四步是服务器端后台进行的。
Spring Social开发第三方登录
流程
简单说:通过供应商的账号密码信息之后获取用户基本信息,组装成我们的认证信息,完成登录。
而Spring Social是一个过滤器,完成上面的操作。
Spring Social对应的内部实现分析
ServiceProvider:相当于服务提供商的封装类;
OAuth2Operations:相当于1-5步骤的通用操作;
API:第6步个性化操作,有的提供商提供3个字段,有点提供5个字段不等;
第7步与提供商没啥关系,只与我们自己的有关,他有如下的接口和类
Connection:封装前六步获取的链接信息,具有固定信息;
ConnectionFactory:生成上面链接的工厂,而创建这些链接,就需要ServiceProvider;由于第六步的每家不同的数据,就需要把不同的数据封装成一个标准的数据,才能构造 Connection,所以还需要ApiAdapter;
另外,平台用户 A 是如何和自己系统用户张三对应上的呢,是在数据库层面有一张userConnection 表进行映射,而操作这个的表的是userConnectionRepository 这个类
QQ 登录
根据上面的依赖分析可知,需要首先构造OAuth2Operations和API相关的类。
首先来操作 QQAPI 部分
新建 QQUserInfo 类来封装 QQ 用户信息;
新建 QQ 接口,提供一个方法,返回 QQUserInfo;
新建 QQ 接口的实现 QQImpl,该类继承 AbstractOAuth2ApiBinding 和实现 QQ 接口;
查看AbstractOAuth2ApiBinding源码可以发现,需要注意是多实例对象。
接下来我们查阅下如何访问 QQ 用户信息的 API 文档
根据返回参数构建QQUserInfo属性字段,省略
然后开始编写 QQImpl 的具体实现
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ{
//获取 openID 的 URL
private static final String URL_GET_OPEN_ID="https://graph.qq.com/oauth2.0/me?access_token=%s";
//获取用户信息的 URL
//https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID
//AccessToken 参数交给父类处理,这里不用挂载
private static final String URL_GET_USER_INFO="https://graph.qq.com/user/get_user_info?&oauth_consumer_key=%s&openid=%s";
private String appId;
private String openId;
public QQImpl(String accessToken,String appId) {
//如果是一个参数的话,会把accessToken塞到请求头,我们需要的是挂载的 URL 后面,所以这里是2个参数的父构造
super(accessToken,TokenStrategy.ACCESS_TOKEN_PARAMETER);
this.appId=appId;
//openid 需要请求后才能获取
String url=String.format(URL_GET_OPEN_ID, accessToken);
String result=super.getRestTemplate().getForObject(url, String.class);
//从返回字符串里面进行截取
this.openId=StringUtils.substringBetween(result, "\"openId\":", "}");
}
@Override
public QQUserInfo getQQUserInfo() {
String url=String.format(URL_GET_USER_INFO, appId,openId);
String result=super.getRestTemplate().getForObject(url, String.class);
System.out.println(result);
ObjectMapper om=new ObjectMapper();
QQUserInfo qqUserInfo=null;
try {
qqUserInfo=om.readValue(result, QQUserInfo.class);
qqUserInfo.setOpenId(openId);
return qqUserInfo;
} catch (Exception e) {
throw new RuntimeException("获取用户失败");
}
}
}
到这里,完成了提供商的 API 操作。
接下来 OAuth2Operations 我们采用默认实现 OAuth2Template 来进行,紧跟着就开始构造 ServiceProvider 对象,新建 QQServiceProvider
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ>{
private String appId;
private static final String authorizeUrl="https://graph.qq.com/oauth2.0/authorize";
private static final String accessTokenUrl="https://graph.qq.com/oauth2.0/token";
public QQServiceProvider(String appId,String appSecret) {
//authorizeUrl 第一步的 URL
//accessTokenUrl 第四步的 URL
super(new OAuth2Template(appId, appSecret, authorizeUrl, accessTokenUrl));
}
@Override
public QQ getApi(String accessToken) {
return new QQImpl(accessToken,appId);
}
}
到这里,右边提供商方面的代码完成,接下来完成左边部分的代码。
首先看到左边包含最里面的类是一个适配器类,把 QQ 用户数据包装成 Connection 通用数据,先从这开始,构建 QQAdapter
public class QQAdapter implements ApiAdapter<QQ>{
@Override
public boolean test(QQ api) {
// 测试 QQ 服务是否可用,这里假设可用
return true;
}
@Override
public void setConnectionValues(QQ api, ConnectionValues values) {
//把提供商提供的个性化数据包装成 Connect 通用的数据格式
QQUserInfo userInfo = api.getQQUserInfo();
values.setDisplayName(userInfo.getNickname());//设置显示名称
values.setImageUrl(userInfo.getFigureurl_qq_1());//设置图像
values.setProfileUrl(null);//个人主页
values.setProviderUserId(userInfo.getOpenId());//提供商给用户的唯一标识
}
@Override
public UserProfile fetchUserProfile(QQ api) {
return null;
}
@Override
public void updateStatus(QQ api, String message) {
// 发条消息更新状态
}
}
有了 ServiceProvider 和 QQAdapter 就可以构造 Connectionfactory了。
public class QQConectionFactory extends OAuth2ConnectionFactory<QQ>{
public QQConectionFactory(String providerId, String appId,String appSecret) {
super(providerId, new QQServiceProvider(appId, appSecret), new QQAdapter());
}
}
有了ConectionFactory就有了 Connection,然后需要UsersConnectionRepository,这个可以使用默认的 JdbcUsersConnectionRepository,只需要配置即可
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter{
@Autowired private DataSource dataSource;
/**
*
* 需要预先建立表
* create table UserConnection (userId varchar(255) not null,
providerId varchar(255) not null,
providerUserId varchar(255),
rank int not null,
displayName varchar(255),
profileUrl varchar(512),
imageUrl varchar(512),
accessToken varchar(512) not null,
secret varchar(512),
refreshToken varchar(512),
expireTime bigint,
primary key (userId, providerId, providerUserId));
create unique index UserConnectionRank on UserConnection(userId, providerId, rank);
*
*/
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
//参数1是数据源
//参数2是链接工厂,可能有多个(QQ、微信),会找到自己需要的
//参数3是加密形式,这些数据敏感
return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
}
}
以此同时,我们的UserDetailService 也需要增加社交登录实现
@Component("userDetailsService")
public class CustomerUserDetailService implements UserDetailsService,SocialUserDetailsService{
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名去数据库查询用户信息
//可以注入 jdbc,mybatis 等 DAO
//这里方便演示,直接在代码里面做了
System.out.println("=======表单登录========="+username);
//User 对象已经实现了UserDetails
//AuthorityUtils.commaSeparatedStringToAuthorityList 方法是以逗号分割产生一个授权集合
User user=new User(username,
passwordEncoder.encode("123456"), //其实是 DB 存的加密密码
true,//账号可用
true,//账号不过期
true,//密码不过期
true,//账号没有锁定
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
return user;
}
@Override
public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
System.out.println("======社交用户登录=========="+userId);
SocialUser user=new SocialUser(userId,
passwordEncoder.encode("123456"), //其实是 DB 存的加密密码
true,//账号可用
true,//账号不过期
true,//密码不过期
true,//账号没有锁定
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
return user;
}
}
我们还需要配置一些 QQ 登录的基本信息,新建 QQProperties
public class QQProperties extends SocialProperties{
private String providerId="qq";
public String getProviderId() {
return providerId;
}
public void setProviderId(String providerId) {
this.providerId = providerId;
}
}
并且把QQProperties设置到总的配置类中,以便调用。
有了这个配置,我们就可以构造 QQConnectionFactory 了,为了得到这个工厂,我们需要新建一个配置类
@Configuration
@ConditionalOnProperty("cn.ts.qq")//希望有配置才起作用
public class QQConfig extends SocialAutoConfigurerAdapter{
@Autowired private SecurityProperties securityProperties;
@Override
protected ConnectionFactory<?> createConnectionFactory() {
return new QQConectionFactory(
securityProperties.getQq().getProviderId(),
securityProperties.getQq().getAppId(),
securityProperties.getQq().getAppSecret()
);
}
}
为了能够把社交登录布置到过滤器链上,需要配置一个SpringSocialConfigurer
到 http
直接在SocialConfig里面增加一个方法,返回SpringSocialConfigurer
@Bean
public SpringSocialConfigurer qqSocialConfigurer(){
return new SpringSocialConfigurer();
}
在WebSecurityConfig里面注入,并且配置到HttpSecurity http这个对象上
页面增加 QQ 登录
<h3>社交登录</h3>
<a href="/auth/qq">QQ 登录</a>
其中“/auth/”开头的请求都会被SocialAuthenticationFilter 拦截;后面的qq 是 providerId
问题
由于我们的表里面需要钱三个字段组合成复合组件,我们现在只有后2两个 ID,没有我们自己系统的 ID,这就需要引导用户到注册界面。童年故事我们查看SocialAuthenticationProvider
源码也发现
首先验证是不是SocialAuthenticationToken,然后通过他拿到 Connection,再通过 Connection 拿到userId,然后根据 userId 获取UserDetails,最后重新构造一个新的SocialAuthenticationToken返回。
我们注意到,一旦userId 为空,就会报出BadCredentialsException异常,而处理这个异常的过滤器是SocialAuthenticationFilter,可以查看这个源码。
这个异常被捕获之后的处理逻辑如上代码,可以发现,需要配置一个注册入口。
新建一个注册页面,配置 properties 属性,配置权限入口
为了更好的体验,需要在注册页面显示 QQ 用户信息,或者用户注册后,需要绑定相应的信息,需要一个工具类ProviderSignInUtils,该工具类构造方法需要两个参数
public ProviderSignInUtils( ConnectionFactoryLocator connectionFactoryLocator,UsersConnectionRepository connectionRepository) {
this(new HttpSessionSessionStrategy(),connectionFactoryLocator,connectionRepository);
}
第一个参数,SpringBoot 已经给我们注册好了,直接注入使用,第二可以在 SocialConfig 里面看到
所以直接在这个类里面构造一个 Bean,作为其他的地方的注入即可
整个流程如下图