当前位置:   article > 正文

SpringSecurity + Oauth2 + jwt实现单点登录

SpringSecurity + Oauth2 + jwt实现单点登录


前言

    在如今前后端分离架构越来越成为开发的主流模式,因此以前基于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;
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

    我们需要配置客户端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();
    }*/

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

    在以前,不使用前后端分离架构的时候,我们通常使用这个配置类来完成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;
    }

    

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54

    我们首先创建了一个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();

    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

    相信学过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;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73

    可以看到,我扩展了一些用户属性,现实开发中这些属性都存储在数据库字段中,并且需要实现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
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112

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

/**
 * 配置被允许访问此认证服务器的客户端信息
 * 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());
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

    在这里可以采用内存方式来存储客户端信息或者使用数据库来存储客户端信息,这里为了简单,我使用了内存方式来存储可以进行授权的客户端信息,这样就只存储一个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;

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

其中具体的客户端配置信息在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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

第二步,我们需要设置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("enhanceMessage","不要在意,我只是一个增强信息。");
        DefaultOAuth2AccessToken token = (DefaultOAuth2AccessToken) accessToken;

        //将SignInUserVO转换成json格式放到jwt访问令牌的额外信息中
        token.setAdditionalInformation(map);
        return token;
    };
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

    我们在 前面创建UserDetailsService类章节 中自定义的UserDetails实现类SignInUser就在这里发挥着重要的作用,在这个增强方法里面使用authentication.getPrincipal()代码就可以获取到CustomerUserDetailService中返回的SignInUser对象了。

    我们通常会在这里增强用户的访问令牌,由于我不需要扩展那么多的信息,因此我创建了一个signInUserVO类,它只负责保存用户的id、username以及用户的角色type即可。最后我们将这个对象转换成json格式然后设置到jwt访问令牌中的额外信息区域中就好了。

看一下signInUserVO实现类,它是SignInUser的简单版本,只存储用户的id,username和用户角色type信息:

@Data
public class SignInUserVO {

    private Integer id;

    private String username;

    private Integer type;

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

第三,我们需要配置认证服务器的端点配置器endpoints,具体代码如下:

//参数名称叫授权服务器端点配置器
@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);

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

    在这里我们需要设置上 前面创建UserDetailsService类章节 中创建的CustomerUserDetailService类以及设置上 前面创建jwt仓库配置类章节 中创建的jwt仓库JwtTokenStore和jwt的访问令牌的内容转换器JwtAccessTokenConverter,最后设置上我们刚才创建出来的jwt增强对象jwtTokenEnhance即可。

**最后第四步,我们需要开放springSecurity_oauth2提供的接口,具体代码如下: **

 //负责开放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
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

这属于jwt方式的固定写法。

至此,其实认证服务器就已经配置好了,为了测试它的功能,就可以创建controller类进行测试了。

四、测试认证服务器的功能

    下面的操作都还是在认证服务器中进行,在进行之前先要在配置文件中配置好redis的信息,因为登录功能要采用redis来做。

# 配置单节点的redis服务
spring.redis.host=127.0.0.1
spring.redis.database=0
spring.redis.port=6379

  • 1
  • 2
  • 3
  • 4
  • 5

1.创建LoginController类

    这里需要注意GETTOKENURL参数是springsecurity为我们提供的一个控制器的方法路径,我们带着特定的参数访问它就可以获取到访问令牌以及刷新令牌了,每个模式需要携带的参数是不一样的,我们这里使用的是密码模式,需要携带上客户端的id和密钥以及用户的用户名和密码。

@Api(value = "loginController",tags = "登录Controller")
@RestController
@RequestMapping("/loginController")
@Slf4j
public class LoginController {

    @Autowired
    private RedisOperator redisOperator;

    @Autowired
    private ClientOAuth2DataConfiguration clientOAuth2DataConfiguration;

    private static final String REDIS_USER_CODEKEY = "verifyCode";

    @ApiOperation(value = "用户登录接口", httpMethod = "POST")
    @PostMapping("/login")
    public Result login(String username, String password, String codeKey, String codeKeyIndex){

        //通过redis检测验证码是否正确
        String verifyCode = redisOperator.get(REDIS_USER_CODEKEY + ":" + codeKeyIndex);
        log.info("接收到验证码: "+codeKey);
        log.info("verifyCode: "+verifyCode);
        if (!codeKey.equals(verifyCode)){
            return new Result(HttpServletResponse.SC_FORBIDDEN,null,"验证码不正确");
        }

        // 构建请求头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        // 构建请求体(请求参数)
        MultiValueMap<String,Object> paramsMap=new LinkedMultiValueMap<>();
        paramsMap.add("username", username);
        paramsMap.add("password", password);
        paramsMap.add("grant_type", "password");

        //利用密码模式到sso授权服务器中拿到access_token和refresh_token,因为是同一个项目的模块,可以使用密码模式
        //在请求头中带上客户端的账号密码
        HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(paramsMap, headers);

        // 设置 Authorization
        RestTemplate restTemplate = new RestTemplate();

        restTemplate.getInterceptors().add(
                new BasicAuthenticationInterceptor("WebClient","123456"));

        ResponseEntity<OAuth2AccessToken> result;

        try {
            //发送请求,从TokenEndpoint类中可以看到返回值是OAuth2AccessToken
            result = restTemplate.postForEntity(clientOAuth2DataConfiguration.getGetTokenUrl(), entity, OAuth2AccessToken.class);
        }catch (HttpClientErrorException e){
            return new Result(HttpServletResponse.SC_FORBIDDEN,null,e.getMessage());
        }


        //处理返回结果
        if (result.getStatusCode()!= HttpStatus.OK){
            return new Result(HttpServletResponse.SC_UNAUTHORIZED,null,"登录失败");
        }

        //在这里也可以使用vo对象,封装好前端需要的数据返回,这个token如果以前设置了token加强信息,这里也能获取到
        return new Result(HttpServletResponse.SC_OK,result.getBody(),"登录成功");
    }




    //获取验证码的方法,将验证码存储到redis中
    @ApiOperation(value = "获取验证码", httpMethod = "GET")
    @GetMapping("/getVerifyCode")
    public Result getVerifyCode() throws IOException {

        //1.生成验证码
        String codeKey = VerifyCodeUtils.generateVerifyCode(4);
        log.info("验证码:" + codeKey);
        //2.存储验证码 redis
        String codeKeyIndex = UUID.randomUUID().toString();
        redisOperator.set(REDIS_USER_CODEKEY+":"+codeKeyIndex,codeKey,500);
        //3.base64转换验证码
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        VerifyCodeUtils.outputImage(120, 60, byteArrayOutputStream, codeKey);
        String data = "data:image/png;base64," + Base64Utils.encodeToString(byteArrayOutputStream.toByteArray());
        //4.响应数据
        Map<String, String> map = new HashMap<>();
        map.put("data",data);
        map.put("codeKeyIndex",codeKeyIndex);

        return new Result(200,map,"获取验证码成功");
    }



    //重新登录,刷新令牌的使用,也要使用客户端id和密码进行查找新的令牌。
    @ApiOperation(value = "刷新令牌重新获取访问令牌", httpMethod = "GET")
    @GetMapping("/refresh")
    public Result refresh(@RequestParam("refreshToken") String refreshToken){

        //从请求头中解析出refresh_token
        System.out.println(refreshToken);

        // 构建请求头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String,Object> paramsMap=new LinkedMultiValueMap<>();
        paramsMap.add("grant_type", "refresh_token");
        paramsMap.add("refresh_token",refreshToken);

        //在请求头中带上客户端的账号密码
        HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(paramsMap, headers);

        //用refresh_token获取到新的access_token
        RestTemplate restTemplate = new RestTemplate();

        restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor("WebClient","123456"));

        OAuth2AccessToken token;
        try{
            token = restTemplate.postForObject(clientOAuth2DataConfiguration.getGetTokenUrl(),entity,OAuth2AccessToken.class);
        }catch (HttpClientErrorException e){
            return new Result(HttpServletResponse.SC_FORBIDDEN,null,e.getMessage());
        }

        return new Result(200,token,"登录成功");
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127

    为了适应实际的开发需要,还添加了验证码的功能,登录流程是首先访问getVerifyCode获取到验证码和验证码在redis中的key,我们采用swagger来进行访问,这样就获取到验证码base64图片上面的验证码图片信息,也可以直接到redis或者控制台中查看验证码。
在这里插入图片描述

2.使用swagger进行测试

    首先访问认证服务器的swagger地址:http://localhost:7777/oauth2-jwt-demo/swagger-ui.html,然后找到登录Controller里面的接口。

    第一步、先访问getVerifyCode方法获取到验证码信息,在swagger的操作界面需要注意,需要把Authorization这个字段清空后再进行验证码信息的获取,因为登录Controller中的方法是不需要用户进行登录就能访问的,默认Authorization会有一个Bearer前缀,这个前缀是我在SwaggerConfig类中配置的,为了方便后续的使用,如果加上这个前缀的话spring security就会帮你去验证访问令牌了,其实我们此时还并没有登录,那令牌肯定也是不会有的。
在这里插入图片描述
getVerifyCode的结果:
在这里插入图片描述
data就是我们的验证码图片了,可以直接复制出来在网页展示或者从控制台中查看,同时还有一个验证码的标识codeKeyIndex。

    第二步、使用获取到的验证码进行登录
在这里插入图片描述
    **在 前面创建UserDetailsService类章节 中说过为了简单,就不连接数据库了,只使用一个用户,用户名username为yuki,password为123456,登录后的结果为: **
在这里插入图片描述
    可以看到,登录成功后我们就可以获得到访问令牌和刷新令牌,并且我们配置的增强信息和jwt的唯一标识jti也返回给我们了。
    这些信息都会被放入到jwt访问令牌中,放入操作都是spring security帮我们实现的,我们只需要在代码中访问它给我们提供的控制器方法/oauth/token就可以做到了。

    下面我们也可以来验证一下jwt访问令牌中到底存储了哪些信息,首先访问jwt的解析网站https://jwt.io/,然后点击上面的Debugger按钮进入解析界面。
在这里插入图片描述
复制我们获取到的access_token访问令牌到左边的解析框中进行解析,得到如下的结果:
在这里插入图片描述
    可以看到,访问令牌中保存了用户的许多信息,以后只要访问令牌通过spring security的校验,那么资源服务器就可以利用访问令牌的信息来验证我们有否有权限访问资源了,并且由于有增强信息的内容,我们也可以很好的扩展,在自定义UserUtil章节中会使用到jwt中的增强内容。

    第三步、刷新令牌的使用
    因为访问令牌其实是有效期的,一旦有效期过了之后,访问令牌就失效了,如果没有访问令牌的话,用户就需要重新登录了,但是如果我们在用户登录完成后同时保存了用户的访问令牌和刷新令牌,那么如果访问令牌失效了,我们就可以根据刷新令牌为用户进行重新的登录,再一次获取到新的访问令牌。有了刷新令牌,就可以实现记住我的功能了。
在这里插入图片描述
**访问refresh方法,只需要填上访问令牌和刷新令牌的参数就可以了,结果如下: **
在这里插入图片描述
这样就重新获取到新的访问令牌和刷新令牌的信息了。

五、认证服务器也可以是资源服务器

    这里的代码依然在认证服务器中编写,认证服务器也可以是资源服务器。

    第一步、在认证服务器中编写一个UserControllerLogin类,实现如下:

@Api(value = "userController",tags = "用户Controller")
@RestController
@RequestMapping("/userController")
public class UserController {

    //这里的TokenStore是我们定义的JwtTokenStore
    @Autowired
    private TokenStore tokenStore;

    //获取用户的访问令牌信息,SpringSecurity的过滤器最终会将用户的访问令牌解析成一个Authentication对象存储在SecurityContextHolder中
    @ApiOperation(value = "获取用户的访问令牌信息", httpMethod = "GET")
    @GetMapping("/getUserAccessTokenInfo")
    public Result getUserAccessTokenInfo(Authentication authentication) throws IOException {

        return Result.ok(authentication,"获取用户的访问令牌信息成功");

    }


    //获取用户信息接口
    @ApiOperation(value = "获取用户信息", httpMethod = "GET")
    @GetMapping("/getUserInfo")
    public Result getUserInfo() throws IOException {

        Integer userId = UserUtil.getUserId();

        //通过userId就可以调用我们自定义的service查询出真正的用户信息了,这里使用假数据来完成
        //User user = userService.getUserInfo(userId);
        HashMap<String, Object> user = new HashMap<>();
        user.put("id",userId);
        user.put("username","yuki");
        user.put("userType","1");

        return Result.ok(user,"获取用户信息成功");
    }



    //用户退出登录接口
    @ApiOperation(value = "用户退出登录", httpMethod = "GET")
    @GetMapping("/logout")
    public Result logout(Authentication authentication){

        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
        String tokenValue = details.getTokenValue();

        //SpringSecurity为我们提供的readAccessToken方法,参数为用户的令牌,返回值为封装好的OAuth2AccessToken对象
        OAuth2AccessToken oAuth2AccessToken = tokenStore.readAccessToken(tokenValue);
        //在jwt方式中removeAccessToken是一个空实现方法,但是为了做到能够退出的功能,我们重写了这个方法,目的是在redis中删除掉
        //每一个jwt的唯一标识jti,这样配合jti的拦截器就能做到用户退出的功能了。
        tokenStore.removeAccessToken(oAuth2AccessToken);

        return Result.ok("用户退出成功");
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56

    很明显 获取用户的访问令牌信息、获取用户信息和用户退出登录 这三个方法都是需要用户登录后才能够进行操作的,因此用户在访问这几个方法的时候需要带着刚才登录完成的访问令牌来进行访问才行,可以理解为这三个方法就是资源服务器中的资源。

    意味着spring security需要对用户传来的访问令牌进行解析,来判断用户是否已经登录,这时就需要配置资源服务器配置文件,体现出了认证服务器也可以是资源服务器。

第二步、编写资源服务器配置文件

/**
 * 资源服务
 */
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true) // 开启方法级别权限控制
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Autowired
    private CustomAuthenticationEntryPoint myAuthenticationEntryPoint;

    @Autowired
    private CustomAccessDeniedHandler customAccessDeniedHandler;


    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        //因为采取redis存储token,因此要到授权服务器中验证token信息
        resources
                //当用户传入无效的token会触发myAuthenticationEntryPoint的commence方法进行处理
                .authenticationEntryPoint(myAuthenticationEntryPoint)
                //当用户的权限不足时,customAccessDeniedHandler的handle方法进行处理
                .accessDeniedHandler(customAccessDeniedHandler);

    }

    //ResourceServerConfigurerAdapter的优先级比WebSecurityConfigurerAdapter高,如果两个都配置了相同路径,则按ResourceServerConfigurerAdapter为准
    //而且他们走的是不同的filter链
    @Override
    public void configure(HttpSecurity http) throws Exception {
        //关闭csrf防御
        http.csrf().disable();

        http.authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()//处理跨域请求中的preflight请求
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/v2/**").permitAll()
                .antMatchers("/swagger-resources/**").permitAll()
                //放行登录的请求
                .antMatchers("/loginController/**").permitAll();

        //其它所有的请求都需要进行访问令牌的验证
        http.authorizeRequests().anyRequest().authenticated();

        //前后端分离架构,设置为无状态模式,SpringSecurity不会操作session
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51

    ResourceServerSecurityConfigurer resources的配置作用会在 编写学生资源服务器章节 中进行介绍,我们这里主要关注HttpSecurity http的配置,首先关闭了csrf的防御功能,这个可以自行选择,在这里放行了swagger和登录Controller的路径信息,这样即使没有访问令牌也能够进行访问这些路径,然后我们将其它的请求都进行拦截,意味着其它的路径我们都认为是一种资源,用户需要带着访问令牌来进行访问,最后设置spring security为STATELESS无状态的模式,因为我们是前后端分离的架构,设置了这个参数后,spring security就不会再对session进行操作了。

对在 前面创建WebSecurity配置类章节 中为什么可以不使用WebSecurityConfigurerAdapter中的configure方法的解释

    其实配置了这个方法就可以发现,这个方法其实和我们一开始配置的WebSecurity配置类中的方法设置的内容是基本一致的,那么为什么要在这里进行设置资源路径放行和认证规则呢,其实这是因为这两个地方设置的路径它对应的过滤器链是不一样的,只有在这里设置的路径才会走oauth2的过滤器链。

    需要注意如果认证服务器同时也是资源服务器的话,可以发现资源服务器的配置文件也是可以配置资源路径放行和认证规则的,如果其中某些资源路径配置和web安全配置类重合,资源服务器的配置文件优先级是会大于web安全配置类的,那么web安全配置类重合的资源路径的放行和认证规则就会被资源服务器的配置覆盖掉。

    那么既然我们在资源服务器的配置中写了http.authorizeRequests().anyRequest().authenticated()这句代码,意味着所有的路径都归资源服务器的配置文件管了,因此在最前面配置WebSecurity配置类的时候我们就可以直接不使用WebSecurityConfigurerAdapter中的configure方法。如果想看oauth2的过滤器链具体有哪些过滤器组成和顺序的话,在项目启动的时候控制台就会打印出来。
在这里插入图片描述

    第三步、测试获取用户的访问令牌信息(getUserAccessTokenInfo)方法

    由于还没有说到我们自定义的UserUtil类以及jwt的退出功能,因此我们先演示最简单的getUserAccessTokenInfo的方法。在swagger的用户controller上找到这个方法进行访问,参数只需要带上访问令牌即可

在这里插入图片描述
结果为:
在这里插入图片描述
可以看到,这是spring security帮我们解析的结果,里面有一些基本的信息,例如用户的权限信息以及我们访问令牌的本体信息,这样测试就完成了。

六、自定义UserUtil(使用增强jwt访问令牌中的增强内容)

    我们前面在配置认证服务器的时候利用我们自定义的UserDetails实现类SignInUser来增强过我们jwt访问令牌,这些增强信息存储在访问令牌中,那么经过spring security的解析之后我们就可以直接获取到访问令牌里面的信息来方便我们的开发。看一下UserUtil的实现:

//获取用户信息的util
@Component
public class UserUtil {


    private static TokenStore tokenStore;
    
    
    public static Integer getUserId(){
        SignInUserVO signInUserVO = getSignInUserVO();
        return signInUserVO.getId();
    }



    public static Integer getUserType(){
        SignInUserVO signInUserVO = getSignInUserVO();
        return signInUserVO.getType();
    }


    //校验jwtToken是否有效,如果有效,返回SignInUserVO
    public static SignInUserVO verifyJwt(String tokenValue){
        try {
            OAuth2AccessToken accessToken = tokenStore.readAccessToken(tokenValue);
        }catch (Exception e){
            return null;
        }
        //解析tokenValue返回
        Jwt jwt = JwtHelper.decode(tokenValue);
        JSONObject jsonObject = JSONObject.parseObject(jwt.getClaims());
        String userJson = (String) jsonObject.get("user");
        return JsonUtils.jsonToPojo(userJson,SignInUserVO.class);
    }


    private static SignInUserVO getSignInUserVO(){
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
        String tokenValue = details.getTokenValue();
        //解析tokenValue返回
        Jwt jwt = JwtHelper.decode(tokenValue);
        JSONObject jsonObject = JSONObject.parseObject(jwt.getClaims());
        String userJson = (String) jsonObject.get("user");
        SignInUserVO signInUserVO = JsonUtils.jsonToPojo(userJson,SignInUserVO.class);
        return signInUserVO;
    }


}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50

    可以看到,我们可以先获取到jwt访问令牌,然后让spring security为我们提供的JwtHelper类来解析这个访问令牌的信息从而获取到我们以前增强过的SignInUserVO中的信息。
    例如SignInUserVO中保存了用户的id,那么我们可以直接获取到用户的id,再根据id信息就可以调用我们自己的userService类获取到用户的具体信息进行返回了,当然因为没有连接数据库,我就没有写userService这个类了。当然如果有需要,还可以在认证服务器的配置文件中增强更多的信息来方便我们的开发。

在swagger的用户controller上找到getUserInfo方法进行测试,当然这是虚构的数据,结果为:
在这里插入图片描述

在这里插入图片描述
这样就模仿了通过访问令牌中的增强内容用户id来查询出用户的具体信息了。

七、jwt方式的退出功能

    spring security在设计的时候其实没有实现jwt方式的退出功能,因为很多情况下使用了jwt的话其实也就可以不考虑退出了,不然就破坏了jwt无状态的特性了,但考虑到有的场景还是需要的,这里使用的是redis来配合我们实现jwt方式的退出功能。

    在创建jwt仓库配置类的时候我们重写了两个方法,分别是storeAccessToken和removeAccessToken,代码如下:

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

 }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

    在storeAccessToken方法中,我们利用spring security创建访问令牌产生的jwt唯一标识jti作为key存储在redis中,过期时间和访问令牌的过期时间一致,这样用户登录后我们就将用户的登录状态记录下来了。

在这里插入图片描述
    这样removeAccessToken方法中我们只需要获取到访问令牌中的jti,然后再redis中删除掉这个key那么用户就算是退出成功了。

    为了达到这个目的,我们还需要创建一个登录的拦截器LoginInterceptor,它的实现如下:

@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisOperator redisOperator;

    private final String TOKENKEY = "access_token:";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        System.out.println("=========进入sso前置拦截器========");


        //判断redis中的token是否过期
        String jti = request.getHeader("jti");

        //如果有cookie,还需要检验一下redis中是否有用户信息
        boolean isExist = redisOperator.keyIsExist(TOKENKEY + jti);
        if (isExist){
            //放行
            return true;
        }

        //返回错误信息
        // 返回 JSON
        response.setContentType("application/json;charset=utf-8");
        // 状态码 401
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        // 写出
        PrintWriter out = response.getWriter();

        Result result = new Result(HttpServletResponse.SC_UNAUTHORIZED, "jti不存在,token已经失效",null);

        out.write(JSONObject.toJSONString(result));
        out.flush();
        out.close();

        return false;
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42

    这个拦截器的作用是:如果检测到访问令牌中的jti已经不存在redis中了,那么就认为用户此时已经退出登录了。

    最后需要配置一下这个拦截器拦截的路径即可,实现如下:

@Configuration
public class InterceptorConfig extends WebMvcConfigurationSupport {

    @Autowired
    private LoginInterceptor loginInterceptor;

    //如果需要退出功能时候再开启,不需要则不用开启
    /*@Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/loginController/**");
    }*/


    // swagger2有controller,因此需要进行放行
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("swagger-ui.html")
                .addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/");

    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

    这里我把LoginInterceptor拦截器给关闭了,这是因为我们前面测试的过程中都没有带上jti这个参数,如果要测试的话打开这个addInterceptors方法的注释即可,这样就开启了退出功能,这样除了loginController外的其它方法都需要在请求头上带上登录成功后的jti了。如果不带上,结果如下:
在这里插入图片描述

    如果用户已经登录需要退出的话,可以访问用户controller上的logout方法,只需要带上访问令牌过来即可,方法中就会调用我们重写的tokenStore.removeAccessToken(oAuth2AccessToken)方法来进行退出了。结果如下:
在这里插入图片描述
在这里插入图片描述

八、编写学生资源服务器

    为了实现单点登录的功能,我们继续创建两个资源服务器,分别为学生资源服务器以及教师资源服务器,和认证服务器不同,他们是专门的资源服务器,没有认证的功能。这里为了简单,就不在这两个资源服务器中继承退出功能了,因为退出功能需要i依赖redis,有需要的可以加上。学生资源服务器编写完成后的项目结构:
在这里插入图片描述

1.创建jwt仓库配置类

    在 前面springsecurity oauth2 + redis方式的缺点章节 中说过jwt方式可以自行校验用户的访问令牌,因此资源服务器同样需要一个jwt仓库配置类,和认证服务器不同的是,由于退出功能是在认证服务器中完成的,因此资源服务器不用再重写storeAccessToken和removeAccessToken方法,直接创建一个普通的JwtTokenStore类就可以了,但jwt对称加密的KEY还是要和认证服务器的保持一致。代码如下:

@Configuration
public class JwtTokenStoreConfig {

    public static final String SIGNING_KEY = "leon";

    @Bean
    public TokenStore tokenStore() {

        // Jwt管理令牌
        return new JwtTokenStore(jwtAccessTokenConverter());

    }

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

        return converter;
    }
    
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

2.创建资源服务器配置类

    因为是资源服务器,所以需要创建资源服务配置类。代码如下:

@Configuration
@EnableResourceServer // 标识为资源服务器,请求服务中的资源,就要带着token过来,找不到token或token是无效访问不了资源
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true) // 开启方法级别权限控制
public class ResourceConfig extends ResourceServerConfigurerAdapter {

