SpringSecurity开发基于表单的认证(八)

使用Spring Social开发第三方登录

OAuth协议简介

  • 服务提供商 Provider
  • 资源所有者 Resource
  • 第三方应用 Client
    举例来说:很多应用都可以使用微信登录,这些应用从微信中获取相关的用户信息,那么微信就是服务提供商,而资源的所有者就是用户本身,而第三方应用就是真正使用的应用。

在服务提供商中,还有两个概念:

  • 认证服务器 Authorization Server
  • 资源服务器 Resource Server
    还是举上面的例子:应用访问微信时,首先访问的是微信后台的认证服务器,当认证服务器同意以后,返回一个token给第三方应用,然后第三方应用才能拿着这个token去微信后台的资源服务器获取用户信息。

下面是OAuth协议的流程图:


SpringSecurity开发基于表单的认证(八)_第1张图片
oauth协议.png

根据第二步同意授权的不同,可以分为如下几种授权模式:

  1. 授权码模式 authorization code
  2. 密码模式 resource owner password credentials
  3. 客户端模式 client credentials
  4. 简化模式 implicit

这里我们主要将最常见的授权码模式:


SpringSecurity开发基于表单的认证(八)_第2张图片
授权码模式.png

从图中可以看出授权码模式和其他几种模式不同之处有二:

  1. 授权码模式中,第三方应用先将用户导向到认证服务器中,用户同意授权这个操作是在认证服务器中完成的。比如我们选中微信登录,我们使用微信扫二维码登录这一步就是同意授权。而其他登录同意授权是在第三方应用中完成的,这就有可能发生第三方应用伪造用户同意授权的情况。
  2. 认证服务器收到用户同意授权后发送授权码给第三方应用,然后第三方应用再拿着授权码去申请token。而其他授权模式是直接去申请令牌的。

当第三方应用从资源服务器中获取到用户信息后,根据用户信息构建Authentication并放入SecurityContext后就完成了类似于微信登录这种第三方登录的功能了,完整的流程图如下:


SpringSecurity开发基于表单的认证(八)_第3张图片
image.png

而这个流程实际上已经由SpringSocial实现了,所以接下来我们来学习如何使用spring social实现第三方登录。

我们先来了解一下使用spring social过程中的一些接口。
在服务提供商方面封装了几个接口:

  1. 在服务提供商相关方面提供了一个称为ServiceProvider的接口。使用OAuth2.0协议过程中,spring social模块又基于这个接口实现了一个抽象类,称为AbstractOAuth2ServiceProvider,针对不同的第三方登录,如QQ,微信,微博就需要实现对应的实现类。
  2. 在上图中第一步到第五步这些oauth2协议中的步骤实现提供了一个称为OAuth2Operations接口和相应的抽象类OAuth2Template。
  3. 上图中的第六步由于每个应用实现都不同,spring social提供了ApiBinding接口和对应的AbstractOAuth2ApiBinding抽象类。
  4. 前面三个接口都是和服务提供商相关的接口,而第7步只和第三方应用相关的,用户封装用户信息,实现登录,为此spring social 提供了一个connection接口(注意有很多种connection接口,注意区分),相应的,也提供了一个OAuth2Connection抽象类。为方便使用spring social以工厂模式来进行实现,提供了一个ConnectionFactory抽象类以及对应的OAuth2ConnectionFactory实现类,我们只要使用这个实现类就可以创建connection实例并完成封装用户信息的工作。实际上OAuth2ConnectionFactory封装了1,2,3三点中的所有步骤。除此之外,我们获取到qq返回的用户信息后,需要关联到我们第三方系统的用户信息上,这里就需要实现ApiAdapter接口,用于进行用户信息的转换。至于如何将转换后的用户信息保存到我们的数据库中,第三方保存用户数据的表对应操作的类是UsersConnectionRepository接口的实现类JdbcUsersConnectionRepository。上面提及的各个类都会在实现Spring Social功能的配置类中体现出来,模块图如下:


    SpringSecurity开发基于表单的认证(八)_第4张图片
    spring-social.png

首先我们定义保存用户信息的接口QQ:

public interface QQ {
   QQUserInfo getUserInfo();
}

其中的QQUserInfo类就是专门保存QQ用户信息的封装类。

public class QQUserInfo {
}

至于这个类中具体保存了哪些QQ的用户信息,我们稍后再提及。
接下来我们接着实现QQ接口,实现类名为QQImpl,通过它来访问QQ的资源服务器,获取用户信息。并且由上面的流程图中可以看出,所有获取用户信息的api都要继承AbstractOAuth2ApiBinding抽象类,为此QQImpl作为获取用户信息的api也要继承它:

public class QQImpl extends AbstractOAuth2ApiBinding implements QQ{
    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 QQImpl(String accessToken,String appId){
        super(accessToken,TokenStrategy.ACCESS_TOKEN_PARAMETER);
        this.appId =appId;
        String url = String.format(URL_GET_OPENID, accessToken);
        String result = getRestTemplate().getForObject(url, String.class);
        this.openId = StringUtils.substringBetween(result, "\"openid\"", "}");
    }
    
    @Override
    public QQUserInfo getUserInfo(){
        String url = String.format(URL_GET_USERINFO, appId,openId);
        String result = getRestTemplate().getForObject(url, String.class);
        try {
            return objectMapper.readValue(result, QQUserInfo.class);//将字符串转换为对象
        } catch (Exception e) {
            throw new RuntimeException("获取用户信息失败",e);
        }
    }
}

