上一篇:OAuth协议简介、第三方登录需要实现的接口
Spring Social 官网(旧的):https://projects.spring.io/spring-social/
QQ互联官方文档:https://wiki.connect.qq/
前面的代码下载:https://github/LawssssCat/v-security/tree/v3.0
(涉及到个人账号,一些配置没有上传,需要自行添加)
我们的实现步骤:
- 编写
Api
实现数据的对接模型 - 编写
OAuth2Operation
实现操作数据模型对接的方法 - 编写
ServiceProvider
实现将前面步骤封装为Provider - 编写
ApiAdapter
实现将模型与本地模型适配起来 - 编写
ConnectionFactory
实现将前面步骤封装为连接工厂 - 编写
Connection
实现工厂的产品 - 创建数据库表和编写
UserConnectionRepository
告诉 Spring Security 平台间映射用户的表在哪里
接口架构
OAuth2.0 流程
编写Api接口实现
因为在 浏览器端 和 app 端均会使用第三方登录,因此把代码写到了 v-security-core 内部。
# accessToken和restTemplate属性
Api 接口的实现可以继承 AbstractOAuth2ApiBinding
这个抽象类有两个属性:
accessToken
:存储获取到的令牌
(因此,这意味着,我们现在写的Api实现不是一个单例对象,而是针对每个第三方登录用户,都会创建一个Api实现)restTemplate
:帮我们发送http请求,通过http请求向服务提供商(Provider)索取用户数据
# QQ互联文档
我们要连接QQ服务,因此要查找QQ提供的文档:https://wiki.connect.qq/
https://wiki.connect.qq/get_user_info
类似的,还有返回的说明(如:返回码0时正确),自己去看
# 代码实现
看上面文档知道,需要三个参数:
- appid:申请QQ登录成功后,分配给应用的appid
- openid:用户的ID,与QQ号码一一对应。
- accessToken(父类实现了):令牌
因此,我们需要在代码中实现另外两个参数。
另外,有两个路径:(需要声明为常量)
https://graph.qq/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID
获取用户数据https://graph.qq/oauth2.0/me?access_token=YOUR_ACCESS_TOKEN
获取 openId
Api 接口实现 QQImpl
注意,这里的appid在QQ互联里面获取首先要经过资料认证,可能需要一段时间的
参考:https://wiki.connect.qq/%E5%87%86%E5%A4%87%E5%B7%A5%E4%BD%9C_oauth2-0
package cn.vshop.security.core.social.qq.api;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.social.oauth2.AbstractOAuth2ApiBinding;
import org.springframework.social.oauth2.TokenStrategy;
/**
* @author alan smith
* @version 1.0
* @date 2020/4/8 14:54
*/
@Slf4j
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ {
/**
* 获取 userInfo 的接口
* <p>
* 携带三个参数
* 1. oauth_consumer_key=YOUR_APP_ID
* 2. openid=YOUR_OPENID
* 3. 还有一个参数:access_token=YOUR_ACCESS_TOKEN会在父类里面被添加上
* <p>
* 其中:%s为占位符
*/
private static final String URL_GET_USERINFO = "https://graph.qq/user/get_user_info?oauth_consumer_key=%s&openid=%s";
/**
* 获取 openId 的接口
* <p>
* 其中:%s为占位符
*/
private static final String URL_GET_OPENID = "https://graph.qq/oauth2.0/me?access_token=%s";
/**
* 申请QQ登录成功后,分配给应用的appId
*/
private String appId;
/**
* 用户的ID,与QQ号码一一对应
*/
private String openId;
/**
* 序列化
* 多例
*/
private ObjectMapper objectMapper = new ObjectMapper();
/**
* 构造函数
* 在获取到令牌后,通过令牌和RESTTemplate获取openId,同时填入appId
*
* @param accessToken 走完OAuth流程后拿到的令牌
* @param appId 系统的配置信息
*/
public QQImpl(String accessToken, String appId) {
// QQ文档中要求accessToken参数放在请求url上,而默认的行为不符合,因此需要用第二个参数手动指定
super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
this.appId = appId;
// 下面获取openID
// 用Token替换掉%s
String url = String.format(URL_GET_OPENID, accessToken);
// 响应字段参考:https://wiki.connect.qq/%E8%8E%B7%E5%8F%96%E7%94%A8%E6%88%B7openid_oauth2-0
// "callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );"
String result = getRestTemplate().getForObject(url, String.class);
log.info("获取openId的REST响应:{}", result);
// 暂时这样处理,后面会重构
this.openId = StringUtils.substringBetween("\"openid\":\"", "\"}");
}
@Override
public QQUserInfo getUserInfo() {
// 发送的请求,还有一个参数access_token会被自动填入
String url = String.format(URL_GET_USERINFO, appId, openId);
// 响应字段参考:https://wiki.connect.qq/get_user_info
// QQUserInfo result = getRestTemplate().getForObject(url, QQUserInfo.class);
// 不可直接用getRestTemplate转换响应,因为还需要自定义转换器
// 这里可以直接使用 fasterxml 下的 ObjectMapper 工具类
try {
QQUserInfo qqUserInfo = objectMapper.readValue(result, QQUserInfo.class);
qqUserInfo.setOpenId(this.openId);
return qqUserInfo;
} catch (IOException e) {
log.error("获取用户新消息失败,错误信息:{}", e.getMessage());
throw new RuntimeException(e);
}
}
}
接口
package cn.vshop.security.core.social.qq.api;
/**
* 映射QQ用户的Api接口
*
* @author alan smith
* @version 1.0
* @date 2020/4/8 14:46
*/
public interface QQ {
/**
* 获取用户的详细信息
*
* @return 用户详细信息的封装
*/
QQUserInfo getUserInfo();
}
用户信息封装
package cn.vshop.security.core.social.qq.api;
import lombok.Data;
/**
* QQ用户详细信息
* 参考: <a>https://wiki.connect.qq/get_user_info</a>
*
* @author alan smith
* @version 1.0
* @date 2020/4/8 14:53
*/
@Data
public class QQUserInfo {
/**
* 用户的ID,与QQ号码一一对应
*/
private String openId;
/**
* 返回码
*/
private String ret;
/**
* 如果ret<0,会有相应的错误信息提示,返回数据全部用UTF-8编码。
*/
private String msg;
/**
* 用户在QQ空间的昵称。
*/
private String nickname;
/**
* 大小为30×30像素的QQ空间头像URL。
*/
private String figureurl;
/**
* 大小为50×50像素的QQ空间头像URL。
*/
private String figureurl_1;
/**
* 大小为100×100像素的QQ空间头像URL。
*/
private String figureurl_2;
/**
* 大小为40×40像素的QQ头像URL。
*/
private String figureurl_qq_1;
/**
* 大小为100×100像素的QQ头像URL。需要注意,不是所有的用户都拥有QQ的100x100的头像,但40x40像素则是一定会有。
*/
private String figureurl_qq_2;
/**
* 性别。如果获取不到则默认返回"男"
*/
private String gender;
}
编写ServiceProvider和OAuth2Operation的接口实现
因为后面写的代码都是为了产生一个 Connection,因此代码都存放到Connection包
参考QQ互联文档:https://wiki.connect.qq/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token
package cn.vshop.security.core.social.qq.connect;
import cn.vshop.security.core.social.qq.api.QQ;
import cn.vshop.security.core.social.qq.api.QQImpl;
import org.springframework.social.oauth2.AbstractOAuth2ServiceProvider;
import org.springframework.social.oauth2.OAuth2Template;
/**
* 服务提供商(Provider)
*
* @author alan smith
* @version 1.0
* @date 2020/4/8 17:50
*/
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {
/**
* 申请QQ登录成功后,分配给应用的appId
*/
private String appId;
/**
* 引导用户到哪个地址进行授权
* 参考:https://wiki.connect.qq/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token
*/
private static final String URL_AUTORIZE = "https://graph.qq/oauth2.0/authorize";
/**
* 获得授权码后,去到哪个地址获取令牌
* 参考:https://wiki.connect.qq/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token
*/
private static final String URL_ACCESS_TOKEN = "https://graph.qq/oauth2.0/token";
public QQServiceProvider(String appId, String appSecret) {
// 传入一个OAuth2Template,用于跟服务提供商通信
super(new OAuth2Template(
// app的用户名
appId,
// app的密码
appSecret,
// 将用户导向认证服务器的地址
URL_AUTORIZE,
// 申请令牌的认证服务器的地址
URL_ACCESS_TOKEN
));
}
/**
* 不是单例的,能确保线程安全
*
* @param accessToken 服务提供商Provider提供的令牌
* @return Api接口的QQ实现
*/
@Override
public QQ getApi(String accessToken) {
return new QQImpl(accessToken, appId);
}
}
🐉
编写ApiAdapter实现
package cn.vshop.security.core.social.qq.connect;
import cn.vshop.security.core.social.qq.api.QQ;
import cn.vshop.security.core.social.qq.api.QQUserInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.social.connect.ApiAdapter;
import org.springframework.social.connect.ConnectionValues;
import org.springframework.social.connect.UserProfile;
/**
* QQ用户数据模型转换到标准的用户数据模型的适配器
*
* @author alan smith
* @version 1.0
* @date 2020/4/8 20:31
*/
@Slf4j
public class QQAdapter implements ApiAdapter<QQ> {
/**
* 测试当前的api请求是否可用
*
* @param api 获取QQ用户信息api
* @return 该api是否可用
*/
@Override
public boolean test(QQ api) {
boolean flag = api.getUserInfo() != null;
// 改为true,略过测试
log.info("测试QQ服务提供商API测试结果:{}", flag);
return flag;
}
/**
* 将QQ中获取的用户数据设置到标准的用户数据中
*
* @param api
* @param values
*/
@Override
public void setConnectionValues(QQ api, ConnectionValues values) {
// 拿到QQ的用户信息
QQUserInfo userInfo = api.getUserInfo();
// 设置用户名
values.setDisplayName(userInfo.getNickname());
// 设置头像
values.setImageUrl(userInfo.getFigureurl_qq_1());
// 设置个人主页,QQ没有这东西
values.setProfileUrl(null);
// 服务商的用户id,也就是openId
values.setProviderUserId(userInfo.getOpenId());
}
/**
* 与setConnectionValues方法类似
* (后面用到绑定和解绑时候再具体写)
*
* @param api
* @return
*/
@Override
public UserProfile fetchUserProfile(QQ api) {
return null;
}
/**
* 更新状态
*
* @param api
* @param message
*/
@Override
public void updateStatus(QQ api, String message) {
// do nothing
}
}
编写 ConnectionFactory 实现
package cn.vshop.security.core.social.qq.connect;
import cn.vshop.security.core.social.qq.api.QQ;
import org.springframework.social.connect.support.OAuth2ConnectionFactory;
/**
* 生成标准用户模型(Connection)的工厂
*
* @author alan smith
* @version 1.0
* @date 2020/4/8 22:05
*/
public class QQConnectionFactory extends OAuth2ConnectionFactory<QQ> {
/**
* Create a {@link OAuth2ConnectionFactory}.
*
* @param providerId the provider id e.g. "facebook"
* @param appId 应用Id,申请QQ登录成功后,分配给应用的appId
* @param appSecret 应用密码,申请QQ登录的时候,指定给应用的密码
*/
public QQConnectionFactory(String providerId, String appId, String appSecret) {
super(
// 服务提供商的唯一标识,通过配置文件配置
providerId,
// 服务提供商的接口实例
// the ServiceProvider model for conducting the authorization flow and obtaining a native service API instance.
new QQServiceProvider(appId, appSecret),
// api接口的适配器
// the ApiAdapter for mapping the provider-specific service API model to the uniform {@link Connection} interface.
new QQAdapter());
}
}
编写 UserConnectionRepository 和创建数据库表
要把Connection的数据保存到数据库里面,还需要 UserConnectionRepository 。
这个接口的 实现类 JdbcUsersConnectionRepository 已经由 Spring Security 帮我们写好了,我们只需要简单配置一下
编写配置类
package cn.vshop.security.core.social;
import org.springframework.beans.factory.annotation.Autowired;
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.SocialConfiguration;
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 javax.sql.DataSource;
/**
* 配置类
* <p>
* {@link EnableSocial}的作用是往容器中注入{@link SocialConfiguration}配置(这个配置会往容器中注入几个和Social相关的类)。
*
* @author alan smith
* @version 1.0
* @date 2020/4/8 22:23
*/
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
@Autowired
private DataSource dataSource;
/**
* 获取UsersConnectionRepository(存储提供商用户和本地用户的映射关系)
* <p>
* 默认返回的是从内存中获取连接的Repository
*
* @param connectionFactoryLocator 在注解@EnableSocial注入的配置中被指定,帮我们定位ConnectionFactory
* @return 从数据库中获取连接的Repository
*/
@Override
public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
return new JdbcUsersConnectionRepository(
// 数据源
dataSource,
// connectionFactory的定位器,因为内存中可能有多个ConnectionFactory(如:QQ、微信)
connectionFactoryLocator,
// 对存入数据库数据的加密解密器,为了便于后面观测数据,这里就不加密了
Encryptors.noOpText());
}
}
创建数据库表
建表语句 Spring Security 也已经提供好了。
就在跟JdbcUsersConnectionRepository同级的文件夹里面(下图)
-- This SQL contains a "create table" that can be used to create a table that JdbcUsersConnectionRepository can persist
-- connection in. It is, however, not to be assumed to be production-ready, all-purpose SQL. It is merely representative
-- of the kind of table that JdbcUsersConnectionRepository works with. The table and column names, as well as the general
-- column types, are what is important. Specific column types and sizes that work may vary across database vendors and
-- the required sizes may vary across API providers.
create table UserConnection (
# userId、providerId、providerUserId 三个id联合起来为这个表的Id
# 记录了两个平台间用户的关系
# 本地系统中用户的Id
userId varchar(255) not null,
# 服务提供商的Id(如:"微信"、"QQ"、"google")
providerId varchar(255) not null,
# 服务提供商给出的用户ID,即openId
providerUserId varchar(255),
# 用户等级
rank int not null,
# set方法设置的值
# 名称
displayName varchar(255),
# 主页
profileUrl varchar(512),
# 头像
imageUrl varchar(512),
# OAuth协议相关
# 服务提供商给出的令牌
accessToken varchar(512) not null,
# 密码
secret varchar(512),
# 令牌刷新时间
refreshToken varchar(512),
# 令牌过期时间
expireTime bigint,
# userId、providerId、providerUserId 三个id联合起来为这个表的Id
primary key (userId, providerId, providerUserId));
# 快速索引
create unique index UserConnectionRank on UserConnection(userId, providerId, rank);
这个表记录了,本地的userId 在服务提供商providerId 内部的 providerUserId (服务提供商用户id)。通过这三个字段形成一个联合组件,记录了本地用户和服务提供商用户之间的映射信息。
可以给表名添加前缀,如 v_UserConnection
这时候要改相应的配置。(即在创建Factory的地方添加setTablePrefix配置)
编写 Connection 实现
Connection 的实现不用我们处理,ConnectionFactory使用我们前面写的代码就能构建出来
但是数据库中 UserConnection 存储的本地User数据只有UserId,Connection 想要通过UserId获取UserDetails数据需要通过 UserDetailsService。
# 修改 UserDetailsService
如何将 UserId 转换成完整的本地用户信息呢?我们之前写过,就是UserDetailsService。
与这个机制类似,Spring Social 提供了 SocialUserDetailsService 接口,通过实现 loadUserByUserId 方法,我们就可以告诉SpringSecurit如何通过UserId 获取完整的本地用户信息。
因为 UserDetailsService 已经涉及到数据库业务模块,因此把此类移动到业务系统中(即v-security-demo)
package cn.vshop.security.auth;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
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.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;
import java.util.Collection;
/**
* 根据用户名查找用户认证信息
*
* @author alan smith
* @version 1.0
* @date 2020/4/3 17:05
*/
@Slf4j
// 注入 spring 容器
@Component//("myUserDetailsService")
public class MyUserDetailsService
// 表单认证时候的 UserDetails Service
implements UserDetailsService,
// 社交认证时候用的 UserDetails Service
SocialUserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
// @Autowired
//private DAO 对象 .... 这里就直接模拟了
/**
* 根据用户名查找用户认证信息,作为登录的认证的依据
* 因为在spring环境中,查找信息的方式只需要注入即可
*
* @param username 用户要的认证的用户名
* @return 认证依据的详细信息
* @throws UsernameNotFoundException 没有 username 对应的用户信息
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("表单登录用户名:{}", username);
return buildUser(username);
}
/**
* Social认证的子类
*
* @param userId 用户名
* @return UserDetails的子类,在Social授权下使用
* @throws UsernameNotFoundException
*/
@Override
public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
log.info("Social登录用户名:{}", userId);
return buildUser(userId);
}
/**
* 根据用户名从数据库查找用户信息
*
* @param username 用户名
* @return
*/
private SocialUser buildUser(String username) {
// 模拟:查出来的用户密码
String password = passwordEncoder.encode("123456");
log.info("数据库密码:{}", password);
// 授权信息,告诉SpringSecurity,当前用户一旦认证成功,拥有哪些权限
Collection<? extends GrantedAuthority> authorities = AuthorityUtils
// 一个工具,把字符串以空格隔开,分别存储为权限
.commaSeparatedStringToAuthorityList(
// 模拟从数据库读出一下权限
"admin user");
// 返回UserDetails接口
// User是SpringSecurity提供的UserDetails接口实现
return new SocialUser(
username,
password,
// 账号未被删除
true,
// 账号未过期
true,
// 密码未过期
true,
// 账号未被冻结
true,
authorities);
}
}
补全配置
像 providerId、appId、appSecret 这些必备的配置需要加上,同时需要通过配置,把ConnectionFactory给构造出来
QQProperties
package cn.vshop.security.core.properties.modules;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.autoconfigure.social.SocialProperties;
/**
* @author alan smith
* @version 1.0
* @date 2020/4/9 8:08
*/
@Getter
@Setter
public class QQProperties extends SocialProperties {
/**
* 服务提供商的唯一标识码
*/
private String providerId = "qq";
}
它继承的父类
SocialProperties
有两个属性
将QQ配置装到总配置中
package cn.vshop.security.core.properties;
import cn.vshop.security.core.properties.modules.BrowserProperties;
import cn.vshop.security.core.properties.modules.SocialProperties;
import cn.vshop.security.core.properties.modules.ValidateCodeProperties;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 总配置项
*
* @author alan smith
* @version 1.0
* @date 2020/4/3 22:03
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "v.security")
public class SecurityProperties {
/**
* 这里读取的是 v.security.browser 配置项
*/
private BrowserProperties browser = new BrowserProperties();
/**
* 这里读取的是 v.security.code 配置项
*/
private ValidateCodeProperties code = new ValidateCodeProperties();
/**
* 这里读取的是 v.security.social 配置项
*/
private SocialProperties social = new SocialProperties();
}
在qq和总配置间再加一层,应对多种social认证的情况
package cn.vshop.security.core.properties.modules;
import lombok.Getter;
import lombok.Setter;
/**
* social 认证授权配置
*
* @author alan smith
* @version 1.0
* @date 2020/4/9 8:11
*/
@Getter
@Setter
public class SocialProperties {
/**
* qq 认证授权配置
*/
private QQProperties qq = new QQProperties();
}
application.yml配置
appId、appSecret需要申请:https://connect.qq/index.html
或者换成github登录
v:
security:
social:
qq:
# 服务供应商id
providerId: xxx
# 应用id
appId: xxx
# 应用密码
appSecret: xxx
添加自动配置
最后添加自动配置,让配置项生效
package cn.vshop.security.core.social.qq.config;
import cn.vshop.security.core.properties.SecurityProperties;
import cn.vshop.security.core.properties.modules.QQProperties;
import cn.vshop.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.config.annotation.SocialConfigurerAdapter;
import org.springframework.social.connect.ConnectionFactory;
/**
* {@link SocialConfigurerAdapter}的适配器
*
* @author alan smith
* @version 1.0
* @date 2020/4/9 8:25
*/
@Configuration
// 希望配置了相应配置,此配置类才生效
@ConditionalOnProperty(prefix = "v.security.social.qq", name = "app-id")// appId
public class QQAutoConfig extends SocialAutoConfigurerAdapter {
@Autowired
private SecurityProperties securityProperties;
@Override
protected ConnectionFactory<?> createConnectionFactory() {
QQProperties qqConfig = securityProperties.getSocial().getQq();
return new QQConnectionFactory(
qqConfig.getProviderId(),
qqConfig.getAppId(),
qqConfig.getAppSecret()
);
}
}
添加过滤器
最后,最重要的是在springSecurity过滤器链上添加social的过滤器。那么首先,需要有该过滤器的配置(类)。
不用自己一步步添加,Spring Security 已经把它配置好了,我们只需要
- 注入配置类
- 引用配置类,用apply方法使其生效
这个配置类我们写在 social config 里面
添加登录入口
在 SocialAuthenticationFilter
中可以看到默认的拦截路径
因此我们的社交登录连接为 /auth/qq
<!--社交-->
<h2>社交登录</h2>
<a href="/auth/qq">QQ登录</a>
到登录页面
因为之前配置了请求判断, 所以随便写一个页面请求就会到登录页面:
http://localhost:8080/xxxxxxxx.html
点击 “qq登录”,会有各种问题,我们一一解决。
问题解决
1. SocialAuthenticationFilter 过滤器不生效
访问 http://localhost:8080/auth/qq 出现:
说明 SocialAuthenticationFilter
没起作用
- 检查配置
- 检查上面的步骤 “ 添加过滤器”
可以在 SpringSecurityFilterChain 中查看是否存在 SocialAuthenticationFilter
2. param client_id is wrong or lost (100001)
访问 http://localhost:8080/auth/qq 出现:
检查配置:
3. redirect url is illegal(100010)
访问 http://localhost:8080/auth/qq 出现:
那么,redirect url 是什么?是一个回调地址。
回顾前面讲的 Oauth 认证流程,(第三步)当用户同意授权后,服务提供商会响应携带授权码回访我们指定的redirect url
- redirect url 是授权成功后携带授权码返回到本地服务器的地址
这个地址是向服务提供商(QQ)申请appId时候协商好的 “网站回调域”
(如下图)
我们可以在错误页面的 url 上看到错误的 回调地址
https://graph.qq/oauth2.0/show?which=error&display=pc&error=100010&client_id=101870995&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth%2Fqq&state=8824b35e-64eb-49d3-9754-d7c0bbfd9981
通过工具: http://tool.chinaz/tools/urlencode.aspx
可知回调地址即我们发送请求的地址
而我们发送请求的地址由 SocialAuthenticationFilter 拦截
由此分析,我们要解决这个错误,只需要将本地的 redirect url 设置成和qq互联上写好的一致即可。
www.chatwhat/callback/qq/login
三步:
- 修改域名为:www.chatwhat
- 修改 DEFAULT_FILTER_PROCESSES_URL:callback
- 修改 providerId:qq/login
1. 修改域名
因为服务是在本地开启的,所以需要把域名映射回本地
这里,我的回调地址
http://www.chatwhat/callback/qq/login
修改host
127.0.0.1 www.chatwhat
建议使用工具 SwitchHosts! , 方便管理
2/3. 修改 DEFAULT_FILTER_PROCESSES_URL和配置providerId
修改这个比较麻烦,
先分析整个依赖流程
DEFAULT_FILTER_PROCESSES_URL
↓
属性
↓
SocialAuthenticationFilter
↓
配置
↓
SpringSocialConfigurer
↓
创建
↓
cn.vshop.security.core.social.SocialConfig(我们的配置)
↓
注册
↓
cn.vshop.security.browser.BrowserSecurityConfig(我们的配置)
其中,SpringSocialConfigurer提供了可重写的方法 postProcess
因此,我们只需要继承SpringSocialConfigurer,重写postProcess方法,在(我们写的配置类)SocialConfig创建我们自己的SpringSocialConfigurer即可
package cn.vshop.security.core.social;
import org.springframework.social.security.SocialAuthenticationFilter;
import org.springframework.social.security.SpringSocialConfigurer;
/**
* @author alan smith
* @version 1.0
* @date 2020/5/27 7:27
*/
public class VshopSocialSecurityConfig extends SpringSocialConfigurer {
private String filterProcessesUrl;
public VshopSocialSecurityConfig(String filterProcessesUrl) {
this.filterProcessesUrl = filterProcessesUrl;
}
@Override
protected <T> T postProcess(T object) {
SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
filter.setFilterProcessesUrl(filterProcessesUrl);
return (T) filter;
}
}
注入我们新写好的SpringSocialConfigurer
做好配置
最后修改页面的登录路径
<h2>社交登录</h2>
<a href="/callback/qq/login">QQ登录</a>
访问
http://www.chatwhat/login5.html
成功来到登录界面
4. 为什么到 http://{域名}/signin ,且被拦截
错误 redirect url is illegal(100010)
解决后,我们登陆qq发现转跳回来的地址依然被拦截
我们查看控制台
或者打断点在:BrowserSecurityController.
requireAuthentication
上(这是我们之前写的,将未认证请求做转跳处理的一个方法)
可见,qq那边登录成功,会回调 http://{域名}/signin 地址,而这个地址被我们拦截了
为什么拦截 signin 很好理解,我们没有对这个路径放行。
但为什么会回调到 signin 地址呢?这里有大文章。
我们需要理解当前到了认证流程的哪一步
流程整理
使用 Spring Social 开发第三方登录 流程图
其中,蓝色的是SpringSocial 封装好的(不需要我们写)
而橙色的是我们现在添加了的
- SocialAuthenticationFilter 拦截请求
(用户三方登录请求,服务提供商回调请求)- SocialAuthenticationService 处理OAuth全流程
- ConnectionFactory首先被调用,获取 服务提供商的用户信息
- 服务提供商的用户信息 会被封装到connection里面(SocialAuthenticationToken)
- 整个Authentication(Token)会被交给AuthenticationManager处理
- 管理器挑选 AuthenticationProvider 处理 Toekn
- Provider 使用 UsersConnectionRepository 通过 服务提供商的用户信息 到数据库查找 本地用户信息(userId)
- SocialUserDetailsService 通过 本地的userId获取本地完整的用户信息 SocialUserDetails
- 最终将 SocialUserDetails 放入 SocialAuthenticationToken 内部
Token 放入 SecurityContext,最终放入 session, 记录为身份认证完成
解答:为什么到 http://{域名}/signin
可见,我们现在完成了 SocialAuthenticationFilter 拦截用户三方登录请求,转跳到 qq 登录页。
而问题出现在 SocialAuthenticationFilter 拦截到服务提供商的回调请求,到SocialAuthenticationService 处。
通过分析
SocialAuthenticationFilter.doFilter
SocialAuthenticationFilter.attemptAuthentication
SocialAuthenticationFilter.attemptAuthService
authService.getAuthToken(request, response);
定位到实现类方法 OAuth2AuthenticationService.getAuthToken
public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException {
String code = request.getParameter("code");
if (!StringUtils.hasText(code)) {
OAuth2Parameters params = new OAuth2Parameters();
params.setRedirectUri(buildReturnToUrl(request));
setScope(request, params);
params.add("state", generateState(connectionFactory, request));
addCustomParameters(params);
throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params));
} else if (StringUtils.hasText(code)) {
try {
String returnToUrl = buildReturnToUrl(request);
AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
// TODO avoid API call if possible (auth using token would be fine)
Connection<S> connection = getConnectionFactory().createConnection(accessGrant);
return new SocialAuthenticationToken(connection, null);
} catch (RestClientException e) {
logger.debug("failed to exchange for access", e);
return null;
}
} else {
return null;
}
}
大概逻辑:
分析请求
- 不带code(授权码),抛异常SocialAuthenticationRedirectException,重定向到qq登录页
- 带code(授权码),执行方法
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]
这里的 OAuthOperation 实现类是
OAuth2Template.exchangeForAccess 方法:
public AccessGrant exchangeForAccess(String authorizationCode, String redirectUri, MultiValueMap<String, String> additionalParameters) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<String, String>();
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);
}
return postForAccessGrant(accessTokenUrl, params);
}
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
return extractAccessGrant(getRestTemplate().postForObject(accessTokenUrl, parameters, Map.class));
}
这个方法最终携带 parameters 访问 accessTokenUrl ,并希望把返回的信息视为 json 处理后放入 map 中。
结合抛出的异常信息可知,返回的信息是 text/html 而不是 json ,从而无法转成 map 存储,从而抛出异常。
异常处理返回空
最终抛出 AuthenticationServiceException 在 Filter 中被捕获
(上图) 捕获后被 failureHandler 处理
而 SocialAuthenticationFilter 的默认失败处理url为:signin ,因此最终到了 http://{域名}/signin ,且被拦截了
响应
继承OAuth2Template
参考 qq开发者文档 - 使用Authorization_Code获取Access_Token
上面分析总结为:
- 我们期望响应头为 json或者form
- 能被我们解析器解析为map
但 qq 的
accessToken 的响应是:
access_token=FE04************************CCE2&expires_in=7776000&refresh_token=88E4************************BE14
而且我们的解析器不支持将字符串解析为map
于是我们添加自己的 converter StringMapHttpMessageConverter
package cn.vshop.security.core.social.qq.connect;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.social.support.FormMapHttpMessageConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 模仿{@link FormMapHttpMessageConverter}并委托其为我们读写请求,
* 我们处理读写前后的值映射问题
*
* @author alan smith
* @version 1.0
* @date 2020/6/2 23:05
*/
public class StringMapHttpMessageConverter implements HttpMessageConverter<Map<String, String>> {
private FormHttpMessageConverter delegate;
public StringMapHttpMessageConverter(Charset defaultCharset) {
delegate = new FormHttpMessageConverter();
delegate.setCharset(defaultCharset);
// unmodified
List<MediaType> supportedMediaTypes = delegate.getSupportedMediaTypes();
ArrayList<MediaType> mediaTypes = new ArrayList<>();
mediaTypes.addAll(supportedMediaTypes);
mediaTypes.add(MediaType.TEXT_HTML);
delegate.setSupportedMediaTypes(mediaTypes);
}
public StringMapHttpMessageConverter() {
this(FormHttpMessageConverter.DEFAULT_CHARSET);
}
@Override
public boolean canRead(Class<?> clazz, MediaType mediaType) {
if (!Map.class.isAssignableFrom(clazz)) {
return false;
}
if (mediaType == null) {
return true;
}
for (MediaType supportedMediaType : getSupportedMediaTypes()) {
// we can't read multipart
if (!supportedMediaType.equals(MediaType.MULTIPART_FORM_DATA) &&
supportedMediaType.includes(mediaType)) {
return true;
}
}
return false;
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
if (!Map.class.isAssignableFrom(clazz)) {
return false;
}
if (mediaType == null || MediaType.ALL.equals(mediaType)) {
return true;
}
for (MediaType supportedMediaType : getSupportedMediaTypes()) {
if (supportedMediaType.isCompatibleWith(mediaType)) {
return true;
}
}
return false;
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return delegate.getSupportedMediaTypes();
}
@Override
public Map<String, String> read(Class<? extends Map<String, String>> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
MultiValueMap<String, String> lmvm = new LinkedMultiValueMap<>();
@SuppressWarnings("unchecked")
Class<MultiValueMap<String, String>> mvmClazz = (Class<MultiValueMap<String, String>>) lmvm.getClass();
MultiValueMap<String, String> mvm = delegate.read(mvmClazz, inputMessage);
return mvm.toSingleValueMap();
}
@Override
public void write(Map<String, String> stringStringMap, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
MultiValueMap mvm = null;
if (stringStringMap instanceof MultiValueMap) {
mvm = (MultiValueMap) stringStringMap;
} else {
mvm = new LinkedMultiValueMap<String, String>();
mvm.setAll(stringStringMap);
}
delegate.write(mvm, contentType, outputMessage);
}
}
然后把新的 StringMapHttpMessageConverter
添加到 restTemplate
中,因为我们之前使用的是默认的 OAuth2Template
。所以现在要重写一个 QQOAuth2Template
package cn.vshop.security.core.social.qq.connect;
import org.springframework.http.converter.HttpMessageConverter;
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.util.List;
/**
* @author alan smith
* @version 1.0
* @date 2020/6/2 7:19
*/
public class QQOAuth2Template extends OAuth2Template {
public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
// true if the client credentials should be passed as parameters; false if passed via HTTP Basic
this.setUseParametersForClientAuthentication(true);
}
@Override
protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
AccessGrant accessGrant = super.postForAccessGrant(accessTokenUrl, parameters);
return accessGrant;
}
@Override
protected RestTemplate createRestTemplate() {
RestTemplate restTemplate = super.createRestTemplate();
List<HttpMessageConverter<?>> converterList = restTemplate.getMessageConverters();
// 不能清空,因为还其他请求响应需要用到
// converterList.clear();
converterList.add(new StringMapHttpMessageConverter()) ;
return restTemplate ;
}
}
(这里也可以用添加 StringHttpMessageConverter ,然后重写 postForAccessGrant 的方法)
最后在 provider 中使用我们自己的 template
第三方登录
结果是我们拿到了 QQimpl 的用户信息
4. 转跳到注册页 http://{域名}/signup
上面获取到了 QQimpl 的数据,但是还是无法完成登录,因为我们程序转跳到了注册页 (http://{域名}/signup )
为什么?
我们跟踪到获取到token之后的代码,auth=null 说明还没有认证(还需要在本地认证)
所以代码就去获取本地的 userDetails,然后换取本地的认证信息
就在 获取 userDetails 处抛了异常 Unknown access token
原因是我们没有配置 userDetails ,会默认从session中取
更多推荐
Spring Security OAuth2.0 认证协议【15】实现QQ第三方登录
发布评论