    @Autowired
    private CustomAuthenticationEntryPoint myAuthenticationEntryPoint;

    @Autowired
    private CustomAccessDeniedHandler customAccessDeniedHandler;

    @Autowired
    private TokenStore tokenStore;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        //因为采取redis存储token,因此要到授权服务器中验证token信息
        resources.tokenStore(tokenStore)
                //当用户传入无效的token会触发myAuthenticationEntryPoint的commence方法进行处理
                .authenticationEntryPoint(myAuthenticationEntryPoint)
                //当用户的权限不足时,customAccessDeniedHandler的handle方法进行处理
                .accessDeniedHandler(customAccessDeniedHandler);

    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()//处理跨域请求中的preflight请求
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/v2/**").permitAll()
                .antMatchers("/swagger-resources/**").permitAll();

        //其它所有的请求都需要进行访问令牌的验证
        http.authorizeRequests().anyRequest().authenticated();
    }
    
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

补充在 前面认证服务器也可以是资源服务器章节 中没有说明的CustomAuthenticationEntryPoint类和CustomAccessDeniedHandler类的作用

    其实和认证服务器那时候配置的资源服务配置类是类似的,这里补充一下ResourceServerSecurityConfigurer resources这个配置,我们在资源服务器上需要额外设置上tokenStore,这样才能在资源服务器中实现用户访问令牌的校验。
    然后我们自定义了两个类,如果用户传来的token访问令牌有误,那么就会触发CustomAuthenticationEntryPoint中的commence方法。如果用户传来的token访问令牌有效但是用户的权限不足,则触发CustomAccessDeniedHandler中的handle方法,同样把他们设置到resources中。

3.创建StudentResourcesController进行测试

    我们在测试权限的过程中可以在资源服务器配置类中设置权限信息,也可以在方法上使用注解来设置权限信息,在这里我使用注解方式来进行测试。代码如下:

@Api(value = "studentResourcesController",tags = "学生资源Controller")
@RequestMapping("/studentResourcesController")
@RestController
public class StudentResourcesController {

    @ApiOperation(value = "测试学生权限", httpMethod = "GET")
    @GetMapping("/testStudent")
    @PreAuthorize("hasRole('STUDENT')")
    public Result test1(){
        return new Result(200,"学生权限才能访问我","456");
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

    @PreAuthorize(“hasRole(‘STUDENT’)”)表示只有学生角色才能访问这个方法,而我们的用户是TEACHER角色,因此会触发CustomAccessDeniedHandler中的handle方法,在swagger那里进行测试。

    访问地址:http://localhost:7778/student-resources-server/swagger-ui.html

    直接访问/studentResourcesController/testStudent接口,结果为:
在这里插入图片描述
在这里插入图片描述

    如果我们输入错误的访问令牌,则会触发CustomAuthenticationEntryPoint中的commence方法,结果如下:
在这里插入图片描述

    经过测试,只要不同的资源服务器有对应的oauth2的配置,这样用户只需要登录一次,接下来就可以一直携带这个访问令牌来访问任意的资源服务器的资源了,从而实现了单点登录的功能。

九、编写教师资源服务器

    代码除了控制器改变了一下,其他代码和学生资源服务器一样。编写完成后的项目结构:
在这里插入图片描述

@Api(value = "teacherResourcesController",tags = "教师资源Controller")
@RequestMapping("/teacherResourcesController")
@RestController
public class TeacherResourcesController {

    @ApiOperation(value = "测试教师权限", httpMethod = "GET")
    @GetMapping("/testTeacher")
    @PreAuthorize("hasRole('TEACHER')")
    public Result test1(){
        return new Result(200,"教师权限才能访问我","666");
    }
    
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

    因为我们的用户角色是TEACHER,那么这次访问这个方法就不会再被拦截了。

    访问地址:http://localhost:7779/teacher-resources-server/swagger-ui.html

    直接访问/teacherResourcesController/testTeacher接口,结果为:
在这里插入图片描述
在这里插入图片描述
至此,教师的权限也得到了验证,单点登录功能测试完成。

总结

    这就是用springsecurity oauth2+jwt方式实现的单点登录功能了,非常的适合前后端分离的项目来使用,即使只有一个认证服务器,没有其它的资源服务器,它也是很适合使用的,因为认证服务器也可以是资源服务器。

    后面我还会根据这个jwt方式的模板去调试spring security的源码,并会编写spring security是如何创建出访问令牌以及如何校验访问令牌等文章,相信可以更加深入的了解springsecurity oauth2使用jwt实现单点登录的流程。

    这个基于springsecurity oauth2+jwt方式的单点登录的项目已经分享到百度网盘中,要运行这个项目只需要开启redis服务,并且修改一下认证服务器application.properties配置文件中的redis配置即可,有需要的来试一下吧。
    链接:https://pan.baidu.com/s/1Gh_WOOA1DpedkraY-aZoWA
    提取码:leon

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Monodyee/article/detail/513894
推荐阅读
相关标签
  

闽ICP备14008679号