在我们讲解QQImpl 类的具体代码之前,我们先看一下它继承的抽象类AbstractOAuth2ApiBinding:

public abstract class AbstractOAuth2ApiBinding implements ApiBinding, InitializingBean {
    private final String accessToken;
    private RestTemplate restTemplate;
......

AbstractOAuth2ApiBinding 这个抽象类中的属性accessToken就是qq返回给第三方应用的token,第三方应用拿着这个token去访问资源服务器并获取具体的用户信息;restTemplate属性封装了第三方应用发起HTTP请求获取用户信息的功能。
在获取用户信息的过程中涉及到几个概念,首先QQ要知道想获取谁的信息,因此用openId作为这个用户在QQ系统中的唯一标识。而这个openId起初是不知道的,也是需要访问QQ的资源服务器并且返回给应用的;其次在获取用户信息的过程中,qq还需要知道是哪个应用想获取到用户信息,这里使用appId来表示某个在QQ互联平台注册在案的第三方应用。所以在QQImpl 这个实现类的构造函数中:
第一句表示调用父类的构造函数,使用token的策略是把token作为参数传入(查看父类的构造函数可以发现,父类默认的构造函数中token是放在查询的请求头中,这与QQ文档中要求的token放在请求的参数中是相违背的,为此此处显式调用设置token存放在请求中的位置);第二句的应用Id,即AppId作为参数传入,表示应用Id,getRestTemplate().getForObject(url, String.class);这一句则是借助restTemplate对象的getForObject方法发起一个RESTFul请求,来获取用户的openId(获取openId的url就是https://graph.qq.com/oauth2.0/me?access_token=%s,参数为access_token)。
构造函数获取到openId后,实现的getUserInfo方法就是发起获取用户信息的请求,这个请求需要三个请求:access_token,openId,appId。为完整的保存用户信息,我们遵照QQ互联的文档来实现QQUserInfo这个类:

public class QQUserInfo {

    public String getRet() {
        return ret;
    }
    public void setRet(String ret) {
        this.ret = ret;
    }
    public String getMsg() {
        return msg;
    }
    public void setMsg(String msg) {
        this.msg = msg;
    }
    public String getNickname() {
        return nickname;
    }
    public void setNickname(String nickname) {
        this.nickname = nickname;
    }
    public String getFigureurl() {
        return figureurl;
    }
    public void setFigureurl(String figureurl) {
        this.figureurl = figureurl;
    }
    public String getFigureurl_1() {
        return figureurl_1;
    }
    public void setFigureurl_1(String figureurl_1) {
        this.figureurl_1 = figureurl_1;
    }
    public String getFigureurl_2() {
        return figureurl_2;
    }
    public void setFigureurl_2(String figureurl_2) {
        this.figureurl_2 = figureurl_2;
    }
    public String getFigureurl_qq_1() {
        return figureurl_qq_1;
    }
    public void setFigureurl_qq_1(String figureurl_qq_1) {
        this.figureurl_qq_1 = figureurl_qq_1;
    }
    public String getFigureurl_qq_2() {
        return figureurl_qq_2;
    }
    public void setFigureurl_qq_2(String figureurl_qq_2) {
        this.figureurl_qq_2 = figureurl_qq_2;
    }
    public String getGender() {
        return gender;
    }
    public void setGender(String gender) {
        this.gender = gender;
    }
    public String getIs_yellow_vip() {
        return is_yellow_vip;
    }
    public void setIs_yellow_vip(String is_yellow_vip) {
        this.is_yellow_vip = is_yellow_vip;
    }
    public String getVip() {
        return vip;
    }
    public void setVip(String vip) {
        this.vip = vip;
    }
    public String getYellow_vip_level() {
        return yellow_vip_level;
    }
    public void setYellow_vip_level(String yellow_vip_level) {
        this.yellow_vip_level = yellow_vip_level;
    }
    public String getLevel() {
        return level;
    }
    public void setLevel(String level) {
        this.level = level;
    }
    public String getIs_yellow_year_vip() {
        return is_yellow_year_vip;
    }
    public void setIs_yellow_year_vip(String is_yellow_year_vip) {
        this.is_yellow_year_vip = is_yellow_year_vip;
    }
    
    public String getOpenId(){
        return openId;
    }
    public void setOpenId(String openId){
        this.openId = openId;
    }
    
    private String openId;
    private String ret; //返回码
    private String msg; //如果ret<0,会有相应的错误信息提示,返回数据全部用UTF-8编码。
    private String nickname;    //用户在QQ空间的昵称。
    private String figureurl;   //大小为30×30像素的QQ空间头像URL。
    private String figureurl_1; //大小为50×50像素的QQ空间头像URL。
    private String figureurl_2; //大小为100×100像素的QQ空间头像URL。
    private String figureurl_qq_1;  //大小为40×40像素的QQ头像URL。
    private String figureurl_qq_2;  //大小为100×100像素的QQ头像URL。需要注意,不是所有的用户都拥有QQ的100x100的头像,但40x40像素则是一定会有。
    private String gender;  //性别。 如果获取不到则默认返回"男"
    private String is_yellow_vip;   //标识用户是否为黄钻用户(0:不是;1:是)。
    private String vip; //标识用户是否为黄钻用户(0:不是;1:是)
    private String yellow_vip_level;    //黄钻等级
    private String level;   //黄钻等级
    private String is_yellow_year_vip;  //标识是否为年费黄钻用户(0:不是; 1:是)
    
}

QQUserInfo这个类中保存了完整的用户信息。、
上述代码实现了获取用户信息的API,由之前的模块图中可以看出,ServiceProvider模块包含了OAuth2Operations+api,所以我们接下来实现一下ServiceProvider:

public class QQServiceProvider extends AbstractOAuth2ServiceProvider{
    private String appId;
    private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
    private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";
    
    public QQServiceProvider(String appId,String appSecret) {
        super(new OAuth2Template(appId,appSecret,URL_AUTHORIZE,URL_ACCESS_TOKEN));
    }
    
    @Override
    public QQ getApi(String accessToken) {
        // TODO Auto-generated method stub
        return new QQImpl(accessToken,appId);
    }
}

QQServiceProvider类的构造函数中,传入的参数appId和appSecret都是第三方应用在QQ平台上的唯一标识(相当于用户名和密码),调用的父类的构造函数中,参数URL_AUTHORIZE表示的是引导应用到认证服务器进行授权的url地址,参数URL_ACCESS_TOKEN则是第三方应用声明access_token的地址,保存好这几个地址后,后续配置中引入这个QQServiceProvider类后就能保证OAUTH流程每一步都能够完成。
QQServiceProvider类复写的getApi方法中,将获取用户信息的QQImpl实例返回。这样ServiceProvider=
restTemplate+api的结构就完成了。
接下来我们继续实现模块图的左边部分,即ConnectionFactory=ServiceProvider+ApiAdapter。ServiceProvider我们之前已经实现了,ApiAdapter就是之前提过的QQ返回的用户信息和应用中实际保存的用户信息的适配类。

public class QQAdapter implements ApiAdapter{
    @Override
    public boolean test(QQ api) {
        return true;
    }

    @Override
    public void setConnectionValues(QQ api, ConnectionValues values) {
        QQUserInfo userInfo = api.getUserInfo();
        
        values.setDisplayName(userInfo.getNickname());
        values.setImageUrl(userInfo.getFigureurl_qq_1());
        values.setProfileUrl(null);
        values.setProviderUserId(userInfo.getOpenId());
    }
    @Override
    public UserProfile fetchUserProfile(QQ api) {
        // TODO Auto-generated method stub
        return null;
    }
    @Override
    public void updateStatus(QQ api, String message) {
        // TODO Auto-generated method stub
    }
}

QQAdapter这个适配类中test方法表示qq api是否可用,此处直接返回true。
setConnectionValues方法中进行的就是用户数据的适配工作,通过调用QQ实现类的getUserInfo方法获取到QQ返回的用户信息,并将几个用户信息设置到ConnectionValues对象中。QQAdapter 写好之后,ConnectionFactory需要的两个部分就都实现了,接下来我们就开始实现ConnectionFactory这个类了:

public class QQConnectionFactory extends OAuth2ConnectionFactory{
    public QQConnectionFactory(String providerId,String appId,String appSecret) {
        super(providerId, new QQServiceProvider(appId,appSecret), new QQAdapter());
    }
}

QQConnectionFactory的实现很简单,符合了ConnectionFactory=ServiceProvider+ApiAdapter这个结构。这里出现了参数providerId是服务提供商的id,在实现QQConnectionFactory类的时候作为参数传入。
实现了QQConnectionFactory类后,我们最后需要的就是配置应用最后保存到数据库这一步,spring social框架给我们提供的实现类JdbcUsersConnectionRepository,我们需要在配置类中配置这个JdbcUsersConnectionRepository类即可。下面是spring social配置类的具体代码:

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
    
    @Autowired
    private DataSource dataSource;
    
    @Autowired
    private SecurityProperties securityProperties;
    
    @Override
    public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        return new JdbcUsersConnectionRepository(dataSource,connectionFactoryLocator,Encryptors.noOpText());
    }
    
