Day615.SpringSecurity常见错误 -Spring编程常见错误

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day615.SpringSecurity常见错误 -Spring编程常见错误相关的知识,希望对你有一定的参考价值。

SpringSecurity常见错误

针对SpringSecurity,Java程序员都会知道。他是一种Filter链+职责链设计模式的安全框架。

那在使用它的时候也会出现一些常见的错误,如下就列举一些开发中可能会出现的常见错误。


一、PasswordEncoder匹配不到错误

第一次尝试使用 Spring Security 时,我们经常会忘记定义一个 PasswordEncoder

因为这在 Spring Security 旧版本中是允许的。而一旦使用了新版本,则必须要提供一个 PasswordEncoder。

这里我们可以先写一个反例来感受下:首先我们在 Spring Boot 项目中直接开启 Spring Security:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

添加完这段依赖后,Spring Security 就已经生效了。然后我们配置下安全策略,如下:

@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter 
//
//    @Bean
//    public PasswordEncoder passwordEncoder() 
//        return new PasswordEncoder() 
//            @Override
//            public String encode(CharSequence charSequence) 
//                return charSequence.toString();
//            
//
//            @Override
//            public boolean matches(CharSequence charSequence, String s) 
//                return Objects.equals(charSequence.toString(), s);
//            
//        ;
//    

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception 
        auth.inMemoryAuthentication()
                .withUser("admin").password("pass").roles("ADMIN");

    

    // 配置 URL 对应的访问权限
    @Override
    protected void configure(HttpSecurity http) throws Exception 
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
                .and()
                .formLogin().loginProcessingUrl("/login").permitAll()
                .and().csrf().disable();
    

我们故意注掉了PasswordEncoder注入容器,然后启动SpringBoot,发现项目启动成功。

但是当我们请求对应的http://localhost:8080/admin 地址时,就报了如下错误:

那这是为什么呢???


那为什么需要一个 PasswordEncoder。我们需要一个 PasswordEncoder 来满足加密的功能。
它的两个关键方法 encode()matches(),相信你就能理解它们的作用了。

思考下,假设我们默认提供一个出来并集成到 Spring Security 里面去,那么很可能隐藏错误,所以还是强制要求起来比较合适。

我们再从源码上看下 “no PasswordEncoder” 异常是如何被抛出的?当我们不指定 PasswordEncoder 去启动我们的案例程序时,我们实际指定了一个默认的 PasswordEncoder,这点我们可以从构造器 DaoAuthenticationProvider 看出来:

public DaoAuthenticationProvider() 
	setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());

我们可以看下 PasswordEncoderFactories.createDelegatingPasswordEncoder() 的实现:

public static PasswordEncoder createDelegatingPasswordEncoder() 
   String encodingId = "bcrypt";
   Map<String, PasswordEncoder> encoders = new HashMap<>();
   encoders.put(encodingId, new BCryptPasswordEncoder());
   encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
   encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
   encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
   encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
   encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
   encoders.put("scrypt", new SCryptPasswordEncoder());
   encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
   encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
   encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
   encoders.put("argon2", new Argon2PasswordEncoder());

   return new DelegatingPasswordEncoder(encodingId, encoders);

我们可以换一个视角来看下这个 DelegatingPasswordEncoder 长什么样:

其实它是多个内置的 PasswordEncoder 集成在了一起。

当我们校验用户时,我们会通过下面的代码来匹配,参考 DelegatingPasswordEncoder#matches

private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();

@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) 
   if (rawPassword == null && prefixEncodedPassword == null) 
      return true;
   
   String id = extractId(prefixEncodedPassword);
   PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
   if (delegate == null) 
      return this.defaultPasswordEncoderForMatches
         .matches(rawPassword, prefixEncodedPassword);
   
   String encodedPassword = extractEncodedPassword(prefixEncodedPassword);

   return delegate.matches(rawPassword, encodedPassword);


private String extractId(String prefixEncodedPassword) 
   if (prefixEncodedPassword == null) 
      return null;
   
   //
   int start = prefixEncodedPassword.indexOf(PREFIX);
   if (start != 0) 
      return null;
   
   //
   int end = prefixEncodedPassword.indexOf(SUFFIX, start);
   if (end < 0) 
      return null;
   
   return prefixEncodedPassword.substring(start + 1, end);

