SpringSecurity + Oauth2 + jwt实现单点登录

时间:2024-04-27 07:03:43
文章目录
  • 前言
  • 一、springsecurity oauth2 + redis方式的缺点
  • 二、oauth2认证的4种模式的选择
  • 三、认证服务器的编写
    • 第一步、创建WebSecurity配置类
    • 第二步、创建jwt仓库配置类
    • 第三步、创建UserDetailsService类
    • 第四步、创建认证服务器配置类
  • 四、测试认证服务器的功能
    • 1.创建LoginController类
    • 2.使用swagger进行测试
  • 五、认证服务器也可以是资源服务器
  • 六、自定义UserUtil(使用增强jwt访问令牌中的增强内容)
  • 七、jwt方式的退出功能
  • 八、编写学生资源服务器
    • 1.创建jwt仓库配置类
    • 2.创建资源服务器配置类
    • 3.创建StudentResourcesController进行测试
  • 九、编写教师资源服务器
  • 总结


前言

    在如今前后端分离架构越来越成为开发的主流模式,因此以前基于session的权限管理已经不适合前后端分离架构了,springsecurity oauth2 的出现帮我们解决了这个问题。

    本文采用springsecurity oauth2 + jwt实现单点登录。
    jwt其实有很多优点,并且自身就能携带很多信息,但jwt是无状态的,服务端理论上不用保存它的信息,这样就有一个问题,一旦jwt的token发送到用户手中,那么只要token不过期,用户就可以一直访问系统,也就没有退出功能了,如果要实现退出功能,我们需要在服务端存储jwt的信息才行,在jwt方式的退出功能章节处我会一种方式实现jwt的退出功能。
    文章中我会着重描述认证服务器配置的流程,下面真正进入正文部分。


一、springsecurity oauth2 + redis方式的缺点

    其实使用redis方式来实现单点登录是有一些缺点的,主要有两个。
    第一个缺点如果我们有多个客户端,那么在配置资源服务器配置文件的时候,需要在配置文件中配置资源服务器如何验证token有效性并且在其中需要指定认证服务器配置的客户端id和客户端密码,例如下面的配置:

/**
 * 配置资源服务器如何验证token有效性
 * 1. DefaultTokenServices
 *  如果认证服务器和资源服务器同一服务时,则直接采用此默认服务验证即可
 * 2. RemoteTokenServices (当前采用这个)
 *  当认证服务器和资源服务器不是同一服务时, 要使用此服务去远程认证服务器验证
 */
  @Bean
  public ResourceServerTokenServices tokenService() {
      // 资源服务器去远程认证服务器验证 token 是否有效
      RemoteTokenServices service = new RemoteTokenServices();
      // 请求认证服务器验证URL,注意:默认这个端点是拒绝访问的,要设置认证后可访问
      service.setCheckTokenEndpointUrl("http://localhost:9050/oauth/check_token");
      // 在认证服务器配置的客户端id
      service.setClientId("WebClient");
      // 在认证服务器配置的客户端密码
      service.setClientSecret("123456");
      return service;
  }

    我们需要配置客户端id:WebClient以及配置客户端密码:123456,这是我们以前自定义的客户端id和密码,这样就在代码上写死了,如果我们的项目是以下两种情况是可以使用redis方式来完成的,第一种是只有一个客户端,例如pc端或者手机端。第二种情况是有多个客户端,例如pc端和手机端同时存在,但它们使用相同的客户端进行登录,这种情况也是可以的。如果不是这两种情况的话,那么采用jwt方式来完成单点登录是更好的选择。

    第二个缺点是redis方式在用户登录成功后,我们需要将用户登录后的信息存储到redis中,例如用户的访问令牌以及刷新令牌等信息,而这些信息都是要存储在认证服务器配置的redis上的,因此资源服务器才需要像第一个缺点那样需要配置去远程认证服务器验证 token 是否有效,其实这样就过度的依赖授权服务器了,意味着所有的访问资源服务器的请求都需要带着访问令牌去远程访问认证服务器来校验访问令牌的有效性,这样的缺点是如果授权服务器崩了的话用户的权限信息就无法校验了。

    而jwt方式则不存在这个问题,因为jwt自身就能携带很多用户登录后的信息,就可以直接在资源服务器上通过解析访问令牌来进行权限的校验,就不需要远程到认证服务器进行权限校验了,意味着认证服务器是什么状态都没关系,资源服务器能自行校验,不需要再远程访问认证服务器了。

    但其实redis方式也是有优点的,那就是它对用户退出的功能支持比较友好,spring security oauth2为我们提供的RedisTokenStore类自身就重写了removeAccessToken方法来方便我们进行用户的退出,而为我们提供的JwtTokenStore类则是一个空方法的实现,意味着如果采用jwt方式的话我们需要自行实现这个方法来进行退出。

    如果对redis的配置方式感兴趣,请看我以前的文章。

