SpringSecurity
SpringSecurity是一个基于Spring开发的非常强大的权限验证框架,其核心功能包括:
- 认证 (用户登录)
- 授权 (此用户能够做哪些事情)
- 攻击防护 (防止伪造身份攻击)
我们为什么需要使用更加专业的全新验证框架,还要从CSRF说起。
CSRF跨站请求伪造攻击
我们时常会在QQ上收到别人发送的钓鱼网站链接,只要你在上面登陆了你的QQ账号,那么不出意外,你的号已经在别人手中了。
实际上这一类网站都属于恶意网站,专门用于盗取他人信息,执行非法操作,甚至获取他人账户中的财产,非法转账等。而这里,我们需要了解一种比较容易发生的恶意操作,从不法分子的角度去了解整个流程。
我们在 JavaWeb 阶段已经了解了Session和Cookie的机制,在一开始的时候,服务端会给浏览器一个名为JSESSION的Cookie信息作为会话的唯一凭据,只要用户携带此Cookie访问我们的网站,那么我们就可以认定此会话属于哪个浏览器。
因此,只要此会话的用户执行了登录操作,那么就可以随意访问个人信息等内容。
比如现在,我们的服务器新增了一个转账的接口,用户登录之后,只需要使用POST请求携带需要转账的金额和转账人访问此接口就可以进行转账操作:
@RequestMapping("/index")
public String index(HttpSession session){
session.setAttribute("login", true); //这里就正常访问一下index表示登陆
return "index";
}
@RequestMapping(value = "/pay", method = RequestMethod.POST, produces = "text/html;charset=utf-8") //这里要设置一下produces不然会乱码
@ResponseBody
public String pay(String account,
int amount,
@SessionAttribute("login") Boolean isLogin){
if (isLogin) return "成功转账 ¥"+amount+" 给:"+account;
else return "转账失败,您没有登陆!";
}
那么,大家有没有想过这样一个问题,我们为了搜索学习资料时可能一不小心访问了一个恶意网站,而此网站携带了这样一段内容:
我是(恶)学(意)习网站
对不起,您还没有充值本站的学习会员,请先充值后再观看学习视频
注意这个页面并不是我们官方提供的页面,而是不法分子搭建的恶意网站。
我们发现此页面中有一个表单,但是表单中的两个输入框被隐藏了,而我们看到的只有一个按钮,我们不知道这是一个表单,也不知道表单会提交给那个地址,这时整个页面就非常有迷惑性了。
如果我们点击此按钮,那么整个表单的数据会以POST的形式发送给我们的服务端(会携带之前登陆我们网站的Cookie信息),但是这里很明显是另一个网站跳转,通过这样的方式,恶意网站就成功地在我们毫不知情的情况下引导我们执行了转账操作,当你发现上当受骗时,钱已经被转走了。
而这种构建恶意页面,引导用户访问对应网站执行操作的方式称为:跨站请求伪造(CSRF,Cross Site Request Forgery)
开发环境搭建
导入以下依赖:
org.springframework.security
spring-security-web
5.5.3
org.springframework.security
spring-security-config
5.5.3
org.springframework
spring-webmvc
5.3.14
mysql
mysql-connector-java
8.0.27
org.mybatis
mybatis-spring
2.0.6
org.mybatis
mybatis
3.5.7
org.springframework
spring-jdbc
5.3.14
com.zaxxer
HikariCP
3.4.5
org.projectlombok
lombok
1.18.22
org.slf4j
slf4j-jdk14
1.7.32
javax.servlet
javax.servlet-api
4.0.1
provided
org.junit.jupiter
junit-jupiter-api
${junit.version}
test
org.junit.jupiter
junit-jupiter-engine
${junit.version}
test
接着创建Initializer来配置Web应用程序:
public class MvcInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class>[] getRootConfigClasses() {
return new Class[]{RootConfiguration.class};
}
@Override
protected Class>[] getServletConfigClasses() {
return new Class[]{MvcConfiguration.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
}
创建MVC配置类:
@ComponentScan("book.manager.controller")
@Configuration
@EnableWebMvc
public class MvcConfiguration implements WebMvcConfigurer {
//我们需要使用ThymeleafViewResolver作为视图解析器,并解析我们的HTML页面
@Bean
public ThymeleafViewResolver thymeleafViewResolver(@Autowired SpringTemplateEngine springTemplateEngine){
ThymeleafViewResolver resolver = new ThymeleafViewResolver();
resolver.setOrder(1);
resolver.setCharacterEncoding("UTF-8");
resolver.setTemplateEngine(springTemplateEngine);
return resolver;
}
//配置模板解析器
@Bean
public SpringResourceTemplateResolver templateResolver(){
SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
resolver.setSuffix(".html");
resolver.setPrefix("/WEB-INF/template/");
return resolver;
}
//配置模板引擎Bean
@Bean
public SpringTemplateEngine springTemplateEngine(@Autowired ITemplateResolver resolver){
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
return engine;
}
//开启静态资源处理
@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
//静态资源路径配置
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("/WEB-INF/static/");
}
}
创建Root配置类:
@Configuration
public class RootConfiguration {
}
最后创建一个专用于响应页面的Controller即可:
/**
* 专用于处理页面响应的控制器
*/
@Controller
public class PageController {
@RequestMapping("/index")
public String login(){
return "index";
}
}
接着我们需要将前端页面放到对应的文件夹中,然后开启服务器并通过浏览器,成功访问。
接着我们需要配置SpringSecurity,与MVC一样,需要一个初始化器:
public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
//不用重写任何内容
//这里实际上会自动注册一个Filter,SpringSecurity底层就是依靠N个过滤器实现的,我们之后再探讨
}
接着我们需要再创建一个配置类用于配置SpringSecurity:
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
//继承WebSecurityConfigurerAdapter,之后会进行配置
}
接着在根容器中添加此配置文件即可:
@Override
protected Class>[] getRootConfigClasses() {
return new Class[]{RootConfiguration.class, SecurityConfiguration.class};
}
这样,SpringSecurity的配置就完成了,我们再次运行项目,会发现无法进入的我们的页面中,无论我们访问哪个页面,都会进入到SpringSecurity为我们提供的一个默认登录页面,之后我们会讲解如何进行配置。
至此,项目环境搭建完成。
认证
直接认证
既然系统要求用户登录之后才能使用,所以我们首先要做的就是实现用户验证,要实现用户验证,我们需要进行一些配置:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); //这里使用SpringSecurity提供的BCryptPasswordEncoder
auth
.inMemoryAuthentication() //直接验证方式,之后会讲解使用数据库验证
.passwordEncoder(encoder) //密码加密器
.withUser("test") //用户名
.password(encoder.encode("123456")) //这里需要填写加密后的密码
.roles("user"); //用户的角色(之后讲解)
}
SpringSecurity的密码校验并不是直接使用原文进行比较,而是使用加密算法将密码进行加密(更准确地说应该进行Hash处理,此过程是不可逆的,无法解密),最后将用户提供的密码以同样的方式加密后与密文进行比较。
对于我们来说,用户提供的密码属于隐私信息,直接明文存储并不好,而且如果数据库内容被窃取,那么所有用户的密码将全部泄露,这是我们不希望看到的结果,我们需要一种既能隐藏用户密码也能完成认证的机制,而Hash处理就是一种很好的解决方案,通过将用户的密码进行Hash值计算,计算出来的结果无法还原为原文,如果需要验证是否与此密码一致,那么需要以同样的方式加密再比较两个Hash值是否一致,这样就很好的保证了用户密码的安全性。
我们这里使用的是SpringSecurity提供的BCryptPasswordEncoder,至于加密过程,这里不做深入讲解。
现在,我们可以尝试使用此账号登录,在登录后,就可以随意访问我们的网站内容了。
使用数据库认证
前面我们已经实现了直接认证的方式,那么如何将其连接到数据库,通过查询数据库中的内容来进行用户登录呢?
首先我们需要将加密后的密码添加到数据库中作为用户密码:
public class MainTest {
@Test
public void test(){
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
System.out.println(encoder.encode("123456"));
}
}
这里编写一个测试来完成。
然后我们需要创建一个Service实现,实现的是UserDetailsService,它支持我们自己返回一个UserDetails对象,我们只需直接返回一个包含数据库中的用户名、密码等信息的UserDetails即可,SpringSecurity会自动进行比对。
@Service
public class UserAuthService implements UserDetailsService {
@Resource
UserMapper mapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
String password = mapper.getPasswordByUsername(s); //从数据库根据用户名获取密码
if(password == null)
throw new UsernameNotFoundException("登录失败,用户名或密码错误!");
return User //这里需要返回UserDetails,SpringSecurity会根据给定的信息进行比对
.withUsername(s)
.password(password) //直接从数据库取的密码
.roles("user") //用户角色
.build();
}
}
别忘了在配置类中进行扫描,将其注册为Bean,接着我们需要编写一个Mapper用于和数据库交互:
@Mapper
public interface UserMapper {
@Select("select password from users where username = #{username}")
String getPasswordByUsername(String username);
}
和之前一样,配置一下Mybatis和数据源:
@ComponentScans({
@ComponentScan("book.manager.service")
})
@MapperScan("book.manager.mapper")
@Configuration
public class RootConfiguration {
@Bean
public DataSource dataSource(){
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/study");
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
dataSource.setUsername("root");
dataSource.setPassword("123456");
return dataSource;
}
@Bean
public SqlSessionFactoryBean sqlSessionFactoryBean(@Autowired DataSource dataSource){
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dataSource);
return bean;
}
}
最后再修改一下Security配置:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(service) //使用自定义的Service实现类进行验证
.passwordEncoder(new BCryptPasswordEncoder()); //依然使用BCryptPasswordEncoder
}
这样,登陆就会从数据库中进行查询。
自定义登录界面
前面我们已经了解了如何实现数据库权限验证,那么现在我们接着来看看,如何将登陆页面修改为我们自定义的样式。
首先我们要了解一下SpringSecurity是如何进行登陆验证的,我们可以观察一下默认的登陆界面中,表单内有哪些内容:
我们发现,首先有一个用户名的输入框和一个密码的输入框,我们需要在其中填写用户名和密码,但是我们发现,除了这两个输入框以外,还有一个input标签,它是隐藏的,并且它存储了一串类似于Hash值的东西,名称为"_csrf",其实看名字就知道,这玩意八成都是为了防止CSRF攻击而存在的。
从Spring Security 4.0开始,默认情况下会启用CSRF保护,以防止CSRF攻击应用程序,Spring Security CSRF会针对PATCH,POST,PUT和DELETE方法的请求(不仅仅只是登陆请求,这里指的是任何请求路径)进行防护,而这里的登陆表单正好是一个POST类型的请求。
在默认配置下,无论是否登陆,页面中只要发起了PATCH,POST,PUT和DELETE请求一定会被拒绝,并返回403错误(注意,这里是个究极大坑),需要在请求的时候加入csrfToken才行,也就是"83421936-b84b-44e3-be47-58bb2c14571a",正是csrfToken。
如果提交的是表单类型的数据,那么表单中必须包含此Token字符串,键名称为"_csrf";如果是JSON数据格式发送的,那么就需要在请求头中包含此Token字符串。
综上所述,我们最后提交的登陆表单,除了必须的用户名和密码,还包含了一个csrfToken字符串用于验证,防止攻击。
因此,我们在编写自己的登陆页面时,需要添加这样一个输入框:
隐藏即可,但是必须要有,而Token的键名称和Token字符串可以通过Thymeleaf从Model中获取,SpringSecurity会自动将Token信息添加到Model中。
接着我们就可以将我们自己的页面替换掉默认的页面了,我们需要重写另一个方法来实现:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests() //首先需要配置哪些请求会被拦截,哪些请求必须具有什么角色才能访问
.antMatchers("/static/**").permitAll() //静态资源,使用permitAll来运行任何人访问(注意一定要放在前面)
.antMatchers("/**").hasRole("user") //所有请求必须登陆并且是user角色才可以访问(不包含上面的静态资源)
}
首先我们需要配置拦截规则,也就是当用户未登录时,哪些路径可以访问,哪些路径不可以访问,如果不可以访问,那么会被自动重定向到登陆页面。
接着我们需要配置表单登陆和登录页面:
.formLogin() //配置Form表单登陆
.loginPage("/login") //登陆页面地址(GET)
.loginProcessingUrl("/doLogin") //form表单提交地址(POST)
.defaultSuccessUrl("/index") //登陆成功后跳转的页面,也可以通过Handler实现高度自定义
.permitAll() //登陆页面也需要允许所有人访问
需要配置登陆页面的地址和登陆请求发送的地址,这里登陆页面填写为/login
,登陆请求地址为/doLogin
,登陆页面需要我们自己去编写Controller来实现,登陆请求提交处理由SpringSecurity提供,只需要写路径就可以了。
@RequestMapping("/login")
public String login(){
return "login";
}
配置好后,我们还需要配置一下退出登陆操作:
.and()
.logout()
.logoutUrl("/logout") //退出登陆的请求地址
.logoutSuccessUrl("/login"); //退出后重定向的地址
注意这里的退出登陆请求也必须是POST请求方式(因为开启了CSFR防护,需要添加Token),否则无法访问,这里主页面就这样写: