上一篇:OAuth协议简介、第三方登录需要实现的接口

Spring Social 官网(旧的):https://projects.spring.io/spring-social/
QQ互联官方文档:https://wiki.connect.qq.com/

前面的代码下载:https://github.com/LawssssCat/v-security/tree/v3.0
(涉及到个人账号,一些配置没有上传,需要自行添加)

我们的实现步骤:

  1. 编写 Api 实现数据的对接模型
  2. 编写 OAuth2Operation 实现操作数据模型对接的方法
  3. 编写 ServiceProvider 实现将前面步骤封装为Provider
  4. 编写 ApiAdapter 实现将模型与本地模型适配起来
  5. 编写 ConnectionFactory 实现将前面步骤封装为连接工厂
  6. 编写 Connection 实现工厂的产品
  7. 创建数据库表和编写 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 已经把它配置好了,我们只需要

  1. 注入配置类
  2. 引用配置类,用apply方法使其生效

添加登录入口

SocialAuthenticationFilter 中可以看到默认的拦截路径



因此我们的社交登录连接为 /auth/qq

<!--社交-->

<h2>社交登录</h2>
<a href="/auth/qq">QQ登录</a>

问题解决

SocialAuthenticationFilter 过滤器不生效

访问 http://localhost:8080/auth/qq 出现:

说明 SocialAuthenticationFilter 没起作用

  1. 检查配置
  2. 检查上面的步骤 “ 添加过滤器”

可以在 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时候协商好的 “网站回调域”
(如下图)