二、oauth2认证的4种模式的选择

    在编写认证服务器之前,首先要确定使用哪一种oauth2的认证方式,一共有四种模式,分别是授权码模式,密码模式,简化模式和客户端模式。
    这里我采用的是密码模式来实现单点登录,理由是简化模式无法使用刷新令牌,客户端模式通常用于资源服务器之间的授权验证,密码模式相对于授权码模式来说不需要获取授权码即可获取访问令牌,如果认证服务器也是我们开发的,那么使用密码模式是更好的选择。
    因为如果使用授权码模式的话用户的账号和密码是需要到认证服务器那里进行输入的,例如常用的网站去集成微信登录和qq登录,为了保证用户信息的安全,都是需要到认证服务器的界面上输入微信和qq的账号和密码的,它们采用的就是授权码的模式,密码模式则不需要,用户可以直接在我们的界面输入账号和密码,然后带着用户的账号和密码访问认证服务器的获取访问令牌的方法进行登录即可。

三、认证服务器的编写

    认证服务器的编写是最重要的部分,它主要负责用户的认证和授权,资源服务器可以有多个,而认证服务器通常只有一个。
    认证服务器编写完成后的项目结构:
在这里插入图片描述


第一步、创建WebSecurity配置类

/**
 * Security 配置类
 */
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {


    // 初始化密码编码器,用BCryptPasswordEncoder加密密码
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 初始化认证管理对象,密码模式需要
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    // 放行和认证规则,ResourceServerConfig可以完全替代这个方法,因为它们的过滤器链式不同的,需要认证的方法不会走这条过滤器链。
    /*@Override
    protected void configure(HttpSecurity http) throws Exception {
        http..authorizeRequests().anyRequest().permitAll();
    }*/

}

    在以前,不使用前后端分离架构的时候,我们通常使用这个配置类来完成SpringSecurity的配置,但在spring security oauth2 中,configure这个方法已经不需要使用了,变成使用资源配置类ResourceServerConfigurerAdapter的configure方法进行放行和认证规则的配置。

    在认证服务器也可以是资源服务器这个章节中会解释为什么不使用WebSecurityConfigurerAdapter中的configure方法,本质上是因为ResourceServerConfigurerAdapter中的configure方法可以完全替代WebSecurityConfigurerAdapter中的configure方法。

    但是如果我们采用密码模式的话,底层源码是需要依赖一个AuthenticationManager对象的,因此我们需要这个WebSecurity配置类创建一个对象出来,然后再配置一个密码编码器BCryptPasswordEncoder就可以了。

@Configuration
public class JwtTokenStoreConfig {
	
	//jwt对称加密
    public static final String SIGNING_KEY = "leon";

