Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2

1.SpringSocia源码分析

一、Spring Social结构化角度解析源码

Spring Social是一个帮助我们连接社交媒体平台,方便在我们自己的应用上开发第三方登录认证等功能的Spring 类库。其中比较核心的类和接口,如下图所示,我们来一一解析。

首先我们简单回顾一下OAuth2,OAuth2主要包含两部分内容:认证和鉴权。

  • 认证过程就是通过用户授权,获取授权码,最终换取AccessToken的过程。这个过程是标准的OAuth2认证流程,所有平台都遵循,可以认为是一致的。
  • 鉴权过程就是携带AccessToken访问社交媒体平台API接口。当然各平台的用户不同、业务不同,所以提供的的接口不一样。

如果你对这部分内容,还不是很熟悉,先回看我的上一篇文章。请结合下面的这张图理解后面的文字。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第1张图片

1.1.OAuth2认证源码

首先在实现OAuth2登录认证的过程中,有多次我们自己开发的应用和社交媒体平台之间的的请求和响应。所以我们需要封装一个类用来处理标准的OAuth2认证专用的HTTP工具类,这个可以说是最重要的工作,Spring Security已经帮我们提供了OAuth2Operations接口,其默认的实现类是OAuth2Template,根据不同的平台的实现差异我们可能会需要自己来实现(微调)。认证过程中所有与OAuth2认证服务器交互的工作就全交给OAuth2Operations,最后返回给我们一个AccessToken。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第2张图片

对于开发者来说,只要将以上四个属性的值告诉OAuth2Operations。只要服务提供商是严格按照OAuth2标准开发的认证服务,剩下的与认证服务器交互的过程,我们就不需要处理了。

1.2.接口资源鉴权

当我们获得了AccessToken之后,就有权限请求OAuth2资源服务器里面的资源了。各个社交媒体平台根据用户及业务不同提供的接口完全不同,这时我们需要用到RestTemplate,通用的HTTP工具类处理请求与响应。从图中可以看到,处理各种数据格式JSON、XML的类库,RestTemplate会自行根据环境判断使用哪一个。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第3张图片

那既然各个平台的业务接口各不相同,我们当然要自定义开发不同的接口实现APIImpl。此时我们应该需要一个统一的父类,包含accessToken和RestTemplate,这样我们的自定义接口实现就可以通过继承这个类获得并使用accessToken和RestTemplate。这个统一的父类叫做AbstractOAuth2Binding。它还帮助我们实现HTTP请求的参数携带,以及请求结果到对象的反序列化工作等。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第4张图片

至此,OAuth2Operations 和 自定义接口实现APIImpl,一个负责认证流程请求响应,一个负责资源请求响应。二者统一被封装为ServiceProvider-服务提供商。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第5张图片

1.3.确定用户关系

通过实现上面的代码中的接口,我们自己的应用与社交媒体平台(服务提供商)的HTTP交互过程就已经可以被全部支持了。但是开发社交媒体登陆还有一个很重要的步骤就是:判定社交媒体平台响应的用户信息与我们自己的应用用户之间的关系。我们用一张数据库表来表示这个关系,而且必须是这张表(Spring Social专用,在spring-social-core包里面可以找到):

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);

这张表中,最重要的三个字段就是userId(自开发应用的用户唯一标识),provider(服务提供商,社交媒体平台唯一标识),providerUserId (服务提供商用户的唯一标识)。通过这三个字段体现自开发应用的用户与服务提供商用户之间的关系,从而判定服务提供商的用户是否可以通过OAuth2认证登录我们的应用。(这张表里面的数据,是通过注册或者绑定操作加入进去的,与认证、鉴权过程无关)

  • 通过1.2小节中的接口,我们可以获得社交媒体的用户的数据User,但是我们说过了这个User在不同的服务提供商平台上,其结构是完全不同的。而spring Social只认识一种用户的数据结构,那就是Connection(OAuth2Connection)。所以我们需要一个ApiAdapter帮我们将二者进行适配。ApiAdapter是一个接口,内容需要我们自行实现。
  • 现在我们拿到了Spring Social认可的服务提供商用户信息Connection,然后使用UsersConnectionRepository加载UserId(我们自己开发的平台的userid)。如果能够加载到userId(不为空),表示登录验证成功。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第6张图片

1.4.本地应用授权