    @Bean
    public SpringSocialConfigurer yunSocialSecurityConfig(){
        String filterProcessUrl = securityProperties.getSocial().getFilterProcessesUrl();
        YunSpringSocialConfigurer yunSpringSocialConfigurer = new YunSpringSocialConfigurer(filterProcessUrl);
        return yunSpringSocialConfigurer;
    }
}

@Configuration注解表示这个一个配置类,@EnableSocial表示打开了spring social 这个功能。
配置类的getUsersConnectionRepository方法中执行了代码return new JdbcUsersConnectionRepository(dataSource,connectionFactoryLocator,Encryptors.noOpText());中传入的三个参数意思是1、设置了dataSource,即数据库信息2、connectionFactoryLocator参数直接从外面传入,直接使用。3、第三个参数是对用户信息的加解密设置,此处Encryptors.noOpText()表示不作加解密,这样在调试的时候方便查看数据库实际效果。至于数据库中最终保存用户信息的是哪张表呢?spring social模块给我们提供了一个默认的表,这个表需要我们手动创建,建表的sql路径如图:

SpringSecurity开发基于表单的认证(八)_第5张图片
sql.png
打开后sql如下:

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

UserConnection表就是最终保存了用户信息的表,这个表中userId和providerUserId是一一对应的,即这个用户在第三方应用中的id和在服务提供商中的id一一对应。当我们从这个表中获取到userId,该如何获取用户的具体信息呢?这时候就得修改之前写的MyUserDetails类了:

