本篇文章带着大家在自己的系统中集成 QQ 第三方登录
不管是做什么操作,官方文档与手册绝对是最靠谱的。所以我们先来看一下 QQ 互联官网的文档
QQ 互联官网
在 QQ 互联官网上,我们发现 QQ 互联接入分为网站接入、移动应用接入、移动游戏接入,本篇文章主要针对网站接入。通过对官网的文档的阅读,我们可以总结出以下几点:
具体步骤说明
放置 QQ 登录按钮: 引导用户到 QQ 认证服务器进行认证,认证通过后会跳转到预设的回调地址上,并在 url 上携带授权码。实质上是当用户点击按钮时,引导用户跳转到 QQ 互联的认证页面。跳转认证页面时携带的参数:
获取 Access_Token : 携带上一步获取到的授权码,向 QQ 认证服务器申请 Access_Token 。如果授权码校验通过,会返回 access_token、refresh_token、expires_in。请求 Access Token 接口,参数说明:
补充说明,Access_Token 的有效期默认是3个月,过期后需要用户重新授权才能获得新的 Access_Token。访问 Access_Token 刷新接口 可以进行刷新,程序中可以采用定时任务来进行处理,参数说明:
获取用户的 OpenId: 拿到 Access_Token 之后,后面的操作可以说是一马平川了,根据上一步获取到的 Access_Token,调用 OpenId 获取接口 获取用户在 QQ 服务器上的唯一标识 (OpenId)。参数说明:
调用 OpenAPI 访问/修改用户信息: QQ 登录提供了用户信息/动态同步/日志/相册/微博等 OpenAPI ( 详见 API列表 ),网站需要将请求发送到某个具体的 OpenAPI 接口,以访问或修改用户数据。
其实到了上面第三步,就已经完成目标了 ( 第三方登录 ),但是为了让用户体验更好,在自己的系统中显示用户的头像等,可以使用第四步进一步收集用户信息。
到了这里,我们基本上对网站接入 QQ 登录的流程比较清楚了。接下来我们的问题就是如何在我们的系统中去实现这些流程。这里以 Spring 为基础来进行处理,进过一些搜索,我们可以发现 Spring 官方有一个叫做 SpringSocial 的框架,专门用来进行第三方认证的。
老规矩,先去看一下 SpringSocial 官网,在官网上,我们发现官方已经实现了 Facebook、Twitter、Linkedin 三种登录方式。可惜国内一般都用不到,在孵化箱里面有 Github、Tripit。还有很多社区项目
不过可惜的是,没有 QQ,看样子我们得自己撸一个了,可惜,可惜。这时候问题来了,怎么撸呢?继续找文档,根据 SpringSocial-Core 自己实现。进入 Sping Social Core 页面,发现有当前三个版本
可以看到是两个正式版与一个预览版,比较尴尬的时,1.1.6 GA 版的 Reference 404 打不开了…,所以决定先看下 2.0.0 M4 的 Reference,配合 Spring Social Showcase、Spring Social Quickstart、Spring Social Canvas 进行一个入门学习。
Service Provider Connect Framework
The
spring-social-core
module includes a Service Provider Connect Framework for managing connections to Software-as-a-Service (SaaS) providers such as Facebook and Twitter. This framework allows your application to establish connections between local user accounts and accounts those users have with external service providers. Once a connection is established, it can be be used to obtain a strongly-typed Java binding to the ServiceProvider’s API, giving your application the ability to invoke the API on behalf of a user.
Spring Social Core 包含 Service Provider Connect Framework (服务提供商连接框架),用来管理服务于服务之间的连接。
Service Provider 的概念在之前 OAuth2 的文章中有提及过。
举个例子,我们自己有一个 Application 叫做 MyApplication,要允许用户将他在微信上的朋友圈分享到 MyApplication 上,要支持这个功能,就需要在用户 MyApplication 账户 与用户的微信账户之间建立 Connection,一旦建立,就可以获取到用户在微信上的信息,并在 MyApplication 上使用,Spring Social Connection Framework 就是帮我们做到这个目的的。
看框架要抓住核心重点,这里的重点是 Connection,整个框架围绕它的生成与存储,起着承上启下的作用。上图中主要包含两个步骤:
上面两步解决了 Connection 的创建于持久化,但是还缺少最重要的一步,将建立的 Connection 用于我们便捷的第三方登录。SpringSocial 给我们提供了两种 Signing in with Service Provider Accounts 的方式:
因为这是 SpringSecurity 系列文章,所以选择与 SpringSecurity 联系更紧密的方案 SocialAuthenticationFilter。
SpringSocial 结合 SpringSecurity 大体流程图
SocialAuthenticationFilter 拦截指定 Url,从请求中获取对应服务商的 ProviderId,根据 ProviderId 获取 SocialAuthenticationService。
调用 SocialAuthenticationService 的 getToken 方法。该方法有两种触发场景:
拿到 SocialAuthenticationToken 后,走 SpringSecurity 的标准认证流程,通过 ProviderManger 找到 SocialAuthenticationProvider,调用其 authenticate 方法。在这个方法里面,会根据 Connection 去查找用户对应的 UserId ( 唯一标识, userId 只是一个代号 )。
拿到 UserId 后,调用 SocialUserDetailsService 的 loadUserByUserId 方法获取 UserDetails。最终使用之前获取到的 SocialAuthenticationToken 与 UserDetial 构造一个新的 SocialAuthenticationToken 返回,作为当前用户的认证凭证。
分析了这么多,最后贴一下主要的最终的实现代码,完整代码后面会放到 GitHub 上。
服务提供商,主要提供 Api 与 OAuth2Operations。
/**
* @author: hblolj
* @Date: 2019/3/18 9:20
* @Description:
* @Version:
**/
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ>{
private String appId;
// 引导用户跳转到 QQ 上认证的链接,获取到授权码
private static final String URL_AUTHORIZE ="https://graph.qq.com/oauth2.0/authorize";
// 使用授权码从 QQ 上获取 accessToken 的请求链接
private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";
/**
* @param appId QQ 互联平台上申请的应用的 AppId
* @param appSecret QQ 互联平台上申请的应用的 AppSecret
*/
public QQServiceProvider(String appId, String appSecret) {
super(new QQOAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN));
this.appId = appId;
}
@Override
public QQ getApi(String accessToken) {
return new QQImpl(accessToken, appId);
}
}
这里主要重写了 createRestTemplate 方法与 postForAccessGrant 方法,因为 QQ 互联 AccessToken 的返回结果与默认的格式有差别,所以进行重写自定义。
/**
* @author: hblolj
* @Date: 2019/3/18 14:52
* @Description:
* @Version:
**/
@Slf4j
public class QQOAuth2Template extends OAuth2Template{
public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
// 只有该值为 true 时,在使用授权码获取 accessToken 时,才会携带 client_id 与 client_secret,而 QQ 访问 accessToken 的接口是需要这两个参数的
setUseParametersForClientAuthentication(true);
}
/**
* 默认创建的 RestTemplate 不支持 html/text,继承覆盖添加,而 QQ 服务商返回是以改格式返回的,所以重写添加支持
* @return
*/
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
return restTemplate;
}
/**
* 实际上向服务商发起请求 AccessToken 的方法,包括对获取到的结果进行格式解析。
* 默认实现是从 json 中以键值对形式获取,但是 QQ 返回的是在字符串中
* eg: access_token=FE04************************CCE2&expires_in=7776000
* &refresh_token=88E4************************BE14
* 所以对该方法进行重写
* @param accessTokenUrl
* @param parameters
* @return
*/
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
String result = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);
log.info("获取 accessToken 的响应: " + result);
// 这里使用的 StringUtils 属于 commons.lang 包
// 按照 & 分隔符进行切割
String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(result, "&");
// 取出 = 号后面的字符串
String access_token = StringUtils.substringAfterLast(items[0], "=");
Long expires_in = Long.parseLong(StringUtils.substringAfterLast(items[1], "="));
String refresh_token = StringUtils.substringAfterLast(items[2], "=");
return new AccessGrant(access_token, null, refresh_token, expires_in);
}
/**
* 使用授权码获取 AccessToken 的方法,主要负责参数的拼装与设置
* 如果默认参数与服务提供商的提供的接口参数不一致,需要重写该方法
* @param authorizationCode
* @param redirectUri
* @param additionalParameters
* @return
*/
@Override
public AccessGrant exchangeForAccess(String authorizationCode, String redirectUri, MultiValueMap<String, String> additionalParameters) {
return super.exchangeForAccess(authorizationCode, redirectUri, additionalParameters);
}
}
QQ 互联提供的开发接口的抽象,目前只定义了一个 getUserInfo 接口。Api 接口的实现,官方约定名称最好为 xxxTemplate,所以这里最好为 QQTemplate,在构造方法里面获取用户的 OpenId,是因为后面调用开发接口的时候需要该参数,所以提前获取准备。
/**
* @author: hblolj
* @Date: 2019/3/16 16:11
* @Description:
* @Version:
**/
public interface QQ {
QQUserInfo getUserInfo();
}
/**
* @author: hblolj
* @Date: 2019/3/16 16:12
* @Description: Api 实现类,官方推荐 Api 实现类的命名最好为 XXXTemplate
* @Version:
**/
@Slf4j
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ{
/**
* 整体流程
* accessToken 是用户授权从 QQ 服务器中获取
* 使用 accessToken 获取用户 openId
* 然后使用用户 openId 获取用户基本信息
*/
// 根据 access_token 获取 openId
private static final String URL_GET_OPENID = "https://graph.qq.com/oauth2.0/me?access_token=%s";
private static final String URL_GET_USERINFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";
private String appId;
private String openId;
private ObjectMapper objectMapper = new ObjectMapper();
public QQImpl(String accessToken, String appId) {
// 这里父类一个参数的构造函数,会默认调用两个参数的构造函数,策略会自定选择 TokenStrategy.AUTHORIZATION_HEADER
// 将请求参数放到请求头中,但是通过 QQ 互联开放平台的文档,我们发现其要求参数需要放到参数中,所以这里手动指定下策略
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
this.appId = appId;
// 通过 accessToken 获取 openId
String url = String.format(URL_GET_OPENID, accessToken);
String result = getRestTemplate().getForObject(url, String.class);
log.info("OpenId Result: {}", result);
// eg: callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} )
String openId = StringUtils.substringBetween(result, "\"openid\":\"", "\"}");
log.info("OpenId Substring: {}", openId);
this.openId = openId;
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
@Override
public QQUserInfo getUserInfo() {
// 父类的构造函数已经帮我们将 accessToken 放在请求中
String url = String.format(URL_GET_USERINFO, appId, openId);
String result = getRestTemplate().getForObject(url, String.class);
try {
QQUserInfo userInfo = objectMapper.readValue(result, QQUserInfo.class);
userInfo.setOpenId(this.openId);
return userInfo;
} catch (IOException e) {
throw new RuntimeException("获取用户 QQ 基本信息失败!");
}
}
}
将 Api 获取到的信息适配给 Connection。
/**
* @author: hblolj
* @Date: 2019/3/18 9:29
* @Description:
* @Version:
**/
@Slf4j
public class QQAdapter implements ApiAdapter<QQ>{
/**
* 测试 Api 获取服务是否可以正常使用
* @param api
* @return
*/
@Override
public boolean test(QQ api) {
return true;
}
@Override
public void setConnectionValues(QQ api, ConnectionValues connectionValues) {
QQUserInfo userInfo = api.getUserInfo();
log.info("UserInfo: {}", userInfo.toString());
connectionValues.setDisplayName(userInfo.getNickname());
connectionValues.setImageUrl(userInfo.getFigureurl_qq_1());
// 用户个人主页地址,qq 没有个人主页,所以这里不设置
connectionValues.setProfileUrl(null);
// 用户在服务商(QQ)上的唯一标示(openId),给 Connection 设置 Key
connectionValues.setProviderUserId(userInfo.getOpenId());
}
@Override
public UserProfile fetchUserProfile(QQ api) {
// 获取用户基本信息
return null;
}
@Override
public void updateStatus(QQ api, String s) {
// 给服务商发送消息更新用户在服务商上的状态,QQ 没有改功能 do nothing
}
}
每一个服务提供商都有一个唯一的 ConnectionFactory,所以这里的 ServiceProvider 与 Adapter 都是 new。
/**
* @author: hblolj
* @Date: 2019/3/18 10:01
* @Description:
* @Version:
**/
public class QQConnectionFactory extends OAuth2ConnectionFactory<QQ>{
/**
* @param providerId 服务提供商唯一标识
* @param appId 本服务在服务提供商上注册的 appId
* @param appSecret 本服务在服务提供商上注册的 appSecret
*/
public QQConnectionFactory(String providerId, String appId, String appSecret) {
// serviceProvider 自定义服务提供商代理,获取从服务提供商获取数据的 Api 实现
// apiAdapter 数据适配器,适配从服务商获取到用户基本信息到 Connection
super(providerId, new QQServiceProvider(appId, appSecret), new QQAdapter());
}
}
自定义的 SocialConfig 与 QQAutoConfig 都是 SocialConfigurer 的实现类。他们主要完成了两个功能:
CustomSpringSocialConfigurer 的作用是将 SocialAuthenticationFilter 挂载到 SpringSecurity 的过滤器链上。
/**
* @author: hblolj
* @Date: 2019/3/18 10:10
* @Description: SocialConfigurerAdapter 需要添加 spring-social-config 依赖
* @Version:
**/
@Order(1)
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter{
@Resource(name = "socialDataSource")
private DataSource dataSource;
@Resource(name = "customSecurityProperties")
private SecurityProperties securityProperties;
@Autowired(required = false)
private ConnectionSignUp connectionSignUp;
/**
* @param connectionFactoryLocator ConnectionFactory 定位器,系统种可能存在多种 ConnectionFactory( qq、微信、微博...),
* 选择一种来生成 Connection
* @return
*/
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
// dataSource 数据源
// Encryptors.noOpText() 加密方式 - 不加密
// 默认建表 sql 与 JdbcUsersConnectionRepository 类在用一个目录下
JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
// 设置数据库表前缀,默认为 UserConnection,可以在这个基础上自定义前缀
repository.setTablePrefix("yszn_");
// 自定义默认注册逻辑,社交登录后在数据库中(按服务商类型 + 用户在服务商的唯一标识)没有找到对应的用户信息,
// 调用自定义的 ConnectionSignUp 方法为用户在系统中注册一个账号
if (connectionSignUp != null){
repository.setConnectionSignUp(connectionSignUp);
}
return repository;
}
/**
* SpringSocial 认证配置
* 将该 Config 添加到 WebSecurityConfig 中
* @return
*/
@Bean
public SpringSocialConfigurer customSocialSecurityConfig(){
// return new SpringSocialConfigurer();
CustomSpringSocialConfigurer configurer = new CustomSpringSocialConfigurer(securityProperties.getSocial().getFilterProcessesUrl());
// 配置注册页面,当找不到用户时,会跳转到该页面
configurer.signupUrl(securityProperties.getBrowser().getSignUpUrl());
// 设置登录成功跳转地址
configurer.postLoginUrl("/hello");
return configurer;
}
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator){
return new ProviderSignInUtils(connectionFactoryLocator, getUsersConnectionRepository(connectionFactoryLocator));
}
}
/**
* @author: hblolj
* @Date: 2019/3/18 11:08
* @Description: 只有配置了 appId,该配置项才会生效
* @Version:
**/
@Order(2)
@Configuration
@ConditionalOnProperty(prefix = "hblolj.security.social.qq", name = "appId")
public class QQAutoConfig extends SocialAutoConfigurerAdapter{
/**
* 这里用 Resource 指定 name,因为在 SpringSocial 把下面有一个同名的 SecurityProperties
*/
@Resource(name = "customSecurityProperties")
private SecurityProperties securityProperties;
@Override
protected ConnectionFactory<?> createConnectionFactory() {
QQProperties qqProperties = securityProperties.getSocial().getQq();
return new QQConnectionFactory(qqProperties.getProviderId(), qqProperties.getAppId(), qqProperties.getAppSecret());
}
/**
* 可以自定义一个名称为 qqConnectedView 的 Bean 来覆盖此默认实现
* callbackConnect 是解绑成功
* @return
*/
@Bean({"connect/callbackConnect"})
@ConditionalOnMissingBean(name = "qqConnectedView")
public View qqConnectView(){
return new CustomConnectionView();
}
/**
* callbackConnected 是绑定成功
* @return
*/
@Bean({"connect/callbackConnected"})
@ConditionalOnMissingBean(name = "qqConnectedView")
public View qqConnectedView(){
return new CustomConnectionView();
}
}
/**
* @author: hblolj
* @Date: 2019/3/18 14:07
* @Description:
* @Version:
**/
public class CustomSpringSocialConfigurer extends SpringSocialConfigurer{
// 自定义 SocialAuthenticationFilter 拦截的 Url
private String filterProcessesUrl;
public CustomSpringSocialConfigurer(String filterProcessesUrl) {
this.filterProcessesUrl = filterProcessesUrl;
}
@Override
protected <T> T postProcess(T object) {
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
filter.setFilterProcessesUrl(filterProcessesUrl);
return (T) filter;
}
}
自定义绑定、解绑的处理结果,解绑、绑定的主要逻辑在 ConnectController 中。
/**
* @author: hblolj
* @Date: 2019/3/19 17:31
* @Description:
* @Version:
**/
@Slf4j
public class CustomConnectionView extends AbstractView{
@Override
protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws Exception {
httpServletResponse.setContentType("text/html;charset=UTF-8");
if (model.get("connections") == null){
// 解绑
httpServletResponse.getWriter().write("解绑成功
");
}else {
httpServletResponse.getWriter().write("绑定成功
");
}
}
}