Spring Security-----SpringSocial社交登录详解

Posted 大忽悠爱忽悠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Security-----SpringSocial社交登录详解相关的知识,希望对你有一定的参考价值。


SpringSocia源码分析

在上一篇文章中我们给大家介绍了OAuth2授权标准,并且着重介绍了OAuth2的授权码认证模式。目前绝大多数的社交媒体平台(QQ、微信、微博等),都是通过OAuth2授权码认证模式对外开放接口(登录认证及用户信息接口等)。但是,我们也看到OAuth2有一定的复杂性,如果所有的代码都由我们自己开发,还是有一定的工作量的。因此,我们完全可以使用Spring Social帮助我们,Spring Social对OAuth2标准进行了完整友好的封装。

本文就通过对Spring Social源码进行一下解析,从而在我们后续开发第三方媒体平台的登录认证功能时,能更加的清晰。


Spring Social结构化角度解析源码

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

  • 首先我们简单回顾一下OAuth2,OAuth2主要包含两部分内容:认证和鉴权。
  • 认证过程就是通过用户授权,获取授权码,最终换取AccessToken的过程。这个过程是标准的OAuth2认证流程,所有平台都遵循,可以认为是一致的。

鉴权过程就是携带AccessToken访问社交媒体平台API接口。当然各平台的用户不同、业务不同,所以提供的的接口不一样。


OAuth2认证源码

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


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


接口资源鉴权

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


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

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


确定用户关系

通过实现上面的代码中的接口,我们自己的应用与社交媒体平台(服务提供商)的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认证登录我们的应用。(这张表里面的数据,是通过注册或者绑定操作加入进去的,与认证、鉴权过程无关)

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


本地应用授权

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

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

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


Spring Social流程角度解析源码

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


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


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


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


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

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


QQ互联注册及应用创建

QQ互联注册开发者

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

可以选择企业或者个人,进行开发者注册

准备一个个人邮箱和手机号

手持身份证正面照

以上所填信息真实完整,通常最多5个工作日即可审核完成,审核结果通过邮件通知。审核之后才能创建应用。


创建应用

如果你真的需要为一个生产环境开发QQ互联登录动能,请先准备好如下内容:

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

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


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

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

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


概念解释

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

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


实现QQ登录功能

maven引入Spring Social的类库

<dependency>
    <groupId>org.springframework.social</groupId>
    <artifactId>spring-social-security</artifactId>
    <version>2.0.0.M4</version>
</dependency>
<dependency>
    <groupId>org.springframework.social</groupId>
    <artifactId>spring-social-config</artifactId>
    <version>2.0.0.M4</version>
</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-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/libs-milestone</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>

OAuth2Template改造

在源码分析的章节,我们已经说过OAuth2Operations负责处理OAuth2的授权码请求、Access Token请求 ,即:OAuth2用户认证的标准流程,可以说是整个OAuth2认证中最关键的类。其默认的实现类是OAuth2Template。如果是标准的OAuth2结构,我们完全不需要针对OAuth2的认证过程开发任何代码。

但是QQ有点特殊,我们来看一下OAuth2Template源码:这个函数是请求获取AccessToken的函数,其默认将响应结果转成一个Map数据结构。

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


从截图中,我们可以看到相应的结果是一个用“&”分割的字符串,这种数据结构既不是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 以上是关于Spring Security-----SpringSocial社交登录详解的主要内容,如果未能解决你的问题,请参考以下文章

Spring全家桶笔记:Spring+Spring Boot+Spring Cloud+Spring MVC

学习笔记——Spring简介;Spring搭建步骤;Spring的特性;Spring中getBean三种方式;Spring中的标签

Spring--Spring入门

Spring框架--Spring事务管理和Spring事务传播行为

Spring框架--Spring事务管理和Spring事务传播行为

Spring框架--Spring JDBC