前言
大家新年快乐呀!
最近在开发自己的个人网站,考虑到以后要做的一个项目要使用Spring Security,遂学习如何将Spring Security与Spring Boot整合并且用在我的个人网站里做授权认证。
但是实际操作之后才发现,这个东西的坑真的是太多了,而且似乎有很多是因为和Spring Boot整合才出现的。
项目地址:https://github.com/WenDev/WenDev-Web
配置Spring Security
Spring Security与Spring Boot整合之后,配置并不是使用xml文件,而是使用一个专门的类来做配置。这个配置类需要继承WebSecurityConfigurerAdapter
并打上@Configuration
和@EnableWebSecurity
注解。
重写方法并配置时,如果从数据库拿数据(我估计现在应该都这么做吧),就需要重写两个方法:protected void configure(HttpSecurity http)
和protected void configure(AuthenticationManagerBuilder auth)
以下是我的配置:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
UserDetailsService userDetailService() {
return new UserDetailServiceImpl();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.authorizeRequests()
.antMatchers("/login", "/index", "/", "/register").permitAll()
.antMatchers("/css/**", "/js/**").permitAll()
.antMatchers("/superAdmin/**").hasRole("superAdmin")
.anyRequest()
.authenticated()
.and()
.logout().permitAll()
;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService()).passwordEncoder(passwordEncoder());
}
}
Spring Security从数据库中获取用户权限配置
在实际开发中我们肯定是不可能把用户名和密码在代码里写死的,所以我们要进行配置,让Spring Security可以从数据库中获取到用户的权限。
上述配置文件中的protected void configure(AuthenticationManagerBuilder auth)
就是起到这个作用的——加载指定的配置类。这个配置类是一个Service,需要实现UserDetailsService
接口。
在上述文件中注入这个配置时,注入的也不是UserDetailService接口而是这个实现类。使用@Autowired
自动注入的话,需要使用@Qualifier
注解指定实现类是我们写的这个UserDetailServiceImpl
。
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private UserDao userDao;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) {
User user = userDao.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException(username + "用户名不存在");
}
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
System.out.println(user.getRole());
String encodedPassword = passwordEncoder.encode(user.getPassword());
return new org.springframework.security.core.userdetails.User(user.getUsername(),
encodedPassword, true,
true, true,
true, authorities);
}
}
@Service
注解不打好像也不会出问题。
这个类的作用,就是从数据库中根据用户名查询出用户,然后将用户与权限一起包装成org.springframework.security.core.userdetails.User
类型的对象返回。
我使用的实体类User如下,使用了lombok简化开发。我用的是MongoDB,所以注解是Spring Data MongoDB的,MySQL的话只要把注解换成Spring Data JPA的就可以了:
/**
* 用户实体类
*
* @author 江文
*/
@Document(collection = "user")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class User {
@Id
private String id; // 主键
@Field
private String username; // 用户名
@Field
private String password; // 密码
@Field
private String nickname; // 用户昵称
@Field
private String email; // 用户邮箱
@Field
private String role; // 用户权限
@Indexed(name = "register-date", direction = IndexDirection.DESCENDING)
@Field
private Date registerDate; // 注册日期
@Version
private Long version; // 乐观锁
}
坑1:开启CSRF时使用自定义login页面出现403问题
在这个配置文件中,我使用了.loginPage()
方法把login页面换成了自己写的。但是这样做有一个问题——登录成功后会提示403,而加上.csrf().disable()
就没有这个问题了。
解决方法:在HTML代码中form表单里加上这一句:
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
我用的是Thymeleaf,这是Thymeleaf的写法。其实加这句代码的目的是在提交时提交一个_csrf.parameterName
参数,值为_csrf_token
,这个值是由Spring Security生成的。
坑2:Password Encoder
解决了坑1,马上又掉进了一个新坑——就算用户名和密码正确也无法登录,查看输出的话会看到类似于"xxxx Password Encoder xxxxx null"的异常(具体我也想不起来是什么了,不过肯定有password encoder和null这几个单词)。看了一下代码,是因为没有配置password encoder导致的(上面列出的代码里已经配好了,没配好的话第二个configure
方法里就没有调用那个passwordEncoder方法)。
这个Password Encoder还不能直接用,必须定义一个Bean,再选择一个Password Encoder加载到这个Bean里来用。
Spring Security提供了一些Password Encoder可供使用(有些已经废弃了,使用的时候注意甄别),当然你也可以实现PasswordEncoder接口,自己写一个:
这里我选了BCryptPasswordEncoder
。
选好或者自己实现了以后,需要定义一个Bean,把Password Encoder加载进来。
也就是:
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
这样就可以使用了。
但是,使用了之后会出现另一个问题——用户名和密码都正确,但是就是登录不了。查看控制台输出可以发现一条Warning,含义大概是“密码似乎不是被加密的”。
也就是说,Spring Security是把我们输入的密码使用Password Encoder加密后,和从数据库中取出来的明文密码进行对比的,那肯定是不正确的了。
解决的方法是在从数据库中获取用户权限配置的那个类中加上:
String encodedPassword = passwordEncoder.encode(user.getPassword());
然后返回的时候返回这个encodedPassword。
其实这不是最好的解决方法,最好的解决方法还是在将用户信息存储到数据库中时就把加密的密码存进去,那样更加安全,我这种方法只是一个权宜之计,过段时间就把它重构了。
这样配置好后,应该就可以正常登录了。
坑3:登录成功,但是没有权限
现在虽然能成功登录了,但是登录之后没有任何权限。比如,使用数据库中设置权限为“superAdmin”的用户登录,访问/superAdmin/下的页面还是会出现403错误。而我已经在配置类中配置了superAdmin可以访问这个路径。
后来才知道,数据库中设置的权限名前面需要加"ROLE_"前缀。这个大概就是个命名规范问题吧。
解决方法:在获取用户权限时,拼接上“ROLE_”前缀就可以了:
authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
这样就可以正常登录并且成功获取到相应的权限了。
坑4:注册无论是成功还是失败都显示403错误
加上注册功能后,忽然发现注册会返回403错误,无论成功还是失败。
这个错误排查了很久,甚至断点调试了很久都没找到是为什么,只得出了结论:“Spring Security认为我们没有权限访问register
页面”。
但是在之前的配置类里对register
页面授予访问权限之后还是无解。
最后采用了一种“不是办法的办法”解决了这个问题:重写另一个configure
方法,忽略掉对register
页面的检测:
@Override
public void configure(WebSecurity web) {
web
.ignoring()
.mvcMatchers("/css/**", "/js/**", "/images/**")
.mvcMatchers("/register", "/register?error");
}
虽然问题确实解决了,但是这个应该不是最终的解决方案。
坑5:登出之后还是可以正常访问
使用具有superAdmin
的用户登录再登出之后,发现仍然可以访问,而删除cookie之后才是真正登出了。
这个比较好解决,配置一下登出时删除名为JSESSIONID
的cookie就可以了:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login")
.loginProcessingUrl("/auth/login")
.permitAll()
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/login", "/index", "/", "/register").permitAll()
.antMatchers(HttpMethod.POST, "/register").permitAll()
.antMatchers("/login?error", "/register?error").permitAll()
.antMatchers("/superAdmin/**").hasRole("superAdmin")
.anyRequest()
.authenticated()
.and()
.logout()
// .logoutUrl("/logout")
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.permitAll()
;
}
Spring Security的登出默认不支持GET
请求,如果要使用GET
请求方法登出,需要配置logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
才可以正常使用。个人认为默认的只允许POST方法登出比较适合前后端分离的应用,当然像我做的这个前后端不分离的其实用Ajax发POST请求也没啥问题(这里我改成用GET仅仅是因为我懒。。。)。
网友评论