    private static final String TOKEN_KEY = "access_token:";

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Bean
    public TokenStore tokenStore() {

        // Jwt管理令牌
        return new JwtTokenStore(jwtAccessTokenConverter()){
            @Override
            public void storeAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {
                String tokenValue = token.getValue();
                //添加Jwt Token白名单,将Jwt以jti为Key存入redis中,并保持与原Jwt有一致的时效性
                if (token.getAdditionalInformation().containsKey("jti")) {
                    String jti = token.getAdditionalInformation().get("jti").toString();
                    redisTemplate.opsForValue().set(TOKEN_KEY+jti, token.getValue(), token.getExpiresIn(), TimeUnit.SECONDS);
                }
            }

            /***************
             * 客户端退出时,删除客户端存储的token,并调用服务器的接口删除服务器上存储的令牌,
             * 删除令牌最终调用的是tokenStore.removeAccessToken方法,
             * 所以只要实现该方法,就能达到删除令牌的效果
             * *************/
            @Override
            public void removeAccessToken(OAuth2AccessToken token) {
                if (token.getAdditionalInformation().containsKey("jti")) {
                    String jti = token.getAdditionalInformation().get("jti").toString();
                    redisTemplate.delete(TOKEN_KEY+jti);
                }
            }
        };

    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        // 对称加密进行签名令牌,资源服务器也要采用此密钥来进行解密,来校验令牌合法性
        converter.setSigningKey(SIGNING_KEY);

        return converter;
    }

    

}

    我们首先创建了一个JwtTokenStore类出来,这个类的作用主要作用是用来生成我们的jwt访问令牌和刷新令牌的,然后我们重写了里面的storeAccessToken以及removeAccessToken方法,这两个方法在JwtTokenStore类中其实是一个空实现的方法,redis方式则是有具体实现的,我们之所以要重写它们就是为了实现jwt方式的退出功能,在jwt方式的退出功能这个章节中会介绍它们的作用。

    并且还创建了一个JwtAccessTokenConverter类出来,这个类主要是用来辅助spring security来生成jwt访问令牌和刷新令牌,因为jwt需要一个key来进行加密,这个key可以分为对称加密方式和非对称加密方式,这里为了尽量简洁,我使用了对称加密的方式,key是固定的,为leon。

第三步、创建UserDetailsService类

@Service
public class CustomerUserDetailService implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    //为了简单,这里不连接数据库,而是直接写死数据,用户为yuki,密码为123456,权限为Teacher以及其它权限
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        //第一种方式:自定义UserDetails类
        SignInUser signInUser = new SignInUser();

        signInUser.setId(7);
        signInUser.setUsername("yuki");
        signInUser.setPassword(passwordEncoder.encode("123456"));
        //这里可以是存储在数据库的教师标识,可以理解为1 = ROLE_TEACHER,通常数据库直接不会存储ROLE_TEACHER
        Integer type = 1;
        signInUser.setType(type);
        //如果以后要扩展RBAC模型的话,就在这里进行扩展,从数据库中查询出用户的所有权限,然后设置到UserDetails对象里面就可以了,注意权限之间要用逗号进行分隔
        signInUser.setAuthorities(type.equals(1)?"ROLE_TEACHER,TEACHER_OTHERS_Authorities":"ROLE_STUDENT,STUDENT_OTHERS_Authorities");

        return signInUser;

        //第二种方式:使用SpringSecurity为我们提供的User类
        //使用SpringSecurity为我们提供的User类的话虽然可以,但是这个类只能记录Username,无法记录更多信息,所以自定义UserDetails类是更好的选择
        //return User.withUsername("yuki").password(passwordEncoder.encode("123456")).roles("TEACHER").build();

    }

}

    相信学过spring security框架后应该已经很熟悉了,这里我为了更专注单点登录功能的实现,就不连接数据库了,写死数据,只使用一个用户,用户名为yuki,密码为123456,他的角色是TEACHER,其它权限为TEACHER_OTHERS_Authorities,其他权限如果后续要使用RBAC模型的话,就用实际的权限替换掉TEACHER_OTHERS_Authorities就可以了,权限之间使用逗号进行分隔,用户登录时要填写正确的用户名和密码。

    需要注意这里我使用了一个自定义的UserDetails实现类SignInUser来完成,如果使用spring security为我们提供的User类的话,它只能够记录只能记录Username这一个属性,而在本项目中后续需要增强我们的jwt访问令牌,让它携带更多的信息,那么自定义一个UserDetails类其实是一个更好的选择。下面来看一下这个自定义UserDetails类的实现:

//自定义UserDetails对象,方便扩展
@Data
@Accessors(chain = true)
public class SignInUser implements UserDetails,Serializable{

    private static final long serialVersionUID = 1L;

    /**
     *
     */
    private Integer id;
    /**
     * 用户名
     */
    private String username;
    /**
     * 密码
     */
    private String password;
    /**
     * 0删除1未删除
     */
    private Integer stealth;
    /**
     * 0禁用1未禁用
     */
    private Integer status;
    /**
     * 操作人id
     */
    private Integer operator;
    /**
     * 用户类型
     */
    private Integer type;
    /**
     *
     */
    private Date createTime;
    /**
     *
     */
    private Date updateTime;


    private String authorities;


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return AuthorityUtils.commaSeparatedStringToAuthorityList(authorities);
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