@Component
public class MyUserDetailsService implements UserDetailsService,SocialUserDetailsService{
    private Logger logger =LoggerFactory.getLogger(getClass());
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return buildUser(username);
    }
    @Override
    public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
        return buildUser(userId);
    }
    private SocialUserDetails buildUser(String userId){
        String password = passwordEncoder.encode("123456");
        return new SocialUser(userId,password,
                true, true, true, true, 
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

修改之处在于MyUserDetailsService又实现了SocialUserDetailsService接口,这个接口代码如下:

public interface SocialUserDetailsService {
    /**
     * @see UserDetailsService#loadUserByUsername(String)
     * @param userId the user ID used to lookup the user details
     * @return the SocialUserDetails requested
     */
    SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException;
}

public interface SocialUserDetails extends UserDetails {
    /**
     * The user's identity at the provider.
     * Might be same as {@link #getUsername()} if users are identified by username
     * @return user's id used to assign connections
     */
    String getUserId();
}

不难发现SocialUserDetailsService接口通过用户id来获取用户信息,并且SocialUserDetails接口也是继承的UserDetails接口。修改后的MyUserDetailsService类loadUserByUserId方法表示可以通过用户ID来获取用户信息。
除了需要配置类配置用户数据保存到数据库以外,还需要配置我们之前写好的QQConnectionFactory实例,这个配置类的实现如下:

@Configuration
@ConditionalOnProperty(prefix = "imooc.security.social.qq", name = "app-id")
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());
    }
}

这个类使用注解@Configuration表示这是一个注解类,此外注解@ConditionalOnProperty(prefix = "imooc.security.social.qq", name = "app-id")表示属性文件中前缀为“imooc.security.social.qq”的属性并且名字为app-id的时候才会加载这个配置类。这个配置类复写的SocialAutoConfigurerAdapter 类的createConnectionFactory()方法就是配置我们实现的QQConnectionFactory类,它是后续代我们自动跑OAUTH流程的依据。最后我们看一下我们保持配置的属性文件的内容:

spring.datasource.driver-class-name = com.mysql.jdbc.Driver
spring.datasource.url= jdbc:mysql://127.0.0.1:3306/imooc-demo?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
spring.datasource.username = root
spring.datasource.password = 123456
server.port = 80

这里要特别注意一个问题,就是服务的端口号,此处我们将其设置为80端口,为什么设为80端口呢,因为浏览器发请求的时候,如果请求URL中没有写上端口号,那就是默认发送到80端口,而tomcat默认的端口号是8080,如果不配置server.port=80,则发送请求http://localhost/login.html页面会报错;如果不报错的话,可以改成访问http://localhost:8080/login.html,改成这样页面能成功访问,但是在我们进行qq登录的时候引导用户到认证服务器并授权后跳转回引导的地址,所以要求我们发起qq登录的请求和我们在qq互联系统上配置的请求要保持一致,否则跳转回来的时候,携带授权码的请求就不会被第三方应用处理,这就导致了发生错误redirect uri is illegal:

image.png

所以最终的方法就是设置server.port=80,这样 http://localhost/login.html也能访问成功,引导用户到认证服务器也能成功。
要想社交登录成功,我们最后还需要将spring social的过滤器配到我们的过滤器链中。

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {
    
    @Autowired
    private DataSource dataSource;
    
    @Autowired
    private SecurityProperties securityProperties;
    
    @Override
    public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        return new JdbcUsersConnectionRepository(dataSource,connectionFactoryLocator,Encryptors.noOpText());
    }
    
    @Bean
    public SpringSocialConfigurer yunSocialSecurityConfig(){
        String filterProcessUrl = securityProperties.getSocial().getFilterProcessesUrl();
        YunSpringSocialConfigurer yunSpringSocialConfigurer = new YunSpringSocialConfigurer(filterProcessUrl);
        return yunSpringSocialConfigurer;
    }
}

这个过滤器配置的bean我们写在SocialConfig类中的yunSocialSecurityConfig方法中。这个方法最终返回一个YunSpringSocialConfigurer对象,这个对象的实现如下:

public class YunSpringSocialConfigurer extends SpringSocialConfigurer{
    
    private String filterProcessesUrl;
    
    public YunSpringSocialConfigurer(String filterProcessesUrl){
        this.filterProcessesUrl = filterProcessesUrl;
    }
    
    @Override
    protected  T postProcess(T object) {
        SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
        filter.setFilterProcessesUrl(this.filterProcessesUrl);
        return super.postProcess(object);
    }
}

这个继承的父类SpringSocialConfigurer类中进行了过滤器的配置,在SpringSocialConfigurer类的源码中发现,在configure方法中先实例化了一个过滤器,然后将SocialAuthenticationFilter 过滤器加到了过滤器链中。

@Override
    public void configure(HttpSecurity http) throws Exception {     
        ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
        UsersConnectionRepository usersConnectionRepository = getDependency(applicationContext, UsersConnectionRepository.class);
        SocialAuthenticationServiceLocator authServiceLocator = getDependency(applicationContext, SocialAuthenticationServiceLocator.class);
        SocialUserDetailsService socialUsersDetailsService = getDependency(applicationContext, SocialUserDetailsService.class);
        
        SocialAuthenticationFilter filter = new SocialAuthenticationFilter(
                http.getSharedObject(AuthenticationManager.class), 
                userIdSource != null ? userIdSource : new AuthenticationNameUserIdSource(), 
                usersConnectionRepository, 
                authServiceLocator);
...
              http.authenticationProvider(
                new SocialAuthenticationProvider(usersConnectionRepository, socialUsersDetailsService))
            .addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class);