假设我们的 prefixEncodedPassword 中含有 id,则根据 id 到 DelegatingPasswordEncoder 的 idToPasswordEncoder 找出合适的 Encoder;假设没有 id,则使用默认的 UnmappedIdPasswordEncoder。我们来看下它的实现:

private class UnmappedIdPasswordEncoder implements PasswordEncoder 
   @Override
   public String encode(CharSequence rawPassword) 
      throw new UnsupportedOperationException("encode is not supported");
   

   @Override
   public boolean matches(CharSequence rawPassword,
      String prefixEncodedPassword) 
      String id = extractId(prefixEncodedPassword);
      throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \\"" + id + "\\"");
   

从上述代码可以看出,no PasswordEncoder for the id “null” 异常就是这样被 UnmappedIdPasswordEncoder 抛出的。

那么这个可能含有 id 的 prefixEncodedPassword 是什么?其实它就是存储的密码,在我们的案例中由下面代码行中的 password() 指定:

auth.inMemoryAuthentication().withUser("admin").password("pass").roles("ADMIN");

这里我们不妨测试下,修改下上述代码行,给密码指定一个加密方式,看看之前的异常还存在与否:

auth.inMemoryAuthentication().withUser("admin").password("MD5pass").roles("ADMIN");

此时,以调试方式运行程序,你会发现,这个时候已经有了 id,且取出了合适的 PasswordEncoder


那知道了如上,对的解决方案如下:

  • 给Ioc容器中注入一个PasswordEncoder
  • 在密码中加个前缀

二、ROLE_ 前缀是否添加错误

我们再来看一个 Spring Security 中关于权限角色的案例,ROLE_ 前缀加还是不加?不过这里我们需要提供稍微复杂一些的功能,即模拟授权时的角色相关控制。

所以我们需要完善下案例,这里我先提供一个接口,这个接口需要管理的操作权限:

@RestController
public class HelloWorldController 
    @RequestMapping(path = "admin", method = RequestMethod.GET)
    public String admin()
         return "admin operation";
    ;

然后我们使用 Spring Security 默认的内置授权来创建一个授权配置类:

@Configuration
public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter 
    @Bean
    public PasswordEncoder passwordEncoder() 
      //同案例1,这里省略掉
    

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception 
        auth.inMemoryAuthentication()
                .withUser("fujian").password("pass").roles("USER")
                .and()
                .withUser("admin1").password("pass").roles("ADMIN")
                .and()
                .withUser(new UserDetails() 
                    @Override
                    public Collection<? extends GrantedAuthority> getAuthorities() 
                        return Arrays.asList(new SimpleGrantedAuthority("ADMIN"));
                    
                    //省略其他非关键“实现”方法
                    public String getUsername() 
                        return "admin2";
                    
                );
    

    // 配置 URL 对应的访问权限
    @Override
    protected void configure(HttpSecurity http) throws Exception 
    http.authorizeRequests()
              .antMatchers("/admin/**").hasRole("ADMIN")
              .anyRequest().authenticated()
              .and()
              .formLogin().loginProcessingUrl("/login").permitAll()
              .and().csrf().disable();
    

通过上述代码,我们添加了 3 个用户:

  • 用户 fujian:角色为 USER
  • 用户 admin1:角色为 ADMIN
  • 用户 admin2:角色为 ADMIN

然后我们从浏览器访问我们的接口 ·http://localhost:8080/admin·,使用上述 3 个用户登录,你会发现用户 admin1 可以登录,而 admin2 设置了同样的角色却不可以登陆,并且提示下面的错误:

那为什么呢?


主要的以两种添加方式,如下:

//admin1 的添加
.withUser("admin").password("pass").roles("ADMIN")

//admin2 的添加
.withUser(new UserDetails() 
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() 
        return Arrays.asList(new SimpleGrantedAuthority("ADMIN"));
     
    @Override
    public String getUsername() 
        return "admin2";
    
    //省略其他非关键代码
);

查看上面这两种添加方式,你会发现它们真的仅仅是两种风格而已,所以最终构建出用户的代码肯定是相同的。