通过实现上面代码中的接口,我们就可以拿到userId,我们自己开发的应用的用户的唯一标识。也表示利用社交媒体用户登录我们自己开发的应用成功了。但是,还有一个问题没有解决,你是登陆成功了,但是不意味着你可以访问本地应用中的所有资源。所以,我们根据userId查找当前用户信息UserDetails,并为他授权。

在我们之前的使用用户名密码登陆的案例中,是通过实现UserDetailsService和UserDetails接口来实现的。在社交媒体登录过程中,我们需要实现的接口是SocialUserDetailsService和SocialUserDetails。其实实现原理是一样的,就是用用户的唯一标识userId,加载该用户角色的权限信息。至此,Spring Security就知道了该用户的权限信息,可以有效的控制其访问权限。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第7张图片

以上过程的核心流程代码,都在SocialAuthenticationProvider中的authenticate方法中定义

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第8张图片

二、Spring Social流程角度解析源码

Spring Social自动配置会在过滤器链中加入一个SocialAuthenticationFilter过滤器,该过滤器拦截社交媒体登录请求。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第9张图片

SocialAuthenticationFilter过滤器拦截的社交媒体登录请求的地址是{filterProcessesUrl}/{providerId}。filterProcessesUrl的默认值是“/auth”,如果你的服务提供商providerId(自定义)是github,那么你的社交媒体登录按钮请求的地址就应该是“/auth/github”,当然这两个值我们都可以修改。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第10张图片

要说明的是{filterProcessesUrl}/{providerId}在Spring Social既是认证请求的地址,也是服务提供商回调的地址。当用户点击"github登录"按钮,此时访问/{filterProcessesUrl}/{providerId}被拦截,此时用户没有被认证通过,所以跳转到GitHub授权页面(authorizeUrl)上,用户输入用户密码授权,在浏览器跳回到本地应用,仍然回到/{filterProcessesUrl}/{providerId}再次被拦截。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第11张图片

首先要检测用户是否授权使用第三方平台用户信息,如果没授权就直接抛出异常。如果用户授权了,就去执行OAuth2一系列的请求响应,获取授权码、AccessToken、Connection用户信息。这个过程代码在OAuth2AuthenticationService中被定义。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第12张图片

doAuthentication中授权过程,参考1.3、1.4小节内容。如果授权失败(该社交平台用户在本地应用中没有对应的用户),则跳转到signUpUrl。该页面是将本系统用户和“服务提供商”用户进行关系绑定页面。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第13张图片

注意:Spring Social实现的OAuth2认证鉴权流程中,使用到了session(如上图中的sessionStrategy代码)。所以当你的应用是一个无状态应用时,需要对Spring Social进行一定程度的改造。但是笔者从来没这么做过。简单的做法就是:使用session开发有状态应用,并且session保存的状态信息交给redis集中管理;或者开发无状态应用之前,确定该应用不需要社交媒体登录功能,比如某企业内网应用。


2.QQ互联注册及应用创建

一、QQ互联注册开发者

要想使用QQ登陆的功能,首先你必须是腾讯开发者。腾讯搞了一大堆的开放平台,有点乱。如果你还不是腾讯开发者,先去QQ互联网站https://connect.qq.com注册一下开发者。

  • 可以选择企业或者个人,进行开发者注册
  • 准备一个个人邮箱和手机号
  • 手持身份证正面照

以上所填信息真实完整,通常最多5个工作日即可审核完成,审核结果通过邮件通知。审核之后才能创建应用。下面的状态是开发者资格审核中的状态。

image-20210415195744822

二、创建应用

注意:本文旨在教大家如何创建一个QQ互联的测试应用,目的是为了学习。所有的填的内容都是以满足本地测试为目的。 如果你真的需要为一个生产环境开发QQ互联登录动能,请先准备好如下内容:

  • 您的应用要申请域名,域名通过备案,要有备案号
  • 想好您的应用名称和应用简介
  • 准备100 * 100px的网站图标

下面我们开始申请,如果您和我一样就是创建一个测试应用,我们什么也不用准备,现在就开始吧。但是测试账号只能使用当前的创建应用的QQ用户进行登录相关测试,这对我们来说就已经足够了。

现在这方法申请测试账号已经不好用了。目前实现QQ还是要申请真实的账号进行测试。域名和网站介绍相关内容都要正规填写,不能填写“127.0.0.1”之类的了。

在开发者资格审核通过之后,再次登录QQ互联网站,创建网站应用。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第14张图片

弹出框内选择创建网站应用

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第15张图片

创建应用的应用名称和应用简介,随便填写一下即可,最后结果:审核不通过。但是不耽误我们测试使用。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第16张图片