    可以看到,我扩展了一些用户属性,现实开发中这些属性都存储在数据库字段中,并且需要实现UserDetails接口中的五个方法,其中最重要的方法是getAuthorities方法,spring security在用户访问资源的时候会利用这个方法返回的权限信息来决定用户是否有权限访问资源,其它的四个方法为了简单,直接返回true即可。

第四步、创建认证服务器配置类

    编写了SecurityConfiguration、JwtTokenStoreConfig和CustomerUserDetailService 这三个类后,就可以去创建认证服务器的配置文件了,这是认证服务器中最重要的一个配置文件,代码都有详细的注释。

@Configuration
//开启授权服务器
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    //密码编码器
    @Autowired
    private PasswordEncoder passwordEncoder;

    //认证管理,使用密码模式需要用到
    @Autowired
    private AuthenticationManager authenticationManager;

    //Token仓库
    @Autowired
    private TokenStore tokenStore;

    @Autowired
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    //使用自定义的UserDetailsService
    @Autowired
    private UserDetailsService customerUserDetailService;

    //使用配置文件配置客户端,使用一个客户端即可
    @Autowired
    private ClientOAuth2DataConfiguration clientOAuth2DataConfiguration;

    /**
     * 配置被允许访问此认证服务器的客户端信息
     * 1.内存方式
     * 2.数据库方式
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        //只需要一个客户端,放在内存中就好
        clients.inMemory()
                //配置客户端id
                .withClient(clientOAuth2DataConfiguration.getClientId())
                //配置客户端密钥
                .secret(passwordEncoder.encode(clientOAuth2DataConfiguration.getSecret()))
                //配置授权范围
                .scopes(clientOAuth2DataConfiguration.getScopes())
                //配置访问令牌过期时间
                .accessTokenValiditySeconds(clientOAuth2DataConfiguration.getTokenValidityTime())
                //配置刷新令牌过期时间
                .refreshTokenValiditySeconds(clientOAuth2DataConfiguration.getRefreshTokenValidityTime())
                //配置授权类型
                .authorizedGrantTypes(clientOAuth2DataConfiguration.getGrantTypes());
    }


    //jwt令牌的增强信息
    private TokenEnhancer jwtTokenEnhance() {
        return (accessToken, authentication) -> {
            // 获取登录用户的信息,然后设置,这个地方切记,如果使用自定义UserDetails对象的话,那么也一定要强转回自己的对象,不然会报500错误
            SignInUser user = (SignInUser) authentication.getPrincipal();
            LinkedHashMap<String, Object> map = new LinkedHashMap<>();
            //不能设置成authorities,因为是关键字,会有冲突
            //map.put("userAuthorities", user.getAuthorities());

            //因为原始的SignInUser太多内容了,因此新建一个SignInUserVO来保存用户登录的重要信息放到访问令牌中
            SignInUserVO signInUserVO = new SignInUserVO();
            BeanUtils.copyProperties(user,signInUserVO);
            String userJson = JsonUtils.objectToJson(signInUserVO);
            map.put("user",userJson);
            map.put("enhanceMessage","不要在意,我只是一个增强信息。");
            DefaultOAuth2AccessToken token = (DefaultOAuth2AccessToken) accessToken;

            //将SignInUserVO转换成json格式放到jwt访问令牌的额外信息中
            token.setAdditionalInformation(map);
            return token;
        };
    }


    //参数名称叫授权服务器端点配置器
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        //增强jwt令牌要使用链路,因为jwtAccessTokenConverter也是一个TokenEnhance
        TokenEnhancerChain chain = new TokenEnhancerChain();
        ArrayList<TokenEnhancer> delegates = new ArrayList<>();
        delegates.add(jwtTokenEnhance());
        //增强内容也要进行转换
        delegates.add(jwtAccessTokenConverter);
        chain.setTokenEnhancers(delegates);


        // password 要这个 AuthenticationManager 实例
        endpoints.authenticationManager(authenticationManager)
                //启动刷新令牌需要在此处指定UserDetailsService
                //它会先从jwt中解析出用户信息,然后再利用username查询数据库中看有没有这个用户才决定要不要返回令牌
                .userDetailsService(customerUserDetailService)
                //使用jwt方式管理令牌,并配置jwt的访问令牌的内容转换器
                .tokenStore(tokenStore).accessTokenConverter(jwtAccessTokenConverter)
                // 令牌增强对象,增强oauth/token路径返回的结果
                .tokenEnhancer(chain);

    }

    //负责开放springSecurity_oauth2提供的接口
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        // 开启/oauth/check_token,作用是资源服务器会带着令牌到授权服务器中检查令牌是否正确,然后如果正确授权服务器会给资源服务器返回用户的信息
        security.checkTokenAccess("permitAll()");
        // 认证后可访问 /oauth/token_key, 默认拒绝访问,作用是获取jwt公钥用来解析jwt
        security.tokenKeyAccess("isAuthenticated()");
    }

}

第一步,我们需要配置认证服务器可以进行授权的客户端信息,具体是下面的代码:

/**
 * 配置被允许访问此认证服务器的客户端信息
 * 1.内存方式
 * 2.数据库方式
 * @param clients
 * @throws Exception
 */
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    //只需要一个客户端,放在内存中就好
    clients.inMemory()
            //配置客户端id
            .withClient(clientOAuth2DataConfiguration.getClientId())
            //配置客户端密钥
            .secret(passwordEncoder.encode(clientOAuth2DataConfiguration.getSecret()))
            //配置授权范围
            .scopes(clientOAuth2DataConfiguration.getScopes())
            //配置访问令牌过期时间
            .accessTokenValiditySeconds(clientOAuth2DataConfiguration.getTokenValidityTime())
            //配置刷新令牌过期时间
            .refreshTokenValiditySeconds(clientOAuth2DataConfiguration.getRefreshTokenValidityTime())
            //配置授权类型
            .authorizedGrantTypes(clientOAuth2DataConfiguration.getGrantTypes());
}

    在这里可以采用内存方式来存储客户端信息或者使用数据库来存储客户端信息,这里为了简单,我使用了内存方式来存储可以进行授权的客户端信息,这样就只存储一个id为WebClient,密钥为123456的客户端信息

    如果有多个客户端的话最好是采用数据库的方式,例如同时有pc端和手机端,这里不做扩展。需要注意,用户在登录的时候要携带客户端的id和密钥,如果客户端信息错误的话,也是不允许进行登录的。

    由于这里我只使用一个客户端来完成单点登录的开发,因此我创建了一个ClientOAuth2DataConfiguration类来辅助我存储客户端的信息,它的实现如下:

