本章我们会一边讲解SpringSecurity框架,一边从头开始编写图书管理系统。
SpringSecurity是一个基于Spring开发的非常强大的权限验证框架,其核心功能包括:
我们为什么需要使用更加专业的全新验证框架,还要从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 "转账失败,您没有登陆!";
}
那么,大家有没有想过这样一个问题,我们为了搜索学习资料时可能一不小心访问了一个恶意网站,而此网站携带了这样一段内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>我是(恶)学(意)习网站</title>
</head>
<body>
<div>
<div>对不起,您还没有充值本站的学习会员,请先充值后再观看学习视频</div>
<form action="http://localhost:8080/mvc/pay" method="post">
<input type="text" name="account" value="hacker" hidden>
<input type="number" name="amount" value="666666" hidden>
<input type="submit" value="点我充值会员,观看完整视频">
</form>
</div>
</body>
</html>
注意这个页面并不是我们官方提供的页面,而是不法分子搭建的恶意网站。我们发现此页面中有一个表单,但是表单中的两个输入框被隐藏了,而我们看到的只有一个按钮,我们不知道这是一个表单,也不知道表单会提交给那个地址,这时整个页面就非常有迷惑性了。如果我们点击此按钮,那么整个表单的数据会以POST的形式发送给我们的服务端(会携带之前登陆我们网站的Cookie信息),但是这里很明显是另一个网站跳转,通过这样的方式,恶意网站就成功地在我们毫不知情的情况下引导我们执行了转账操作,当你发现上当受骗时,钱已经被转走了。
而这种构建恶意页面,引导用户访问对应网站执行操作的方式称为:跨站请求伪造(CSRF,Cross Site Request Forgery)
显然,我们之前编写的图书管理系统就存在这样的安全漏洞,而SpringSecurity就很好地解决了这样的问题。
我们依然使用之前的模板来搭建图书管理系统项目。
导入以下依赖:
<!-- 建议为各个依赖进行分类,到后期我们的项目可能会导入很多依赖,添加注释会大幅度提高阅读效率 -->
<dependencies>
<!-- Spring框架依赖 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>5.5.3</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>5.5.3</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.3.14</version>
</dependency>
<!-- 持久层框架依赖 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.27</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>2.0.6</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.7</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.3.14</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>3.4.5</version>
</dependency>
<!-- 其他工具框架依赖:Lombok、Slf4j -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.22</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jdk14</artifactId>
<version>1.7.32</version>
</dependency>
<!-- ServletAPI -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<!-- JUnit依赖 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
接着创建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是如何进行登陆验证的,我们可以观察一下默认的登陆界面中,表单内有哪些内容:
<div class="container">
<form class="form-signin" method="post" action="/book_manager/login">
<h2 class="form-signin-heading">Please sign in</h2>
<p>
<label for="username" class="sr-only">Username</label>
<input type="text" id="username" name="username" class="form-control" placeholder="Username" required="" autofocus="">
</p>
<p>
<label for="password" class="sr-only">Password</label>
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required="">
</p>
<input name="_csrf" type="hidden" value="83421936-b84b-44e3-be47-58bb2c14571a">
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
</form>
</div>
我们发现,首先有一个用户名的输入框和一个密码的输入框,我们需要在其中填写用户名和密码,但是我们发现,除了这两个输入框以外,还有一个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字符串用于验证,防止攻击。
因此,我们在编写自己的登陆页面时,需要添加这样一个输入框:
<input type="text" th:name="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>
隐藏即可,但是必须要有,而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),否则无法访问,这里主页面就这样写:
<body>
<form action="logout" method="post">
<input type="text" th:name="${_csrf.getParameterName()}" th:value="${_csrf.token}" hidden>
<button>退出登陆</button>
</form>
</body>
</html>
登陆成功后,点击退出登陆按钮,就可以成功退出并回到登陆界面了。
由于我们在学习的过程中暂时用不到CSFR防护,因此可以将其关闭,这样直接使用get请求也可以退出登陆,并且登陆请求中无需再携带Token了,推荐关闭,因为不关闭后面可能会因为没考虑CSRF防护而遇到一连串的问题:
.and()
.csrf().disable();
这样就可以直接关闭此功能了,但是注意,这样将会导致您的Web网站存在安全漏洞。(这里为了之后省事,就关闭保护了,但是一定要记得在不关闭的情况下需要携带Token访问)
用户登录后,可能会根据用户当前是身份进行角色划分,比如我们最常用的QQ,一个QQ群里面,有群主、管理员和普通群成员三种角色,其中群主具有最高权限,群主可以管理整个群的任何板块,并且具有解散和升级群的资格,而管理员只有群主的一部分权限,只能用于日常管理,普通群成员则只能进行最基本的聊天操作。
对于我们来说,用户的一个操作实际上就是在访问我们提供的接口
(编写的对应访问路径的Servlet),比如登陆,就需要调用/login
接口,退出登陆就要调用/logout
接口,而我们之前的图书管理系统中,新增图书、删除图书,所有的操作都有着对应的Servlet来进行处理。因此,从我们开发者的角度来说,决定用户能否使用某个功能,只需要决定用户是否能够访问对应的Servlet即可。
我们可以大致像下面这样进行划分:
/login
、/logout
、/chat
、/edit
、/delete
、/upgrade
/login
、/logout
、/chat
、/edit
/login
、/logout
、/chat
也就是说,我们需要做的就是指定哪些请求可以由哪些用户发起。
SpringSecurity为我们提供了两种授权方式:
两者只是概念上的不同,实际上使用起来效果差不多。这里我们就先演示以角色方式来进行授权。
现在我们希望创建两个角色,普通用户和管理员,普通用户只能访问index页面,而管理员可以访问任何页面。
首先我们需要对数据库中的角色表进行一些修改,添加一个用户角色字段,并创建一个新的用户,Test用户的角色为user,而Admin用户的角色为admin。
接着我们需要配置SpringSecurity,决定哪些角色可以访问哪些页面:
http
.authorizeRequests()
.antMatchers("/static/**").permitAll()
.antMatchers("/index").hasAnyRole("user", "admin") //index页面可以由user或admin访问
.anyRequest().hasRole("admin") //除了上面以外的所有内容,只能是admin访问
接着我们需要稍微修改一下验证逻辑,首先创建一个实体类用于表示数据库中的用户名、密码和角色:
@Data
public class AuthUser {
String username;
String password;
String role;
}
接着修改一下Mapper:
@Mapper
public interface UserMapper {
@Select("select * from users where username = #{username}")
AuthUser getPasswordByUsername(String username);
}
最后再修改一下Service:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
AuthUser user = mapper.getPasswordByUsername(s);
if(user == null)
throw new UsernameNotFoundException("登录失败,用户名或密码错误!");
return User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();
}
现在我们可以尝试登陆,接着访问一下/index
和/admin
两个页面。
基于权限的授权与角色类似,需要以hasAnyAuthority
或hasAuthority
进行判断:
.anyRequest().hasAnyAuthority("page:index")
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
AuthUser user = mapper.getPasswordByUsername(s);
if(user == null)
throw new UsernameNotFoundException("登录失败,用户名或密码错误!");
return User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities("page:index")
.build();
}