社交登录的过滤器名为SocialAuthenticationFilter。在其源代码中发现:

private static final String DEFAULT_FILTER_PROCESSES_URL = "/auth";

这个常量的意思是默认过滤器处理的请求url是/auth,SpringSocialConfigurer类中在过滤器链中加过滤器之前调用了方法postProcess(filter),我们要想修改这个默认处理的url,就可以对这个方法进行override,然后改成我们想要的url。这里我们实现的配置类为YunSpringSocialConfigurer ,它继承了SpringSocialConfigurer类,我们最终就是拿这个类进行配置。为了实现上面讲的修改过滤器的url,我们override了postProcess方法,这个方法内部定义并实现了一个新的过滤器,并设置了过滤器处理的url。
好了,我们需要的配置类都已就位,我们最后在主配置类的configure方法中对其配置:

@Override
    protected void configure(HttpSecurity http) throws Exception{
        ValidateCodeFilter validateCodeFilter =new ValidateCodeFilter();

        validateCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailHandler);
        
        http
        
            /*
             * 在过滤器链中添加过滤器
             */
            .apply(yunSocialSecurityConfig)
            .and()
        
            /*
             * 在UsernamePasswordAuthenticationFilter过滤器之前添加短信验证码过滤器
             */
            .addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
            
            /*
             * 表单登录适用于浏览器向restful api发起请求;如果是后台程序直接发起请求访问restful api,则设为HTTP BASIC模式
             */
            .formLogin()
            .loginPage("/login.html") //跳转的登录页/authentication/require
            .loginProcessingUrl("/authentication/form") //登录时的请求
            .successHandler(myAuthenticationSuccessHandler) //表单登录成功时使用我们自己写的处理类
            .failureHandler(myAuthenticationFailHandler) //表单登录失败时使用我们自己写的处理类
            .and()
            
            /*
             * 调用rememberMe()方法启用记住我功能,通过在cookie中存储一个token完成
             */
            .rememberMe()
            //指定保存token的仓库,此处实现为保存到指定的数据库中
            .tokenRepository(persistenceTokenRepository())
            //tokenValiditySeconds()指定token的有效时间
            .tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())
            //指定登录用户、密码和权限
            .userDetailsService(userDetailsService) 
            .and()
            
            /*
             * 调用HttpSecurity类的authorizeRequests()方法所返回的对象的方法来配置请求级别的安全性细节
             */
            .authorizeRequests()
            //antMatchers()对指定路径的请求需要进行认证,这个方法以ant开头表示路径支持Ant风格的通配符,permitAll()方法运行请求没有任何的安全限制
            .antMatchers(securityProperties.getBrowser().getLoginPage(),"/code/image").permitAll()
            //anyRequest()指定了匹配之外的所有请求,authenticated()要求在执行该请求时,必须已经登录了应用
            .anyRequest().authenticated()
            .and()
            
            /*
             * 禁用CSRF(跨站请求伪造)防护功能
             */
            .csrf().disable();
    }

其中的

           http .apply(yunSocialSecurityConfig)
            .and()

就是把我们设置的配置类添加进来。最后我们要看一下配置文件,因为我还没有在qq互联平台上申请开发账户,所以这里用的是网友提供的账号和密码,网友设置的返回url为www.ictgu.cn,为了能成功返回,需要修改C:\Windows\System32\drivers\etc文件,添加一行:、

127.0.0.1 www.ictgu.cn
spring.security.social.qq.app-id = 101386962
spring.security.social.qq.app-secret = 2a0f820407df400b84a854d054be8b6a

这样我们启动应用后,访问www.ictgu.cn/login.html后,先显示之前写的登录页:

SpringSecurity开发基于表单的认证(八)_第6张图片
image.png

然后点击QQ登录按钮后:
SpringSecurity开发基于表单的认证(八)_第7张图片
image.png

说明已经成功引导用户到认证服务器上了,当我们用手机扫描登录后,页面又重新跳转到了登录页:
SpringSecurity开发基于表单的认证(八)_第8张图片
image.png

为什么会重新跳转到登录页面呢?为了能深入理解整个机制我们接下来来分析一下spring social的部分源码。
下面这张图是代码执行的流程图:
SpringSecurity开发基于表单的认证(八)_第9张图片
image.png

首先我们从第一步请求授权的过滤器SocialAuthenticationFilter类开始:

    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (detectRejection(request)) {
            if (logger.isDebugEnabled()) {
                logger.debug("A rejection was detected. Failing authentication.");
            }
            throw new SocialAuthenticationException("Authentication failed because user rejected authorization.");
        }
        
        Authentication auth = null;
        Set authProviders = authServiceLocator.registeredAuthenticationProviderIds();
        String authProviderId = getRequestedProviderId(request);
        if (!authProviders.isEmpty() && authProviderId != null && authProviders.contains(authProviderId)) {
            SocialAuthenticationService authService = authServiceLocator.getAuthenticationService(authProviderId);
            auth = attemptAuthService(authService, request, response);
            if (auth == null) {
                throw new AuthenticationServiceException("authentication failed");
            }
        }
        return auth;
    }

这个方法用户处理请求,我们先关注代码:

auth = attemptAuthService(authService, request, response);

即对应流程图中SocialAuthenticationService类如何创建出Authentication认证信息的。attemptAuthService方法实现如下:

    private Authentication attemptAuthService(final SocialAuthenticationService authService, final HttpServletRequest request, HttpServletResponse response) 
            throws SocialAuthenticationRedirectException, AuthenticationException {

        final SocialAuthenticationToken token = authService.getAuthToken(request, response);
        if (token == null) return null;
        
        Assert.notNull(token.getConnection());
        
        Authentication auth = getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            return doAuthentication(authService, request, token);
        } else {
            addConnection(authService, request, token, auth);
            return null;
        }       
    }

在这个方法中,传入的SocialAuthenticationService对象通过调用getAuthToken方法,直接就获取了通过授权码来获取到的token,由于SocialAuthenticationService是一个接口,具体的实现在SocialAuthenticationService接口的实现类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 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;
        }
    }

实际上过滤器拦截的请求“\login\qq”既是第一步将用户导向认证服务器的请求,也是认证服务器返回授权码给第三方用户的返回请求,所以这个方法第一句就对这两种请求进行区分。

  1. 如果授权码code为空,则说明是第一次登陆,则抛出一个重定向的异常SocialAuthenticationRedirectException,参数getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params)通过打断点发现这个地址就是我们之前在QQConnectionFactory中的QQServiceProvider参数中配置的地址拼凑起来的,通过这个地址将用户导向到QQ的用户登录页面。
  2. 如果授权码不为空,说明是QQ携带授权码返回给第三方应用的请求。代码AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);就是通过ConnectionFactory中的QQServiceProvider中的OAuth2Operations实现类来做拿着授权码去获取access_token的操作。OAuth2Operations接口的实现是我们之前配的OAuth2Template类。在这个类中发起获取token的请求:
getRestTemplate().postForObject(accessTokenUrl, parameters, Map.class)

postForObject方法的最后一个参数是Map,意味着预期返回结果可以转成Map格式,即认证服务器要返回类似于JSON格式的内容。但我们实际在QQ互联的官网中《Step2:通过Authorization Code获取Access Token》文档中发现返回的token格式如下:


SpringSecurity开发基于表单的认证(八)_第10张图片
token.png

即token是一个字符串类型的,而不是标准的JSON格式,这就需要我们自己截取字符串来获取token,有效期和其他参数,为此我们之前在QQServiceProvider中的写法:

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

OAuth2Template这个类不能直接使用了,我们需要自行实现一个符合我们实际要求的QQOAuth2Template类,这个类override OAuth2Template类,复写它的方法,来发起符合要求的请求。

public class QQOAuth2Template extends OAuth2Template {

    private Logger logger = LoggerFactory.getLogger(getClass());
    
    public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
        super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
        setUseParametersForClientAuthentication(true);
    }
    
    @Override
    protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap parameters) {
        String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);
        logger.info(responseStr);

        String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");
        
        String accessToken = StringUtils.substringAfterLast(items[0], "=");
        Long expireIn = new Long(StringUtils.substringAfterLast(items[1], "="));
        String refreshToken = StringUtils.substringAfterLast(items[2], "=");
        
        return new AccessGrant(accessToken,null,refreshToken,expireIn);
    }

    @Override
    protected RestTemplate createRestTemplate() {
        RestTemplate restTemplate = super.createRestTemplate();
        restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
        return restTemplate;
    }
    
}

这里复写了postForAccessGrant方法和createRestTemplate方法。在原OAuth2Template类中postForAccessGrant方法实现如下:

    protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap parameters) {
        return extractAccessGrant(getRestTemplate().postForObject(accessTokenUrl, parameters, Map.class));
    }
    private AccessGrant extractAccessGrant(Map result) {
        return createAccessGrant((String) result.get("access_token"), (String) result.get("scope"), (String) result.get("refresh_token"), getIntegerValue(result, "expires_in"), result);
    }

即通过获取token的请求accessTokenUrl来获取token等信息后封装在AccessGrant实例中,而且要求返回格式为map,为此我们复写的这个方法要求返回的格式为String.class,即返回字符串responseStr,并截取出accessToken,expireIn ,refreshToken 这三个参数,最后保存到AccessGrant对象中。此外复写的createRestTemplate方法也是为了RestTemplate 实例可以接受返回的UTF-8格式的字符串。复写了这两个方法后,我们通过使用这个QQOAuth2Template类就可以正常收到返回的字符串格式的token信息了,我们在QQServiceProvider中使用这个QQOAuth2Template类:

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

这样就能成功获取到access_token了,我们重新回到OAuth2AuthenticationService的getAuthToken方法中来接着走:

      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 connection = getConnectionFactory().createConnection(accessGrant);
      return new SocialAuthenticationToken(connection, null);

我们已经获取到保存token信息的accessGrant实例了,接下来看一下getConnectionFactory().createConnection(accessGrant);这句代码具体做了什么。getConnectionFactory()返回的是OAuth2ConnectionFactory实例,它的createConnection实现如下:

    public Connection createConnection(AccessGrant accessGrant) {
        return new OAuth2Connection(getProviderId(), extractProviderUserId(accessGrant), accessGrant.getAccessToken(),
                accessGrant.getRefreshToken(), accessGrant.getExpireTime(), getOAuth2ServiceProvider(), getApiAdapter());       
    }