我们先来查看下 admin1 的添加最后对 Role 的处理(参考 User.UserBuilder#roles):

public UserBuilder roles(String... roles) 
   List<GrantedAuthority> authorities = new ArrayList<>(
         roles.length);
   for (String role : roles) 
      Assert.isTrue(!role.startsWith("ROLE_"), () -> role
            + " cannot start with ROLE_ (it is automatically added)");
      //添加“ROLE_”前缀
      authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
   
   return authorities(authorities);


public UserBuilder authorities(Collection<? extends GrantedAuthority> authorities) 
   this.authorities = new ArrayList<>(authorities);
   return this;

可以看出,当 admin1 添加 ADMIN 角色时,实际添加进去的是 ROLE_ADMIN

但是我们再来看下 admin2 的角色设置,最终设置的方法其实就是 User#withUserDetails

public static UserBuilder withUserDetails(UserDetails userDetails) 
   return withUsername(userDetails.getUsername())
      //省略非关键代码
      .authorities(userDetails.getAuthorities())
      .credentialsExpired(!userDetails.isCredentialsNonExpired())
      .disabled(!userDetails.isEnabled());


public UserBuilder authorities(Collection<? extends GrantedAuthority> authorities) 
   this.authorities = new ArrayList<>(authorities);
   return this;

所以,admin2 的添加,最终设置进的 Role 就是 ADMIN。

此时我们可以得出一个结论:通过上述两种方式设置的相同 Role(即 ADMIN),最后存储的 Role 却不相同,分别为 ROLE_ADMIN 和 ADMIN。

那么为什么只有 ROLE_ADMIN 这种用户才能通过授权呢?这里我们不妨通过调试视图看下授权的调用栈,截图如下:


对于案例的代码,最终是通过 “UsernamePasswordAuthenticationFilter” 来完成授权的。

而且从调用栈信息可以大致看出,授权的关键其实就是查找用户,然后校验权限。查找用户的方法可参考 InMemoryUserDetailsManager#loadUserByUsername,即根据用户名查找已添加的用户:

public UserDetails loadUserByUsername(String username)
      throws UsernameNotFoundException 
   UserDetails user = users.get(username.toLowerCase());

   if (user == null) 
      throw new UsernameNotFoundException(username);
   

   return new User(user.getUsername(), user.getPassword(), user.isEnabled(),
         user.isAccountNonExpired(), user.isCredentialsNonExpired(),
         user.isAccountNonLocked(), user.getAuthorities());

完成账号是否过期、是否锁定等检查后,我们会把这个用户转化为下面的 Token(即 UsernamePasswordAuthenticationToken)供后续使用,关键信息如下:


最终在判断角色时,我们会通过 UsernamePasswordAuthenticationToken 的父类方法 AbstractAuthenticationToken#getAuthorities 来取到上述截图中的 ADMIN。

而判断是否具备某个角色时,使用的关键方法是 SecurityExpressionRoot#hasAnyAuthorityName

private boolean hasAnyAuthorityName(String prefix, String... roles) 
   //通过 AbstractAuthenticationToken#getAuthorities 获取“role”
   Set<String> roleSet = getAuthoritySet();

   for (String role : roles) 
      String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
      if (roleSet.contains(defaultedRole)) 
         return true;
      
   

   return false;

//尝试添加“prefix”,即“ROLE_”
private static String getRoleWithDefaultPrefix(String defaultRolePrefix, String role) 
   if (role == null) 
      return role;
   
   if (defaultRolePrefix == null || defaultRolePrefix.length() == 0) 
      return role;
   
   if (role.startsWith(defaultRolePrefix)) 
      return role;
   
   return defaultRolePrefix + role;

在上述代码中,prefix 是 ROLE_(默认值,即 SecurityExpressionRoot#defaultRolePrefix),Roles 是待匹配的角色 ROLE_ADMIN,产生的 defaultedRole 是 ROLE_ADMIN,而我们的 role-set 是从 UsernamePasswordAuthenticationToken 中获取到 ADMIN,所以最终判断的结果是 false。

最终这个结果反映给上层来决定是否通过授权,可参考 WebExpressionVoter#vote

public int vote(Authentication authentication, FilterInvocation fi,
      Collection<ConfigAttribute> attributes) 
   //省略非关键代码 
   return ExpressionUtils.evaluateAsBoolean(weca.getAuthorizeExpression(), ctx) ? ACCESS_GRANTED
     

以上是关于Day615.SpringSecurity常见错误 -Spring编程常见错误的主要内容,如果未能解决你的问题,请参考以下文章

Day610.SpringWebHeader解析常见错误 -Spring编程常见错误

Day616.SpringException常见错误 -Spring常见编程错误

Day609.SpringWebURL解析常见错误 -Spring编程常见错误

Day621.Spring Test 常见错误 -Spring编程常见错误

Day614.SpringWebFilter常见错误② -Spring编程常见错误

Day613.SpringWebFilter常见错误① -Spring编程常见错误