最重要的是填写网站域名和回调地址,其他两项随便填。注意一件事情:大家看我的图中,回调地址写的是“/authqq/callback”,这样写不好,我是为了给大家在编码过程中演示一个问题及其解决方案,才使用了这个回调地址。大家在申请的时候回调地址“/auth/qq”是最好的。这个配置是可以修改的,所以申请时写错了也没关系。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第17张图片

填写完成之后等待审核。已经为我们分配了APP ID 和 APP KEY。并且获取了QQ登录接口的权限。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第18张图片

三、概念解释

  • APP ID:在QQ互联创建的应用的唯一标识。
  • APP Key:在QQ互联创建的应用的密钥,与APP ID结合使用,确定该应用访问QQ互联相关接口的合法性。

在进行网站QQ登录功能开发之前,建议开发者一定要仔细阅读该文档:QQ互联网站接入流程 ,这样在我们后续的开发中才能更顺畅。


3.实现QQ登录功能

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第19张图片

零、maven引入Spring Social的类库

<dependency>
    <groupId>org.springframework.socialgroupId>
    <artifactId>spring-social-securityartifactId>
    <version>2.0.0.M4version>
dependency>
<dependency>
    <groupId>org.springframework.socialgroupId>
    <artifactId>spring-social-configartifactId>
    <version>2.0.0.M4version>
dependency>

Spring Social的2.0.0.M4版本与Spring Boot2.0兼容。在Spring Boot2.0环境下不要使用Spring Social的1.x,改动比较大。但是大家也能看出来Spring Social 的2.0.0.M4版本比较新,新到在中央仓库中还没有这个jar,所以在pom.xml中需要我们新增一个非中央仓库地址。

<repositories>
    <repository>
        <id>spring-milestonesid>
        <name>Spring Milestonesname>
        <url>https://repo.spring.io/libs-milestoneurl>
        <snapshots>
            <enabled>falseenabled>
        snapshots>
    repository>
repositories>

一、OAuth2Template改造

在源码分析的章节,我们已经说过OAuth2Operations负责处理OAuth2的授权码请求、Access Token请求 ,即:OAuth2用户认证的标准流程,可以说是整个OAuth2认证中最关键的类。其默认的实现类是OAuth2Template。如果是标准的OAuth2结构,我们完全不需要针对OAuth2的认证过程开发任何代码。
但是QQ有点特殊,我们来看一下OAuth2Template源码:这个函数是请求获取AccessToken的函数,其默认将响应结果转成一个Map数据结构。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第20张图片

我们看一下:获取Access_Token接口文档。其文档中关键截图如下:

image-20210415203625746

从截图中,我们可以看到相应的结果是一个用“&”分割的字符串,这种数据结构既不是JSON,也不是XML,是无法自动转成对象的,所以需要我们手动来改造一下,重写postForAccessGrant方法。

@Slf4j
public class QQOAuth2Template extends OAuth2Template {
    
    public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
        super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
        // 设置带上 client_id、client_secret
        setUseParametersForClientAuthentication(true);
    }

    /**
     * 解析 QQ 返回的令牌
     */
    @Override
    protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
        // 返回格式:access_token=FE04********CCE2&expires_in=7776000&refresh_token=88E4***********BE14
        String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);

        log.info("获取accessToke的响应:"+responseStr);

        String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");

        String accessToken = StringUtils.substringAfterLast(items[0], "=");
        Long expiresIn = new Long(StringUtils.substringAfterLast(items[1], "="));
        String refreshToken = StringUtils.substringAfterLast(items[2], "=");

        return new AccessGrant(accessToken, null, refreshToken, expiresIn);
    }

    /**
     * QQ 响应 ContentType=text/html;因此需要加入 text/html; 的处理器
     */
    @Override
    protected RestTemplate createRestTemplate() {
        RestTemplate restTemplate = super.createRestTemplate();
        restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charsets.UTF_8));
        return restTemplate;
    }
}
  • QQ响应的数据是一个“ContentType=text/html;”的字符串,所以我们要配置StringHttpMessageConverter进行数据接收
  • 重写postForAccessGrant方法,解析QQ响应的字符串,从中解析出accessToken 、expiresIn过期时间和refreshToken
  • 如果需要在请求URL上带上client_id(APP ID)、client_secret(APP KEY),需要设置setUseParametersForClientAuthentication(true)。默认不带这两个参数。

二、QQ用户信息