我们再继续往OAuth2Connection()构造函数里面看:

    public OAuth2Connection(String providerId, String providerUserId, String accessToken, String refreshToken, Long expireTime,
            OAuth2ServiceProvider serviceProvider, ApiAdapter apiAdapter) {
        super(apiAdapter);
        this.serviceProvider = serviceProvider;
        initAccessTokens(accessToken, refreshToken, expireTime);
        initApi();
        initApiProxy();
        initKey(providerId, providerUserId);
    }

   protected void initKey(String providerId, String providerUserId) {
        if (providerUserId == null) {
            providerUserId = setValues().providerUserId;
        }
        key = new ConnectionKey(providerId, providerUserId);        
    }

    private ServiceProviderConnectionValuesImpl setValues() {
        ServiceProviderConnectionValuesImpl values = new ServiceProviderConnectionValuesImpl();
        apiAdapter.setConnectionValues(getApi(), values);
        valuesInitialized = true;
        return values;
    }

经过我狗刨一样的寻找,终于找到了 apiAdapter.setConnectionValues(getApi(), values);这句实际调用了我们之前写的QQAdapter类中的setConnectionValues方法:

public class QQAdapter implements ApiAdapter{
    @Override
    public void setConnectionValues(QQ api, ConnectionValues values) {
        QQUserInfo userInfo = api.getUserInfo();
        
        values.setDisplayName(userInfo.getNickname());
        values.setImageUrl(userInfo.getFigureurl_qq_1());
        values.setProfileUrl(null);
        values.setProviderUserId(userInfo.getOpenId());
    }
}

而程序QQUserInfo userInfo = api.getUserInfo();又调用到了我们之前写的QQImpl的getUserInfo()方法:

    @Override
    public QQUserInfo getUserInfo(){
        String url = String.format(URL_GET_USERINFO, appId,openId);
        String result = getRestTemplate().getForObject(url, String.class);
        QQUserInfo userInfo = null;
        
        try {
            userInfo = objectMapper.readValue(result, QQUserInfo.class);
            userInfo.setOpenId(this.openId);
            return userInfo;
        } catch (Exception e) {
            throw new RuntimeException("获取用户信息失败",e);
        }
    }

所以经过我前面这几个狗刨的啰嗦后,创建Connection的时候,最终就会跳到QQImpl的getUserInfo方法中拿着access_token去获取用户信息。在这个方法中打上断点后,请求返回的result的值如下:

{
    "ret": 0,
    "msg": "",
    "is_lost":0,
    "nickname": "云师兄",
    "gender": "男",
    "province": "浙江",
    "city": "衢州",
    "year": "1993",
    "figureurl": "http:\/\/qzapp.qlogo.cn\/qzapp\/101386962\/E78AC2A3A18C6F93015066E17FF4EDC3\/30",
    "figureurl_1": "http:\/\/qzapp.qlogo.cn\/qzapp\/101386962\/E78AC2A3A18C6F93015066E17FF4EDC3\/50",
    "figureurl_2": "http:\/\/qzapp.qlogo.cn\/qzapp\/101386962\/E78AC2A3A18C6F93015066E17FF4EDC3\/100",
    "figureurl_qq_1": "http:\/\/q.qlogo.cn\/qqapp\/101386962\/E78AC2A3A18C6F93015066E17FF4EDC3\/40",
    "figureurl_qq_2": "http:\/\/q.qlogo.cn\/qqapp\/101386962\/E78AC2A3A18C6F93015066E17FF4EDC3\/100",
    "is_yellow_vip": "0",
    "vip": "0",
    "yellow_vip_level": "0",
    "level": "0",
    "is_yellow_year_vip": "0"
}

成功返回QQUserInfo结构的用户信息。
我们重新回到OAuth2AuthenticationService的getAuthToken方法中来接着走:

      Connection connection = getConnectionFactory().createConnection(accessGrant);
      return new SocialAuthenticationToken(connection, null);

最后一步就是创建一个SocialAuthenticationToken,这个方法就结束了,我们也获取到了用户信息,成功走完了OAuth流程。
我们在接着回到SocialAuthenticationFilter类的attemptAuthService方法中:

    private Authentication attemptAuthService(final SocialAuthenticationService authService, final HttpServletRequest request, HttpServletResponse response) 
            throws SocialAuthenticationRedirectException, AuthenticationException {

        final SocialAuthenticationToken token = authService.getAuthToken(request, response);
        if (token == null) return null;
        
        Assert.notNull(token.getConnection());
        
        Authentication auth = getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            return doAuthentication(authService, request, token);
        } else {
            addConnection(authService, request, token, auth);
            return null;
        }       
    }

我们现在已经通过OAuth2AuthenticationService实例获取到封装用户信息的SocialAuthenticationToken对象。
在doAuthentication的方法实现中:

    private Authentication doAuthentication(SocialAuthenticationService authService, HttpServletRequest request, SocialAuthenticationToken token) {
        try {
            if (!authService.getConnectionCardinality().isAuthenticatePossible()) return null;
            token.setDetails(authenticationDetailsSource.buildDetails(request));
            Authentication success = getAuthenticationManager().authenticate(token);
            Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principle type");
            updateConnections(authService, token, success);         
            return success;
        } catch (BadCredentialsException e) {
            // connection unknown, register new user?
            if (signupUrl != null) {
                // store ConnectionData in session and redirect to register page
                sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection()));
                throw new SocialAuthenticationRedirectException(buildSignupUrl(request));
            }
            throw e;
        }
    }

执行了代码:

Authentication success = getAuthenticationManager().authenticate(token);