/**
 * 客户端配置类
 */
@Component
@ConfigurationProperties(prefix = "client.oauth2")
@Data
public class ClientOAuth2DataConfiguration {

    // 客户端标识 ID
    private String clientId;

    // 客户端安全码
    private String secret;

    // 授权类型
    private String[] grantTypes;

    // token有效期
    private int tokenValidityTime;

    // refresh-token有效期
    private int refreshTokenValidityTime;

    // 客户端访问范围
    private String[] scopes;

}

其中具体的客户端配置信息在application.properties配置文件中进行指定。

# 客户端ClientOAuth2DataConfiguration配置
client.oauth2.clientId=WebClient
client.oauth2.secret=123456
client.oauth2.grantTypes[0]=password
client.oauth2.grantTypes[1]=refresh_token
client.oauth2.tokenValidityTime=360000
client.oauth2.refreshTokenValidityTime=72000
client.oauth2.scopes=all

第二步,我们需要设置jwt令牌的增强信息,具体是下面的代码:

//jwt令牌的增强信息
private TokenEnhancer jwtTokenEnhance() {
    return (accessToken, authentication) -> {
        // 获取登录用户的信息,然后设置,这个地方切记,如果使用自定义UserDetails对象的话,那么也一定要强转回自己的对象,不然会报500错误
        SignInUser user = (SignInUser) authentication.getPrincipal();
        LinkedHashMap<String, Object> map = new LinkedHashMap<>();
        //不能设置成authorities,因为是关键字,会有冲突
        //map.put("userAuthorities", user.getAuthorities());

        //因为原始的SignInUser太多内容了,因此新建一个SignInUserVO来保存用户登录的重要信息放到访问令牌中
        SignInUserVO signInUserVO = new SignInUserVO();
        BeanUtils.copyProperties(user,signInUserVO);
        String userJson = JsonUtils.objectToJson(signInUserVO);
        map.put("user",userJson);
        map.put(