该用户信息即“SpringSocial社交媒体登录总图”中的User。即:社交媒体平台的用户信息。

@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public class QQUser {
    private String openId;
    //返回码:0表示获取成功
    private String ret;
    //返回错误信息,如果返回成功,错误信息为空串
    private String msg;
    //用户昵称
    private String nickname;
    //用户的头像30x30
    private String figureurl;
    //性别
    private String gender;
}

以上信息是从响应数据中挑选了一些重要的信息进行封装,完整的响应数据结构参考:get_user_info接口定义。因为我们定义的信息不完整,为了避免映射字段找不到的异常,加上@JsonIgnoreProperties(ignoreUnknown = true)注解。该注解如果无法理解,可以自行学习很简单。

三、QQ用户信息获取接口

首先我们定义一个获取QQ用户信息的接口,接口只有一个方法如下:

public interface QQApi {
    QQUser getUserInfo();
}

然后我们来定义QQAPI接口实现类,同时继承AbstractOAuth2ApiBinding。我们在源码解析章节已经说到了,AbstractOAuth2ApiBinding封装了accessToken以及RestTemplate,帮助我们实现HTTP请求的参数携带,以及请求结果到对象的反序列化工作等。

@Slf4j
public class QQApiImpl extends AbstractOAuth2ApiBinding implements QQApi {

    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 QQApiImpl(String accessToken, String appId) {
        //默认是使用header传递accessToken,而QQ比较特殊是用parameter传递token
        super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);

        this.appId = appId;
        this.openId = getOpenId(accessToken);
        log.info("QQ互联平台openId:{}",this.openId);
    }

    //通过接口获取openId
    private String getOpenId(String accessToken) {
        String url = String.format(URL_GET_OPENID, accessToken);
        String result = getRestTemplate().getForObject(url, String.class);
        return StringUtils.substringBetween(result, "\"openid\":\"", "\"}");
    }

    //通过接口获取用户信息
    @Override
    public QQUser getUserInfo() {
        try {
            String url = String.format(URL_GET_USERINFO, appId, openId);
            String result = getRestTemplate().getForObject(url, String.class);
            QQUser userInfo = objectMapper.readValue(result, QQUser.class);
            userInfo.setOpenId(openId);
            return userInfo;
        } catch (Exception e) {
            throw new RuntimeException("获取用户信息失败", e);
        }
    }
}
  • get_user_info接口的定义仍参考QQ:get_user_info接口
  • 获取openId的接口参考:QQ:获取OpenId的接口。openId是用户在社交媒体平台上的唯一标识,准确的说是用于对外提供的用户唯一标识,open的开放的,他们自己内部一定会有一个内部使用的用户唯一标识。
  • AbstractOAuth2ApiBinding 在进行接口请求的时候,默认是使用header传递accessToken,而QQ是要求使用URL参数的方式传递AccessToken。所以我们需要更改一下参数的传递方式,如上文代码中的注释。
  • ObjectMapper 是jackson的类,此处用于将JSON字符串转换为QQUser对象。
  • RestTemplate用于帮助我们实现HTTP请求与响应的处理操作。

四、服务提供商ServiceProvider

我们自己开发的应用通过OAuth2协议与服务提供商进行交互,主要有两部分

  • 一是认证流程,获取授权码、获取AccessToken,这部分是标准的OAuth2认证流程,这个过程大家基本都一样,有差别也很小。由QQOAuth2Template(OAuth2Operations)帮我们完成。
  • 二是请求接口,获取用户数据,获取openId。这部分每个平台都不一样,需要我们自定义完成,如QQApiImpl(QQAPI)

我们需要将这两部分内容的封装结果告知ServiceProvider,从而可以被正确调用。代码如下:

public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQApi> {

    //OAuth2获取授权码的请求地址
    private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";

    //OAuth2获取AccessToken的请求地址
    private static final String URL_GET_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";

    private String appId;

    public QQServiceProvider(String appId, String appSecret) {
        super(new QQOAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_GET_ACCESS_TOKEN));
        this.appId = appId;
    }

    @Override
    public QQApi getApi(String accessToken) {
        return new QQApiImpl(accessToken, appId);
    }
}

五、QQ用户信息适配

不同的社交媒体平台(QQ、微信、GitHub)用户数据结构各式各样,但是Spring Social只认识Connection这一种用户信息结构。所以需要将QQUser与Connection进行适配。代码如下

public class QQApiAdapter implements ApiAdapter<QQApi> {

