针对SpringSecurity,Java程序员都会知道。他是一种Filter链+职责链设计模式的安全框架。
那在使用它的时候也会出现一些常见的错误,如下就列举一些开发中可能会出现的常见错误。
一、PasswordEncoder匹配不到错误
第一次尝试使用 Spring Security 时,我们经常会忘记定义一个 PasswordEncoder。
因为这在 Spring Security 旧版本中是允许的。而一旦使用了新版本,则必须要提供一个 PasswordEncoder。
这里我们可以先写一个反例来感受下:首先我们在 Spring Boot 项目中直接开启 Spring Security:
org.springframework.boot spring-boot-starter-security
添加完这段依赖后,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 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("{MD5}pass").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 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 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 attributes) {
//省略非关键代码
return ExpressionUtils.evaluateAsBoolean(weca.getAuthorizeExpression(), ctx) ? ACCESS_GRANTED
: ACCESS_DENIED;
}
很明显,当是否含有某个角色(表达式 Expression:hasRole(‘ROLE_ADMIN’))的判断结果为 false 时,返回的结果是 ACCESS_DENIED。
那对应上面的解决方案为,注意对应的方式是否在声明的时候需要加上前缀
//admin2 的添加
.withUser(new UserDetails() {
@Override
public Collection extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
@Override
public String getUsername() {
return "admin2";
}
//省略其他非关键代码
})
三、总结
- PasswordEncoder
在新版本的 Spring Security 中,你一定不要忘记指定一个 PasswordEncoder,因为出于安全考虑,我们肯定是要对密码加密的。至于如何指定,其实有多种方式。常见的方式是自定义一个 PasswordEncoder 类型的 Bean。还有一种不常见的方式是通过存储密码时加上加密方法的前缀来指定,例如密码原来是 password123,指定前缀后可能是 {MD5}password123。我们可以根据需求来采取不同的解决方案。
- Role
在使用角色相关的授权功能时,你一定要注意这个角色是不是加了前缀 ROLE_。虽然 Spring 在很多角色的设置上,已经尽量尝试加了前缀,但是仍然有许多接口是可以随意设置角色的。所以有时候你没意识到这个问题去随意设置的话,在授权检验时就会出现角色控制不能生效的情况。从另外一个角度看,当你的角色设置失败时,你一定要关注下是不是忘记加前缀了。



