目前市面上常用的安全框架有:
Spring Security、Shiro,还有一个国人开发的框架目前也备受好评:SaToken
但是与spring boot项目融合度最高的还是Spring Security,所以目前我们讲解一下基于spring boot项目来整合spring security来实现常用的登录校验与权限认证;
Spring Security(安全框架)
1、介绍
Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。
如果项目中需要进行权限管理,具有多个角色和多种权限,我们可以使用Spring Security。
采用的是责任链的设计模式,是一堆过滤器链的组合,它有一条很长的过滤器链。
2、功能
Authentication (认证),就是用户登录
Authorization (授权),判断用户拥有什么权限,可以访问什么资源
安全防护,跨站脚本攻击,session攻击等
非常容易结合Springboot项目进行使用,本次就着重与实现认证和授权这两个功能
版本spring boot3.1.16、spring security6.x
身份认证:
1、创建一个spring boot项目,并导入一些初始依赖:
org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-web com.alibaba fastjson 2.0.21 com.mysql mysql-connector-j runtime org.projectlombok lombok true org.springframework.boot spring-boot-starter-test test org.springframework.security spring-security-test test
2、由于我们加入了spring-boot-starter-security的依赖,所以security就会自动生效了。这时直接编写一个controller控制器,并编写一个接口进行测试:
可以看到我们在访问这个接口时出现了拦截,必须要我们进行登录之后才能访问;
那么接下来我们就来实现第一个功能:用户登录认证;
3、自定义用户的登录认证:
Spring Security 6.x 的认证实现流程如下:
- 用户提交登录请求
- Spring Security 将请求交给 UsernamePasswordAuthenticationFilter 过滤器处理。
- UsernamePasswordAuthenticationFilter 获取请求中的用户名和密码,并生成一个 AuthenticationToken 对象,将其交给 AuthenticationManager 进行认证。
- AuthenticationManager 通过 UserDetailsService 获取用户信息,然后使用 PasswordEncoder 对用户密码进行校验。
- 如果密码正确,AuthenticationManager 会生成一个认证通过的 Authentication 对象,并返回给 UsernamePasswordAuthenticationFilter 过滤器。如果密码不正确,则 AuthenticationManager 抛出一个 AuthenticationException 异常。
- UsernamePasswordAuthenticationFilter 将 Authentication 对象交给 SecurityContextHolder 进行管理,并调用 AuthenticationSuccessHandler 处理认证成功的情况。
- 如果认证失败,UsernamePasswordAuthenticationFilter 会调用 AuthenticationFailureHandler 处理认证失败的情况。
看起来有点复杂,其实写起来很简单的。spring security的底层就是一堆的过滤器来是实现的,而我们只需要编写一些重要的过滤器即可,其他的就用spring security默认的实现,只要不影响我们正常的登录功能即可。
(创建一个用户表用来进行登录实现,注意这个表中的用户名不能重复,我们将用户名作为每一个用户的唯一凭证,就如同人身份证号一样)表的结构非常简单,一些配置我这里就不在描述了(实体类、mapper、service、controller等)
认证的实现流程:
1、创建一个UserDetailsService实现SpringSecurity的UserDetailsService接口(这里写的是查询用户的逻辑)
UserDetailsService:此接口中定义了登录服务方法,用来实现登录逻辑。方法的返回值是UserDetails,也是spring security框架定义中的一个接口,用来存储用户信息,我们可以自定义一个类用来实现这个接口,将来返回的时候就返回我们自定义的用户实体类。
实现UserDetailsService接口
@Component public class MyUserDetailsService implements UserDetailsService { /* * UserDetailsService:提供查询用户功能,如根据用户名查询用户,并返回UserDetails *UserDetails,SpringSecurity定义的类, 记录用户信息,如用户名、密码、权限等 * */ @Autowired private SysUserMapper sysUserMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //根据用户名从数据库中查询用户 SysUser sysUser = sysUserMapper.selectOne(new LambdaQueryWrapper() .eq(username != null, SysUser::getUsername, username)); if (sysUser==null){ throw new UsernameNotFoundException("用户不存在"); } MySysUserDetails mySysUserDetails=new MySysUserDetails(sysUser); return mySysUserDetails; } }
(在原有数据库表的基础上)实现UserDetails接口:
@Data @AllArgsConstructor @NoArgsConstructor public class MySysUserDetails implements UserDetails { private Integer id; private String username; private String password; // 用户拥有的权限集合,我这里先设置为null,将来会再更改的 @Override public Collection extends GrantedAuthority> getAuthorities() { return null; } public MySysUserDetails(SysUser sysUser) { this.id = sysUser.getId(); this.username = sysUser.getUsername(); this.password = sysUser.getPassword(); } // 后面四个方法都是用户是否可用、是否过期之类的。我都设置为true @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
2、通过配置类对AuthenticationManager与自定义的UserDetails和PasswordEncoder进行关联
Spring Security是通过AuthenticationManager实现的认证,会借此来判断用户名和密码的正确性
密码解析器spring security框架定义的接口:PasswordEncoder
spring security框架强制要求,必须在spring容器中存在PasswordEncoder类型对象,且对象唯一
@Configuration @EnableWebSecurity //开启webSecurity服务 public class SecurityConfig { @Autowired private MyUserDetailsService myUserDetailsService; @Bean public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder){ DaoAuthenticationProvider provider=new DaoAuthenticationProvider(); //将编写的UserDetailsService注入进来 provider.setUserDetailsService(myUserDetailsService); //将使用的密码编译器加入进来 provider.setPasswordEncoder(passwordEncoder); //将provider放置到AuthenticationManager 中 ProviderManager providerManager=new ProviderManager(provider); return providerManager; } /* * 在security安全框架中,提供了若干密码解析器实现类型。 * 其中BCryptPasswordEncoder 叫强散列加密。可以保证相同的明文,多次加密后, * 密码有相同的散列数据,而不是相同的结果。 * 匹配时,是基于相同的散列数据做的匹配。 * Spring Security 推荐使用 BCryptPasswordEncoder 作为密码加密和解析器。 * */ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); }
3、在登录方法所在的类中注入AuthenticationManager,调用authenticate实现认证逻辑,并且在认证之后返回认证过的用户信息:
controller层:
// 用户登录 @PostMapping("/login") public String login(@RequestBody LoginDto loginDto){ String token= sysUserService.login(loginDto); return token; }
对应的service层的方法:
@Autowired private AuthenticationManager authenticationManager; // 登录接口的具体实现 @Override public String login(LoginDto loginDto) { // 传入用户名和密码 UsernamePasswordAuthenticationToken usernamePassword = new UsernamePasswordAuthenticationToken(loginDto.getUsername(),loginDto.getPassword()); //是实现登录逻辑,此时就回去调用LoadUserByUsername方法 Authentication authenticate = authenticationManager.authenticate(usernamePassword); // 获取返回的用户信息 Object principal = authenticate.getPrincipal(); //强转为MySysUserDetails类型 MySysUserDetails mySysUserDetails = (MySysUserDetails) principal; // 输出用户信息 System.err.println(mySysUserDetails); //返回token String token= UUID.randomUUID().toString(); return token; }
我在test类中设置一些用户数据,并进行测试;
@Autowired private SysUserMapper sysUserMapper;
@Autowired private PasswordEncoder passwordEncoder;
@Test void contextLoads() { //导入了一个用户
SysUser sysUser=new SysUser();
sysUser.setUsername(“zhangsan”); sysUser.setPassword(passwordEncoder.encode(“123456”)); sysUserMapper.insert(sysUser);
}
这里我们已经写好了自定义的登录流程,将项目运行起来(我同时还写了一个普通的test方法,类型是get,用来一起测试)
访问http://localhost:8080/test
这是我们写的一个普通的get方法,我们明明访问的是http://localhost:8080/test这个路径,但是却自动跳转到了Spring Security提供的默认的登录页面;这是因为Spring Security默认所有的请求都要先登录才行,我们在这里登录之后就可以继续访问test页面了;
(由于我们已经实现了UserDetailsService接口,并且在用户表中导入了一条用户数据,那么,这里的用户名和密码就是我们在数据库中存储的用户名和密码)
登录成功之后,我们就可以访问到test的信息了:
既然这个test请求要先进行拦截认证才能访问,那么,我们刚才编写的登录接口sys-user/login岂不是也要先进行拦截认证才能访问,这就与我们编写登录接口的初衷违背了,我们这个接口就是用来登陆的,现在还要先登录认证,之后再访问这个登录接口。那么有没有一种方法,不使用SpringSecurity默认的登录页面呢,使我们编写的登录接口所有人都可以直接访问呢?
4、使用(SecurityFilterChain)过滤器, 配置用户登录的接口可以暴露出来,被所有人都正常的访问(还应在暴露一个注册接口,但我这里就先不写了)
还是在第二步设置的SecurityConfig类中设置过滤器:
在spring security6.x版本之后,原先经常用的and()方法被废除了,现在spring官方推荐使用Lambda表达式的写法。
(因为我们接下来要进行测试,所以禁用CSRF保护,CSRF(Cross-Site Request Forgery)是一种攻击方式,攻击者通过伪造用户的请求来执行恶意操作。)
/* * 配置权限相关的配置 * 安全框架本质上是一堆的过滤器,称之为过滤器链,每一个过滤器链的功能都不同 * 设置一些链接不要拦截 * */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { // 关闭csrf httpSecurity.csrf(it->it.disable()); httpSecurity.authorizeHttpRequests(it-> it.requestMatchers("/sys-user/login").permitAll() //设置登录路径所有人都可以访问 .anyRequest().authenticated() //其他路径都要进行拦截 ); return httpSecurity.build(); }
5、将项目运行起来(我同时还写了一个普通的test方法,类型是get,没有放行,用于测试能不能拦截到):
访问test请求:遇到拦截,说明我们的配置生效了
访问登录页面:能正常访问,且密码正确,返回了一个我们自己生成的一个token。
6、自定义一个登录页面:
SpringSecurity虽然默认有一个登录页面,但是我们一般情况下还是用我们自己写的登录页面,这样可操作性就大了很多;
引入thymeleaf依赖,我们直接在idea项目中建立一个登录页面;
编写一个登录页面,主要是完成用户的登录,同时我们也不再需要频繁的使用postman进行测试了:
自定义的登录页面 用户名:
密码:
这是一个简单的登录页面,就指定了用户名和密码。
并且指定from表单的提交路径为我们自定义的登录接口;将这个页面放在resource/templates目录下,方便我们将来的调用;
HTML中的form表单默认情况下会将数据格式化为key-value形式,而不是JSON格式。
也就是说我们刚刚写的自定义登录接口时是用@RequestBody接受收json类型的数据,这肯定是接受不到的,有两种方法实现:
1、直接用@RequestParam(“username”) ,@RequestParam(“password”)接收这两个参数
2、@ModelAttribute
注解:@ModelAttribute(“formData”) User user //在@ModelAttribute注解内写表单的id,还能使用对象进行接收
我们也可以在前端将from表单的数据转化为json之后,在进行发送,但那样需要写js,我就直接在后端改一下了。
还是使用使用(SecurityFilterChain)过滤器,指定我们自定义的登录表单路径:
/* * 配置权限相关的配置 * 安全框架本质上是一堆的过滤器,称之为过滤器链,每一个过滤器链的功能都不同 * 设置一些链接不要拦截 * */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { // 关闭csrf httpSecurity.csrf(it->it.disable()); // 配置路径相关 httpSecurity.authorizeHttpRequests(it-> it.requestMatchers("/login","sys-user/login").permitAll() //设置登录路径所有人都可以访问 .anyRequest().authenticated() //其他路径都要进行拦截 ); //表单 httpSecurity.formLogin(from-> from.loginPage("/login") //跳转到自定义的登录页面 .loginProcessingUrl("/sys-user/login") //处理前端的请求,与from表单的action一致即可 .defaultSuccessUrl("/index") //默认的请求成功之后的跳转页面,直接访问登录页面 ); return httpSecurity.build(); }
注意,这里还需要将/login这个接口进行放行。
我们知道,不能直接访问login.html这个自定义的登录页面,但是我们可以使用路径映射。先写一个login的get请求,并将这个请求映射到login.html页面。
.defaultSuccessUrl(“/index”):这个方法是我们默认的登录成功之后跳转的请求地址。
如果你之前有请求的地址,但是这个地址没有放行或者你没有登录,那么会自动跳转到我们自定义的登录页面,完成登录之后,会跳转到你最先访问的地址;如果你直接访问的就是/login登录地址,那么默认的登录成功之后跳转到我们指定的地址:/index
@Controller public class Login { @GetMapping("/login") public String login(){ System.out.println("用户进入登录页面"); return "login"; //没使用json返回,直接映射到自定义登录的页面 } @GetMapping("/index") @ResponseBody public String index(){ return "用户登录成功"; } }
现在我们已经自定义了一个登录页面,将项目启动起来进行测试:
我访问/test地址,这个地址没有放行,而且我们这是没有登录,那么会自动跳转到我们自定义的登录页面:
我们进行登录之后,会跳转到/test请求地址:
可以看到我们的结果与我们设想的一样:
现在我们直接访问/login登录页面:可以看到返回了/index页面的内容(这个是我们设置的默认登录成功之后返回的页面)
权限校验:
我们费了很多功夫完成了身份认证,权限校验相对来说是比较简单的:
首先,我先解释一下角色与权限在SpringSecurity中的作用:
-
角色(Role):角色是一组权限的集合,通常代表着用户的身份或职责。在Spring Security中,可以通过配置将角色分配给用户或者用户组,以此来控制用户对系统资源的访问。例如,管理员拥有添加、删除和修改用户的权限,而普通用户只能查看自己的信息。
-
权限(Permission):权限是指对某一特定资源的访问控制,例如读写文件、访问数据库等。在Spring Security中,通常使用“资源-操作”命名方式来定义权限,例如“/admin/* – GET”表示允许访问以/admin/开头的所有URL的GET请求。可以将权限分配给角色,也可以将其分配给单独的用户。
角色与权限之间的关系是多对多的;
建立两张简单的表;一张用来存放角色、一张用来存放权限
角色表:
权限表:
这里建立的两张表只是用来进行测试,正常的数据不可能这么少的。建立相应的实体类;
SpringSecurity要求将身份认证信息存到GrantedAuthority对象列表中。代表了当前用户的权限。 GrantedAuthority对象由AuthenticationManager插入到Authentication对象中,然后在做出授权决策 时由AccessDecisionManager实例读取。 GrantedAuthority 接口只有一个方法
AuthorizationManager实例通过该方法来获得GrantedAuthority。通过字符串的形式表示, GrantedAuthority可以很容易地被大多数AuthorizationManager实现读取。如果GrantedAuthority不 能精确地表示为String,则GrantedAuthorization被认为是复杂的,getAuthority()必须返回null
告知权限的流程:
直接在登录时查询用户的权限,并放在我们自定义的实现了UserDetail的接口类中,用来表示登录用户的全部信息;
在MySysUserDetails类中加入两个属性,记录从数据库中查处的角色和权限信息
我这里就简单一点,不在做多表关联查询了。直接把zhangsan用户设置为超级管理员,拥有所有权限;lisi用户设置为普通管理员,拥有基本权限。
在MyUserDetailsService中实现用户权限的赋值:
@Component public class MyUserDetailsService implements UserDetailsService { /* * UserDetailsService:提供查询用户功能,如根据用户名查询用户,并返回UserDetails *UserDetails,SpringSecurity定义的类, 记录用户信息,如用户名、密码、权限等 * */ @Autowired private SysUserMapper sysUserMapper; @Autowired private SysRoleMapper sysRoleMapper; @Autowired private SysPermissionsMapper sysPermissionsMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //根据用户名从数据库中查询用户 SysUser sysUser = sysUserMapper.selectOne(new LambdaQueryWrapper() .eq(username != null, SysUser::getUsername, username)); if (sysUser==null){ throw new UsernameNotFoundException("用户不存在"); } MySysUserDetails mySysUserDetails=new MySysUserDetails(sysUser); if ("zhangsan".equals(username)){ //zhangsan用户是超级管理员,拥有一切权限 SysRole sysRole = sysRoleMapper.selectOne(new LambdaQueryWrapper().eq(SysRole::getRoleName, "超级管理员")); Set roles=new HashSet(); roles.add(sysRole); mySysUserDetails.setRoles(roles); SysPermissions sysPermissions = sysPermissionsMapper.selectById(1); Set permissions=new HashSet(); permissions.add(sysPermissions.getPermissionsName()); mySysUserDetails.setPermissions(permissions); } if ("lisi".equals(username)){ //lisi用户是普通管理员,拥有基本权限 SysRole sysRole = sysRoleMapper.selectOne(new LambdaQueryWrapper().eq(SysRole::getRoleName, "普通管理员")); Set roles=new HashSet(); roles.add(sysRole); mySysUserDetails.setRoles(roles); SysPermissions sysPermissions = sysPermissionsMapper.selectById(2); Set permissions=new HashSet(); permissions.add(sysPermissions.getPermissionsName()); mySysUserDetails.setPermissions(permissions); } return mySysUserDetails; } }
在实现了UserDetailes接口的用户信息类MySysUserDetails中完成角色和权限的赋值:
// 角色信息 private Set roles; // 权限信息 private Set permissions; // 用户拥有的权限集合,我这里先设置为null,将来会再更改的 @Override public Collection extends GrantedAuthority> getAuthorities() { System.err.println("进入权限的获取方法"); List authorities = new ArrayList(); // 授权信息列表 // 将角色名称添加到授权信息列表中 roles.forEach(role-> authorities.add(new SimpleGrantedAuthority(role.getRoleName()))); // 将权限名称添加到授权信息列表中 permissions.forEach(permission-> authorities.add(new SimpleGrantedAuthority(permission)) ); return authorities; // 返回授权信息列表 }
用户认证之后,会去存储用户对应的权限,并且给资源设置对应的权限,SpringSecurity支持两种粒度 的权限:
1、基于请求的:在配置文件中配置路径,可以使用**的通配符
2、基于方法的:在方法上使用注解实现
角色配置:在UserDetails接口中存在相关的权限和角色管理,只不过我们在实现这个接口的时候,将这些都设置为了null。现在我们只需要将这些信息实现即可:
1、基于请求:
还是在SecurityFilter过滤器中实现请求地址的权限校验
httpSecurity.authorizeHttpRequests(it-> //hello地址只有超级管理员角色才能访问 it.requestMatchers("/hello").hasRole("超级管理员") //hello2地址只有"拥有所有权限"的权限才能访问 .requestMatchers("hello2").hasAuthority("拥有所有权限") .requestMatchers("/login","sys-user/login").permitAll() //设置登录路径所有人都可以访问 .anyRequest().authenticated() //其他路径都要进行拦截 );
使用sili进行登录时,访问hello2接口显示权限不够:
使用zhangsan进行登录时,访问hello2接口可以访问到:
2、基于方法:
基于方法的权限认证要在SecurityConfig类上加上@EnableMethodSecurity注解,表示开启了方法权限的使用;
常用的有四个注解:
@PreAuthorize
@PostAuthorize
@PreFilter
@PostFilter
/*测试@PreAuthorize注解 * 作用:使用在类或方法上,拥有指定的权限才能访问(在方法运行前进行校验) * String类型的参数:语法是Spring的EL表达式 * 有权限:test3权限 * hasRole:会去匹配authorities,但是会在hasRole的参数前加上一个ROLE_前缀, * 所以在定义权限的时候需要加上ROLE_前缀 * role和authorities的关系是:role是一种复杂的写法,有ROLE_前缀,authorities是role的简化写法 * 如果使用 * hasAnyRole:则匹配的权限是在authorities加上前缀ROLE_ * 推荐使用 * hasAnyAuthority:匹配authorities,但是不用在authorities的参数前加上ROLE_前缀 * */ @PreAuthorize("hasAnyAuthority('拥有所有权限')") @ResponseBody @GetMapping("/test3") public String test3(){ System.out.println("一个请求"); return "一个test3请求"; }
/* @PostAuthorize:在方法返回时进行校验。 可以还是校验权限、或者校验一些其他的东西(接下来我们校验返回值的长度) *返回结果的长度大于3、则认为是合法的 returnObject:固定写法,代指返回对象 * */ @ResponseBody @PostAuthorize("returnObject.length()>4") @GetMapping("/test4") public String test4(){ System.out.println("一个test4请求"); return "小张自傲张最终"; }
/* * @PreFilter:过滤符合条件的数据进入到接口 * */ @PostFilter("filterObject.length()>3") @ResponseBody @GetMapping("/test5") public String test5(){ System.out.println("一个test4请求"); List list = new ArrayList(); list.add("张三"); list.add("王麻子"); list.add("狗叫什么"); return "一个test5请求"; }
/* * @PreFilter:过滤符合条件的数据返回,数据必须是Collection、map、Array【数组】 * */ @PreFilter("filterObject.length()>5") @ResponseBody @PostMapping("/test6") public List test6(@RequestBody List list){ return list; }
这四个常用的权限校验方法我都写出来了,运行结果我就不在一一截图了。
需要注意的是这些方法不仅仅局限在权限的校验,还能对返回的结果做一定的操作;
最需要注意的就是@PreFilter注解,它要求前端传递的参数一定是数组或集合;
还有在SpringSecurity框架中:
role和authorities的关系是:role是一种复杂的写法,有ROLE_前缀,authorities是role的简化写法
基于方法鉴权 在SpringSecurity6版本中@EnableGlobalMethodSecurity被弃用,取而代之的是 @EnableMethodSecurity。默认情况下,会激活pre-post注解,并在内部使用 AuthorizationManager。
新老API区别 此@EnableMethodSecurity替代了@EnableGlobalMethodSecurity。提供了以下改进: 1. 使用简化的AuthorizationManager。 2. 支持直接基于bean的配置,而不需要扩展GlobalMethodSecurityConfiguration 3. 使用Spring AOP构建,删除抽象并允许您使用Spring AOP构建块进行自定义 4. 检查是否存在冲突的注释,以确保明确的安全配置 5. 符合JSR-250 6. 默认情况下启用@PreAuthorize、@PostAuthorize、@PreFilter和@PostFilter
主要的权衡似乎是您希望您的授权规则位于何处。重要的是要记住,当您使用基于注释的方法安全性 时,未注释的方法是不安全的。为了防止这种情况,请在HttpSecurity实例中声明一个兜底授权规则。 如果方法上也定义了权限,则会覆盖类上的权限
注意:使用注解的方式实现,如果接口的权限发生变化,需要修改代码了。
总结:
-
登录校验(Authentication):
- 用户提交用户名和密码进行登录。
- Spring Security会拦截登录请求,并将用户名和密码与存储在系统中的凭据(如数据库或LDAP)进行比对。
- 如果用户名和密码匹配,则认为用户通过了身份验证,可以继续访问受限资源。
- 认证成功后,Spring Security会创建一个包含用户信息和权限的安全上下文(Security Context)。
-
权限认证(Authorization):
- 一旦用户通过了身份验证,Spring Security就会开始进行权限认证。
- 针对每个受限资源或操作,可以配置相应的权限要求,例如需要哪些角色或权限才能访问。
- Spring Security会根据配置的权限要求,检查当前用户所拥有的角色和权限,判断是否满足访问条件。
- 如果用户拥有足够的角色或权限,就被允许访问资源;否则将被拒绝访问,并可能重定向到登录页面或返回相应的错误信息。
Spring Security通过身份验证(Authentication)来确认用户的身份,并通过授权(Authorization)来控制用户对受保护资源的访问。这种分离的设计使得安全配置更加灵活,并且可以轻松地对不同的用户和角色进行管理和控制。