    //测试Api连接是否可用
    @Override
    public boolean test(QQApi api) {
        return true;
    }
    
    //QQApi 与 Connection 做适配(核心)
    @Override
    public void setConnectionValues(QQApi api, ConnectionValues values) {
        QQUser user = api.getUserInfo();

        values.setDisplayName(user.getNickname());
        values.setImageUrl(user.getFigureurl());
        values.setProviderUserId(user.getOpenId());
    }

    @Override
    public UserProfile fetchUserProfile(QQApi api) {
        return null;
    }

    @Override
    public void updateStatus(QQApi api, String message) {

    }
}

自定义OAuth2ConnectionFactory,通过QQServiceProvider发送请求,通过QQApiAdapter将请求结果转换为Connection。

public class QQConnectionFactory extends OAuth2ConnectionFactory<QQApi> {

    public QQConnectionFactory(String providerId, String appId, String appSecret) {
        super(providerId, new QQServiceProvider(appId, appSecret), new QQApiAdapter());
    }

}

QQConnectionFactory构造方法的第一个参数是providerId可以随便定义,但是最好要具有服务提供商的唯一性和可读性。比如:qq、wechat。第二个参数和第三个参数是在服务提供商创建应用申请的APP ID和APP KEY。

六、Spring Social自动装载配置

@Configuration
@EnableSocial
public class QQAutoConfiguration extends SocialConfigurerAdapter {

    @Resource
    private DataSource dataSource;

    @Override
    public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        JdbcUsersConnectionRepository usersConnectionRepository =
                new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
        // 设置表前缀
        usersConnectionRepository.setTablePrefix("sys_");
        return usersConnectionRepository;
    }

    @Override
    public void addConnectionFactories(ConnectionFactoryConfigurer connectionFactoryConfigurer,
                                       Environment environment) {
        connectionFactoryConfigurer.addConnectionFactory(
                new QQConnectionFactory("qq",  //这里配置什么取决于你的回调地址
                        "你申请的APP ID","你申请的APP KEY")); //这里可以优化为application配置
    }


    @Override
    public UserIdSource getUserIdSource() {
        return new AuthenticationNameUserIdSource();
    }

}
  • UsersConnectionRepository是用于操作数据库UserConnection表的持久层封装。我们可以通过setTablePrefix为UserConnection增加一个表前缀。
  • 向Spring Socail添加一个ConnectionFactory,即:QQConnectionFactory
  • UserIdSource这段代码照着写就行,是Spring Social升级2.0之后做的兼容性不好,UserIdSource需要我们自己创建。
  • 上面的代码可以优化,将一些常量配置抽取到application全局配置文件里面,使用@Value或@ConfigurationProperties注解读取。

七、配置过滤器

@Configuration
public class QQFilterConfigurer extends SpringSocialConfigurer {

    private String filterProcessesUrl;

    public QQFilterConfigurer() { }

    public QQFilterConfigurer(String filterProcessesUrl) {
        this.filterProcessesUrl = filterProcessesUrl;
    }

    @Override
    @SuppressWarnings("unchecked")
    protected <T> T postProcess(T object) {
        SocialAuthenticationFilter filter =  (SocialAuthenticationFilter) super.postProcess(object);
        filter.setFilterProcessesUrl(filterProcessesUrl);
        return (T) filter;
    }
}
  • filterProcessesUrl是用于拦截用户QQ登录请求和认证服务器回调请求的路径,如果不做配置默认是“/auth”。在QQAutoConfiguration加入如下配置
    @Bean
    public SpringSocialConfigurer qqFilterConfig() {
        QQFilterConfigurer configurer = new QQFilterConfigurer("/login");
        configurer.signupUrl("/bind.html");
        configurer.postLoginUrl("/index");
        return configurer;
    }
  • 除了配置filterProcessesUrl,还可以配置诸如:用户绑定界面signupUrl、登录成功跳转页面postLoginUrl、登录失败跳转路径等等。
@Resource
private SpringSocialConfigurer qqFilterConfig;


@Override
protected void configure(HttpSecurity http) throws Exception {
    http.apply(qqFilterConfig).and()
    ...
}
  • 在应用中注入入qqFilterConfig,并在Spring Security配置中将该配置生效,用于使Spring Social过滤器拦截。

七、登录界面

<a href="/login/qq">QQ登录a>
  • 这个登录地址分为两段,login是上文中配置的filterProcessesUrl,qq是上文中配置的providerId。
  • QQ登录路径的配置一定要与filterProcessesUrl和providerId对应上,否则登录请求无法正确拦截
  • 在QQ互联的回调域的配置也必须是http://域名:端口/{filterProcessesUrl}/{providerId},否则用户认证回调无法正确拦截

4.QQ登录功能细节处理

一:创建UserConnection表

如果报下面类似的表找不到的错误,需要先去建表。这张表是用于保存本平台用户与社交媒体平台用户关系的数据库表。

Caused by: com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: 
Table 'testdb.sys_userconnection' doesn't exist

在spring-social-core.jar里面找到org.springframework.social.connect.jdbc.JdbcUsersConnectionsRepository.sql。使用该文件里面的建表语句创建:[tablePrefix]UserConnection,其中tablePrefix替换为我们上一节的自定义配置。比如:“sys_”

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第21张图片

create table sys_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 sys_UserConnection(userId, providerId, rank);

二:session不要设置为无状态模式

Spring Social依赖于session,所以不要设置无状态模式,否则无法正确跳转。

//.sessionManagement()
    //.sessionCreationPolicy(SessionCreationPolicy.STATELESS);

三、QQ登陆之后的用户资源授权原理

通过之前的文章讲解。我们知道ApiAdapter将QQ平台的用户标准数据结构QQUser转换为Spring Social用户标准的数据结构Connection。这两种用户信息仍然代表的是服务提供商的用户信息,那我们就面临着一个问题:如何通过服务提供商的用户信息Connection,得到我们自己开发的系统的用户信息?

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第22张图片

首先,明确数据库里面有一张表UserConnection,这张表有三个核心字段userId、providerId、providerUserId。

  • userId是我们自己开发的应用的用户唯一标识
  • providerId是服务提供商的唯一标识
  • providerUserId是服务提供商用户的唯一标识(对于qq来说就是用户的openId)

通过这张表我们可以确定我们自己开发的应用的用户与服务提供商用户之间的关系,这张表里面的数据是用户注册或者绑定的时候插入的(后文会讲到如何实现)。
其次,现在已知Connection包含providerId和providerUserId,那么如何获取userId?答案就是使用UserConnectionRepository接口。Spring Social通过该接口查询UserConnection表,通过providerId和providerUserId获取userId。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第23张图片

至此,我们就拿到了本地系统的userId。Spring Social提供给我们两个接口,一个是SocialUserDetails,一个是SocialUserDetailsService,大家看到这两个接口是不是有点眼熟?对了,就是和我们使用用户名密码登录情景下的UserDetails和UserDetailsService是一样的,只不过一个是通过username加载UserDetails,一个是通过userId加载UserDetails。SocialUserDetails继承自UserDetails,所以我们的用户信息实体实现SocialUserDetails接口即可。

@Data
@AllArgsConstructor
public class MyUserDetails implements SocialUserDetails {
    
    String password;  //密码
    String username;  //用户名
    boolean accountNonExpired;   //是否没过期
    boolean accountNonLocked;   //是否没被锁定
    boolean credentialsNonExpired;  //是否没过期
    boolean enabled;  //账号是否可用
    Collection<? extends GrantedAuthority> authorities;  //用户的权限集合

    @Override
    public String getUserId() {
        return username;
    }
}

通常我们需要为用户生成一个userId并保存在数据库字段,我们这里就不做的那么麻烦了,直接使用username作为userId。
然后在原有的UserDetailsService实现上,新增SocialUserDetailsService的实现,即实现loadUserByUserId。

@Component
public class MyUserDetailsService implements UserDetailsService ,SocialUserDetailsService {

    @Resource
    private MyUserDetailsServiceMapper myUserDetailsServiceMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        MyUserDetails myUserDetails = getMyUserDetails(username);

        return myUserDetails;
    }


    @Override
    public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
        MyUserDetails myUserDetails = getMyUserDetails(userId);

        return myUserDetails;
    }

    private MyUserDetails getMyUserDetails(String username) {
        //加载基础用户信息
        MyUserDetails myUserDetails = myUserDetailsServiceMapper.findByUserName(username);

        //加载用户角色列表
        List<String> roleCodes = myUserDetailsServiceMapper.findRoleByUserName(username);


        //通过用户角色列表加载用户的资源权限列表
        List<String> authorties = myUserDetailsServiceMapper.findAuthorityByRoleCodes(roleCodes);

        //角色是一个特殊的权限,ROLE_前缀
        roleCodes = roleCodes.stream()
                .map(rc -> "ROLE_" +rc)
                .collect(Collectors.toList());

        authorties.addAll(roleCodes);

        myUserDetails.setAuthorities(
                AuthorityUtils.commaSeparatedStringToAuthorityList(
                        String.join(",",authorties)
                )
        );
        return myUserDetails;
    }


}

