Spring Social 官网(旧的):https://projects.spring.io/spring-social/
QQ互联官方文档:https://wiki.connect.qq.com/
前面的代码下载:https://github.com/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属性
<mark>Api 接口的实现可以继承 AbstractOAuth2ApiBinding</mark>
这个抽象类有两个属性:
accessToken
:存储获取到的令牌
(因此,这意味着,我们现在写的Api实现不是一个单例对象,而是针对每个第三方登录用户,都会创建一个Api实现)restTemplate
:帮我们发送http请求,通过http请求向服务提供商(Provider)索取用户数据
# QQ互联文档
我们要连接QQ服务,因此要查找QQ提供的文档:https://wiki.connect.qq.com/
https://wiki.connect.qq.com/get_user_info
类似的,还有返回的说明(如:<mark>返回码0时正确</mark>),自己去看
# 代码实现
看上面文档知道,需要三个参数:
- appid:申请QQ登录成功后,分配给应用的appid
- openid:用户的ID,与QQ号码一一对应。
- accessToken(父类实现了):令牌
因此,我们需要在代码中实现另外两个参数。
另外,有两个路径:(需要声明为常量)
https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID
获取用户数据https://graph.qq.com/oauth2.0/me?access_token=YOUR_ACCESS_TOKEN
获取 openId
Api 接口实现 QQImpl
注意,这里的appid在QQ互联里面获取首先要经过资料认证,可能需要一段时间的
参考:https://wiki.connect.qq.com/%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.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";
/** * 获取 openId 的接口 * <p> * 其中:%s为占位符 */
private static final String URL_GET_OPENID = "https://graph.qq.com/oauth2.0/me?access_token=%s";
/** * 申请QQ登录成功后,分配给应用的appId */
private String appId;
/** * 用户的ID,与QQ号码一一对应 */
private String openId;
/** * 构造函数 * 在获取到令牌后,通过令牌和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.com/%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.com/get_user_info
// 可以使用 fasterxml 下的 ObjectMapper 工具类
QQUserInfo result = getRestTemplate().getForObject(url, QQUserInfo.class);
result.setOpenId(this.openId);
return result;
}
}
接口
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.com/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.com/%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.com/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token */
private static final String URL_AUTORIZE = "https://graph.qq.com/oauth2.0/authorize";
/** * 获得授权码后,去到哪个地址获取令牌 * 参考:https://wiki.connect.qq.com/%E4%BD%BF%E7%94%A8authorization_code%E8%8E%B7%E5%8F%96access_token */
private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/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);
可以给表名添加前缀,如 v_UserConnection
这时候要改相应的配置。(即在创建Factory的地方添加setTablePrefix配置)
编写 Connection 实现
Connection 的实现不用我们处理,ConnectionFactory使用我们前面写的代码就能构建出来
但是数据库中 UserConnection 存储的本地User数据只有UserId,Connection 想要通过UserId获取UserDetails数据需要通过 UserDetailsService。
# 修改 UserDetailsService
<mark>因为 UserDetailsService 已经涉及到数据库业务模块,因此把此类移动到业务系统中(即v-security-demo)</mark>
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.com/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()
);
}
}
添加过滤器
不用自己一步步添加,Spring Security 已经把它配置好了,我们只需要
- 注入配置类
- 引用配置类,用apply方法使其生效
添加登录入口
在 SocialAuthenticationFilter
中可以看到默认的拦截路径
因此我们的社交登录连接为 /auth/qq
<!--社交-->
<h2>社交登录</h2>
<a href="/auth/qq">QQ登录</a>
问题解决
SocialAuthenticationFilter 过滤器不生效
访问 http://localhost:8080/auth/qq 出现:
说明 SocialAuthenticationFilter
没起作用
- 检查配置
- 检查上面的步骤 “ 添加过滤器”
可以在 SpringSecurityFilterChain 中查看是否存在 SocialAuthenticationFilter
param client_id is wrong or lost (100001)
访问 http://localhost:8080/auth/qq 出现:
检查配置:
redirect url is illegal(100010)
访问 http://localhost:8080/auth/qq 出现:
- redirect url 是授权成功后携带授权码返回到本地服务器的地址
这个地址是向服务提供商(QQ)申请appId时候协商好的 “网站回调域”
(如下图)