此处获取的manager就是AuthenticationManager接口的实现类,正好对应了流程图中token信息传递到AuthenticationManager这一步,从流程图中看到,AuthenticationManager这个接口的实现类是ProviderManager,这个类的authenticate方法实现如下:

    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        Class toTest = authentication.getClass();
        AuthenticationException lastException = null;
        Authentication result = null;
        boolean debug = logger.isDebugEnabled();

        for (AuthenticationProvider provider : getProviders()) {
            if (!provider.supports(toTest)) {
                continue;
            }
            try {
                result = provider.authenticate(authentication);
                if (result != null) {
                    copyDetails(authentication, result);
                    break;
                }
            }

发现代码中调用了:result = provider.authenticate(authentication);说明具体的认证过程封装在AuthenticationProvider这个接口中,这也证实了流程图中AuthenticationProvider这一个节点,从流程图中得知,实现类是SocialAuthenticationProvider,我们查看它的authenticate方法实现:

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Assert.isInstanceOf(SocialAuthenticationToken.class, authentication, "unsupported authentication type");
        Assert.isTrue(!authentication.isAuthenticated(), "already authenticated");
        SocialAuthenticationToken authToken = (SocialAuthenticationToken) authentication;
        String providerId = authToken.getProviderId();
        Connection connection = authToken.getConnection();

        String userId = toUserId(connection);
        if (userId == null) {
            throw new BadCredentialsException("Unknown access token");
        }

        UserDetails userDetails = userDetailsService.loadUserByUserId(userId);
        if (userDetails == null) {
            throw new UsernameNotFoundException("Unknown connected account id");
        }

        return new SocialAuthenticationToken(connection, userDetails, authToken.getProviderAccountData(), getAuthorities(providerId, userDetails));
    }

上述代码中,首先从token中获取了providerId,即服务提供商的id,即“qq”,又从token中获取了封装用户信息的connection变量,String userId = toUserId(connection);这句代码意思是从connection变量中获取服务提供商中的用户id所对应的第三方应用中的用户id,由于我们第一次登陆,数据库中并未有对应的用户id,所以这里userId为空,并抛出BadCredentialsException异常,这个异常最终被SocialAuthenticationFilter过滤器的doAuthentication方法中的catch捕捉,我们再看一下这个方法:

    private Authentication doAuthentication(SocialAuthenticationService authService, HttpServletRequest request, SocialAuthenticationToken token) {
        try {
            if (!authService.getConnectionCardinality().isAuthenticatePossible()) return null;
            token.setDetails(authenticationDetailsSource.buildDetails(request));
            Authentication success = getAuthenticationManager().authenticate(token);
            Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principle type");
            updateConnections(authService, token, success);         
            return success;
        } catch (BadCredentialsException e) {
            // connection unknown, register new user?
            if (signupUrl != null) {
                // store ConnectionData in session and redirect to register page
                sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection()));
                throw new SocialAuthenticationRedirectException(buildSignupUrl(request));
            }
            throw e;
        }
    }

catch内部首先判断signupUrl 这个变量是否为空,如果不为空,表示设置了一个用户注册的url,即认为我们设置了一个注册的页面,并抛出一个重定向到注册页面的异常,从调试中我们发现signupUrl的值为"/signup",这个url由于我们未配置对其授权,所以又被过滤器拦截,重定向到login.html这个登录页面上去。为解决这个问题,我们只需要实现一个注册页,并修改signupUrl的值,跳到我们实现的注册页面上实现注册即可。
注册页面实现如下:





登陆页面


    

标准注册页面

表单登录

用户名:
密码:

注册表单提交的url是/user/regist,表单提交按钮有两个,这是因为如果用户没有注册过应用,那么需要从头注册这时就点上面这个按钮,如果用户注册过,那么需要操作的是将以前注册的账号和社交登录的账号进行绑定,这时就要点下面那个按钮,代码实现中,这两个按钮都添加一个名字是type的属性,属性值分别为regist和binding,作为区分,后台的restful api中,这个/user/regist请求的处理api是:

    @PostMapping("/regist")
    public void regist(User user){
        //注册用户
    }

注册页面写好之后,还需要配置一下跳转到注册页面的logup.html请求不需通过认证,为此我们需要在安全配置类中对这个url进行配置:

...
            .antMatchers(
                    securityProperties.getBrowser().getLoginPage(),
                    "/code/image",
                    "/logup.html").permitAll()
...

此外我们还需告诉过滤器我们想要注册的地址是\logup.html,而不是默认的\signup.html,这个配置在之前写好的社交配置类中配置:

@Bean
    public SpringSocialConfigurer yunSocialSecurityConfig(){
        String filterProcessUrl = securityProperties.getSocial().getFilterProcessesUrl();
        YunSpringSocialConfigurer yunSpringSocialConfigurer = new YunSpringSocialConfigurer(filterProcessUrl);
        yunSpringSocialConfigurer.signupUrl("/logup.html");
        return yunSpringSocialConfigurer;
    }

配置好这些后,我们重新开始点击QQ登录,手机扫二维码通过后,页面最终跳转到了我们刚写的注册页面上:


image.png

现在已经成功跳转到注册页面了,但是有两个问题还需解决,一个是如何将用户的信息,如头像等体现在我们这个注册页面;另一个是我们点击注册或者绑定后,如何生成一个第三方应用的用户id,并将这个用户id和服务提供商上的用户id进行绑定,并最终一起保存到数据库中。为解决这两个问题,我们借助spring social提供的一个帮助类来完成,这个帮助类为ProviderSigninUtils

你可能感兴趣的:(SpringSecurity开发基于表单的认证(八))