至此,我们通过userId得到了UserDetails信息,该信息中既包含用户基础信息,又包含用户权限信息。这样Spring Security就可以根据该信息控制用户登陆之后的访问权限。

至此,QQ登陆的核心功能我们就实现完成了,通过QQ登录按钮也可以正确的访问QQ扫码授权界面。如下:

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第24张图片

四、造数据

因为我们还没有实现用户的注册和绑定功能,所以在qq扫码之后,仍然不能正确登陆。我们可以根据代码中如下打印信息,在userConnection表里面造一条用户注册绑定数据。用来测试。

[INFO ] 2019-12-09 21:38:08,050 com.zimug.bootlaunch.config.auth.qq.QQOAuth2Template - 
获取accessToke的响应:access_token=4953C0123EEDC3BFE11EC7C14C85E7A1&expires_in=7776000&refresh_token=7917C5D8C5483273C8A91E80D1DF2ABB
[INFO ] 2019-12-09 21:38:08,583 com.zimug.bootlaunch.config.auth.qq.QQApiImpl - 
QQ互联平台openId:ACEC828AB2EE206A155418FE2FC8E44C

五、如何测试

测试过程需要注意:如果你在QQ互联注册的时候,填写的回调地址是:“http://www.zimug.com:8888/login/qq” ,然而你测试的时候,访问的是"http://localhost:8888/login.html"进行登录。这样QQ登陆之后是无法正确回调的,因为二者的域名对不上,QQ会做这方面的安全验证。

正确的做法是:你应该使用“http://www.zimug.com:8888/login.html” 进行登录。但是问题又来了,我这个“zimug.com”域名不是指向本机的,另外我也没有互联网ip(向移动、联通申请一个互联网ip要几万元一年)。我们可以本机的c:\windows\system32\drivers\etc\hosts文件来解决这个问题。windows10下面修改此文件非常麻烦,所以我使用一个工具:SwitchHosts

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第25张图片


Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第26张图片


5.QQ登录用户关系绑定

一、用户关系绑定页面

  • 我们已知用户QQ登录请求,需要判断userconnection表中,是否存在本地系统用户与“服务提供商”用户之间的关系,这个关系实际上就是一个绑定状态。
  • 用户登录的时候,当发现数据库中没有绑定关系的时候,跳转到绑定页面进行绑定,也就是我们之前在QQFilterConfigurer中配置的signupUrl。如下:
    @Bean
    public SpringSocialConfigurer qqFilterConfig() {
        QQFilterConfigurer configurer = new QQFilterConfigurer("/login");
        configurer.signupUrl("/bind.html");
        configurer.postLoginUrl("/index");
        return configurer;
    }
    }
  • 为了实现这种绑定状态,我们需要提供一个用户的操作界面,用于绑定本地系统用户与“服务提供商”用户
<h2>用户绑定页面h2>

<form action="/qqbind" method="post">
    <span>用户名称span><input type="text" name="username" id="username"/> <br>
    <span>用户密码span><input type="password" name="password" id="password"/> <br>
    <button type="button" onclick="bind()" >绑定button>
form>


<script>
    function bind() {
        var username = $("#username").val();
        var password = $("#password").val();
        if (username === "" || password === "") {
            alert('用户名或密码不能为空');
            return;
        }
        $.ajax({
            type: "POST",
            url: "/qqbind",
            data: {
                "username": username,
                "password": password
            },
            success: function (json) {
                alert(json.data);
                if(json.isok){
                    location.href = "/index";
                }
            },
            error: function (e) {
                console.log(e.responseText);
            }
        });
    }
script>

同时为了保障bind.html能被访问到,我们需要为“/bind.html”开放permitAll权限

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第27张图片

二、用户关系绑定

开发一个Controller用于用户关系的绑定,需要为请求端点"/qqbind"开放permitAll权限

@RestController
public class QQBindController {
    
    @Resource
    private ProviderSignInUtils providerSignInUtils;

    @Resource
    private MyUserDetailsService myUserDetailsService;

    @Resource
    private PasswordEncoder passwordEncoder;
    
    @PostMapping("/qqbind")
    public AjaxResponse regist(@RequestParam String username,
                               @RequestParam String password, HttpServletRequest request) {

        UserDetails userDetails = myUserDetailsService.loadUserByUserId(username);
        boolean isMatch = passwordEncoder.matches(password,userDetails.getPassword());
        
        if(userDetails.getUsername().equals(username)
                && isMatch){
            //如果用户名密码正确,进行用户关系绑定
            providerSignInUtils.doPostSignUp(username, new ServletWebRequest(request));
            return AjaxResponse.success("用户绑定成功,请重新登录!");
        }else{
            return AjaxResponse.success("用户绑定失败,请检查用户名或密码是否正确!");
        }

    }
}
  • doPostSignUp方法用于绑定本系统用户username与request.getSession中保存的“服务提供商”用户信息,将二者的关系保存到userconnection表中。
  • 其中ProviderSignInUtils 需要我们先做初始化的加载配置工作,可以将该配置放到QQAutoConfiguration中。
@Bean
public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator) {
    return new ProviderSignInUtils(connectionFactoryLocator,
            getUsersConnectionRepository(connectionFactoryLocator)) {
    };
}

当然,此处我们做的是用户关系绑定,实际上还有另外一种情况,就是当前用户还没有注册,也就是没有本系统用户。你也可以跳转到“注册页面”,注册的同时进行本地系统用户与“服务提供商”用户关系的绑定工作。实现原理和上面是几乎一致的。业务略有不同,只不过一个是从用户表加载数据判断用户名密码正确性,一个是向用户信息表里面插入用户信息数据。

三、源码解析

我们在上一节中说到,doPostSignUp方法用于绑定本系统用户username与request.getSession中保存的“服务提供商”用户信息,将二者的关系保存到userconnection表中。那么session中的“服务提供商”用户信息数据,是什么时候放到session里面的呢?
其实我们在源码解析的章节里面已经讲到过:

private Authentication doAuthentication(SocialAuthenticationService<?> authService, HttpServletRequest request, SocialAuthenticationToken token) {
    try {
        if (!authService.getConnectionCardinality().isAuthenticatePossible()) {
            return null;
        } else {
            token.setDetails(this.authenticationDetailsSource.buildDetails(request));
            Authentication success = this.getAuthenticationManager().authenticate(token);
            Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principle type");
            this.updateConnections(authService, token, success);
            return success;
        }
    } catch (BadCredentialsException var5) {
        if (this.signupUrl != null) {
            this.sessionStrategy.setAttribute(new ServletWebRequest(request), 
                ProviderSignInAttempt.SESSION_ATTRIBUTE,
                 new ProviderSignInAttempt(token.getConnection()));
            throw new SocialAuthenticationRedirectException(
                  this.buildSignupUrl(request));
        } else {
            throw var5;
        }
    }
}

上面代码是SocialAuthenticationFilter中的代码,当捕获BadCredentialsException之后,先将 token.getConnection()即“服务提供商”用户信息保存到seesion里面,然后再跳转到SignupUrl。

四、优化

大家看下面的张张图,页面内容是“用户绑定”,既然是用户信息绑定就是“谁”和“谁”绑定。下面这张图,只能体现出本系统用户需要输入用户名密码,然而“服务提供商”的用户信息完全没有任何显示,这样很不友好,所以我们应该优化一下。

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第28张图片

怎么优化呢?上一小节我们已经说了“服务提供商”用户信息,就保存在session里面,我们把它取出来显示在页面上就可以了。怎么取?

@GetMapping("/qquser")
public AjaxResponse regist(HttpServletRequest request) {

    Connection connection =
            providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));

    return AjaxResponse.success(connection.getImageUrl());
}

Connection 中有很多的QQ用户信息,但是不需要全都显示出来,我这里就只返回了QQ用户头像。然后前端bind.html略微改造


~~~
window.onload = function () {
    $.ajax({
        type: "GET",
        url: "/qquser",
        success: function (json) {
            document.getElementById("qqUserImg").src = json.data;
        }
    });
};

Day247.SpringSocia源码分析、QQ互联注册及应用创建&实现QQ登录功能&登录功能细节处理&用户关系绑定 -springsecurity-jwt-oauth2_第29张图片

这里会显示qq用户的头像,qq用户头像有很多种,30*30 等等其他大小的头像,这个位置显示什么,取决于你QQApiAdapter、QQUser、Connection 之间如何做的适配。


学习资料参考:

https://www.kancloud.cn/hanxt/springsecurity/1836919

感谢字母歌!!!

你可能感兴趣的:(springsocia,QQ,qq登录,oauth2)