项目
博客
文档
归档
资源链接
关于我
项目
博客
文档
归档
资源链接
关于我
spring-security安全框架讲解
2024-10-22
·
·
原创
·
Spring Security
·
本文共 6,609个字,预计阅读需要 23分钟。
文档地址:https://spring.io/projects/spring-security ### 安全框架是什么 安全框架的本质就是一堆过滤器的组成,目的在于保护系统资源的访问是被允许的,所以在到达资源之前会做一系列的验证工作,这些验证工作通过一系列的过滤器完成。安全框架通常的功能有认证、授权、防止常见的网络攻击,以此为核心拓展其他功能。比如session管理,密码加密,权限管理等功能 ### Apache Shiro #### 起源 Shiro是Apache下的一个开源安全框架,提供了身份验证、授权、密码学和会话管理等关于安全的核心功能。它的前身是JSecurity,2004年,Les Hazlewood和Jeremy Haile创办了Jsecurity。当时他们找不到适用于应用程序级别的合适Java安全框架,同时又对JAAS非常失望,于是就搞了Shiro这个框架。 2004年到2008年期间,JSecurity托管在SourceForge上,贡献者包括Peter Ledbrook、Alan Ditzel和TimVeil。 2008年,JSecurity项目贡献给了Apache软件基金会(ASF),并被接纳成为Apache Incubator项目,由导师管理,目标是成为一个顶级Apache项目。期间,Jsecurity曾短暂更名为Ki,随后因商标问题被社区更名为"Shiro"。随后项目持续在Apache Incubator中孵化,并增加了贡献者Kalle Korhonen。 2010年7月,Shiro社区发布了1.0版,随后社区创建了其项目管理委员会,并选举Les Hazlewood为主席。2010年9月22日,Shrio成为Apache软件基金会的顶级项目(TLP)。 #### 功能 Shiro干净利落地处理身份认证,授权,会话管理和加密。Apache Shiro的首要目标是易于使用和理解。框架应该尽可能掩盖复杂的地方,暴露一个干净而直观的API,来简化开发人员在应用程序安全上所花费的时间。 Apache Shiro是一个拥有许多功能的综合性的安全框架,下图表展示了Shiro的核心功能: Shiro中有四大核心功能一一**身份验证,授权,会话管理和加密**。 1. Authentication:简称为“登录”,这是一个证明用户是谁的行为。 2. Authorization:访问控制的过程,也就是决定“谁”可以去访问"什么” 3. Session Management:管理用户特定的会话,即使在非Web或EjB应用程序。 4. Cryptography:通过使用加密算法保持数据安全同时易于使用。 除此之外,Shiro也提供了额外的功能,来解决在不同环境下所面临的安全问题,尤其是以下这些: 1. Web Support:web支持的API能够轻松地保护Web应用程序。 2. Caching:缓存是用来确保操作的快速而又高效的。 3. Concurrency:ApacheShiro利用它的并发特性来支持多线程应用程序。 4. Testing:测试功能来帮助编写单元测试和集成测试。 5. Run As:一个允许用户以另一个用户身份(如果允许)运行的功能,有时候在管理脚本时很有用。 6. RememberMe:在会话中记住用户的身份,这样用户只需要在强制登录时登录。 #### 特点 Shiro框架具有直观、易用等特性,同时也能提供了健壮的安全性,在常规的企业级应用中,其实也够用了。 ### SpringSecurity课程使用技术版本 - SpringBoot:3.1.5 - SpringSecurity:6.1.5 - MySQL:8.0 - Mybatis-Plus:3.5.x - Redis - JWT ### SpringSecurity SpringSecurity是一个功能强大且高度可定制的,主要负责为Java程序提供声明式的身份验证和访问控制的安全框架。其前身是Acegi Security,后来被收纳为 Spring 的一个子项目,并更名为了Spring Security。 SpringSecurity的底层主要是基于Spring AOP和 Servlet过滤器来实现安全控制,它提供了全面的安全解决方案,同时授权粒度可以在Web请求级和方法调用级来处理身份确认和授权。SpringSecurity是由Spring提供的一个安全框架,依赖于SpringFreamwork的基础功能,也可以将Bean交由Spring管理,充分利用Spring的IOc和AOP,为系统提供安全服务,如果项目使用Spring为基础使用SpringSecurity整合再合适不过。如果你的项目不是用Spring开发的就不要考虑此技术了 #### 功能 SpringSecurity的核心功能主要包括如下几个:· 1. 认证:解决“你是谁”的问题- > 解决的是系统中是否有这个"用户"(用户/设备/系统)的问题,也就是我们常说的"登录” 2. 授权: 权限控制/鉴别,解决的是系统中某个用户能够访问哪些资源,即"你能干什么"的问题。Spring Security支持基于URL 的请求授权、方法访问授权、对象访问授权。 3. 防护攻击:防止身份伪造等各种攻击手段。 4. 加密功能:对密码进行加密、匹配等。 5. 会话功能:对Session进行管理。 6. RememberMe功能:实现"记住我"功能,并可以实现token令牌持久化 #### 两者区别 1. SpringSecurity基于Spring开发,与SpringBoot、、SpringCloud更容易集成 2. SpringSecurity拥有更多功能,如安全防护,对OAuth授权登录的支持 3. SpringSecurity拥有良好的扩展性,更容易自定义实现一些定制需求 4. SpringSecurity的社区资源比Shiro更丰富 5. Shiro相较于SpringSecurity更轻便,简单,使用流程更清晰,上手容易,反观SpringSecurity属于重量级,学习难度比Shiro高 6. Shiro不依赖于其他框架可独立运行,而SpringSecurity需要依赖于Spring容器运行 ### Sa-Token Sa-Token是一款国产安全框架,使用简单,轻便。文档清晰详细,内置多重功能,喜欢的同学可以了解一下 SpringSecurity是什么 SpringSecurity是Spring社区的一个项目,其官方地址为:https://spring:io/prqjects/spring-securitySpringSecurit 是一个强大并支持高度定制的访问控制框架。事实上是保护基于Spring应用的一套标准。 SpringSecurity专注于为Java应用提供身份认证和授权,和所有的Spring项目一样,SpringSecurity很容易扩展满足自定义需求 ### Spring Security 6.1新特性 1. 依赖项和配置的简化:Spring Security 6.1简化了项目的依赖项和配置,通过使用SpringBoot 2.3作为默认的启动器,简化了自动配置和依赖项的处理 2. 改进的身份验证和授权:Spring Security 6.1 对身份验证和授权进行了改进,增加了新的策略如默认的UserDetailService策略,更简单的角色和权限管理,以及更灵活的异常处理 3. 密码存储和加密的增强:SpringSecurity6.1提供了更强大的密码存储和加密功能,支持新的密码加密策略,用户可以自定义密码加密方式 4. 支持JDK16:Spring Security 6.1支持JDK16,可以更好地利用Java 的新特性和API进行开发 5. 提高了默认配置的易用性:SpringSecurity6.1增强了默认配置的易用性,例如默认的认证管理器,默认的安全过滤器链等,使得开发者可以更快速地启动项目 6. 改进的测试支持:Spring Security 6.1对测试提供了更好的支持,包括对测试的简化和性能优化 7. 对WebFlux的支持:Spring Security 6.1支持Spring WebFlux,可以更好地适用于非阻塞式的Web应用 8. 对 Spring Cloud 的集成:Spring Security 6.1与 Spring Cloud 集成更好,可以更好地支持微服务架构的应用 9. 安全性增强:Spring Security6.1对安全性进行了增强,包括对CSRE和CSP等攻击的防御,以及新的安全特性如安全HEADER等的支持 SpringSecurity6.1废弃了很多老的接口,已经不再是过时警告而是直接剔除。在配置上写法也发生变化,后边细说。 #### 创建SpringSecurity项目 SpringSecurity焕发第二春主要是SpringBoot的兴起,让SpringSecurity配置更方便,使用更简单,在使用SpringSecurity时最好与SpringBoot搭配使用,此处以SpringSecurity6.1.5版本为例,首先创建一个基本的Maven工程,此处使用JDK17+SpringBoot3.1.5版本 直接创建SpringBoot项目 #### 在工程中引入springboot和springsecurity依赖 ```xml
org.springframework.boot
spring-boot-starter-security
org.springframework.boot
spring-boot-starter-web
``` 配置类:**SecurityProperties** 默认用户名和密码从哪来在SpringSecurity源码中的SecurityProperties类中做了关于用户、密码,角色的配置,该类中有一个**User内部类**。定义了默认用户名密码及权限。 所以当我们配置name和password时就会使用自定的名字和密码了,当然也可以配置角色roles,当然他是一个集合。 #### 修改默认用户密码 可以在项目的application.yml文件中配置自定义用户名和密码。 ```yaml spring: security: user: name: admin password: 123456 roles: ['admin'] ``` #### SpringSecurity配置类 定义用户名和密码: 基于内存的用户名和密码,角色的定义,后边切换成数据库,目的在于搞明白SpringSecurity认证流程 自定义登录页面: 不使用前后端分离,涉及跨域问题,这里将会使用Thymeleaf实现 ```java /*** * 使用的是SpringSecurity6.1.5,配置类有一些变化 * 1、该类不再需要继承其他的Security定义的类 * 2、需要使用@Configuration才会被Spring容器加载 * 3、废弃了很多方法,比如and()方法,建议使用Lambda表示实现 * */ @Configuration //标记为一个Security类,启用SpringSecurity的自定义配置 @EnableWebSecurity public class SecurityConfig { // 自定义用户密码 @Bean public UserDetailsService userDetailsService() { // {noop} 不加密的密码 UserDetails admin = User.withUsername("admin").password("{noop}1234567").roles("admin", "user").build(); UserDetails vip = User.withUsername("vipUser").password("{noop}123456").roles("user").build(); //将用户存储到SpringSecurity中 InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager(); //1 创建两个用户,springSecurity在运行时就会知道有两个用户 userDetailsManager.createUser(admin); userDetailsManager.createUser(vip); return userDetailsManager; } } ``` UserDetailsService:提供查询用户功能,如根据用户名查询用户,并返回UserDetails UserDetails:记录用户信息,如用户名,密码,权限等 #### 密码加密处理 文档:进入https://spring.io/projects/spring-security,选择`Getting Started (Servlet)`, 进入`Spring Security / Servlet Applications / Authentication / Username/Password / Password Storage` - [PasswordEncoder](https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/password-encoder.html) - [exposing a `PasswordEncoder` Bean](https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html#authentication-password-storage-configuration). SpringSecurity提供密码加密工具:PasswordEncoder,具体实现又很多,此处使用BCryptPasswordEncoder 创建密码编码器 ```java @Bean public PasswordEncoder PasswordEncoderParser() { // 构建密码编码器 return new BCryptPasswordEncoder(); } ``` 测试密码 ```java @Autowired public PasswordEncoder passwordEncoder; // 生成密码加密后的字符申 @Test public void test1() { String pass = "123456"; // 编码 String result = passwordEncoder.encode(pass); System.out.println("result===>" + result); //匹配密码 boolean isTrue = passwordEncoder.matches(pass, result); } ``` 此时密码配置 ```java UserDetails vip = User.withUsername("vipUser").password("$2a$10$/hSNatFmlTgIAyA2QIQpjOZNI4U2svT8Eq3mpGl0S/hr5Nqjy6rgq").roles("user").build(); ``` #### 自定义登录页面 不使用前后端分离,涉及跨域问题,这里将会使用Thymeleaf实现 ##### 自定义登录页面 ###### 引入Thymeleaf的依赖 ```
org.springframework.boot
spring-boot-starter-thymeleaf
``` ###### 定义登陆页面 SpringBoot要求登录页面写在`resources/templates`目录下 login.html ```html
用户名:
密码:
``` 首页 ```html
恭喜你登录成功!
``` ##### 配置 配置跳转登陆页面的请求不需要拦截 ```java @Configuration @EnableWebSecurity public class SecurityConfig { // 配置权限相关的配置 // 安全框架本质都是一堆的过滤器,称之为过滤器链,每一个过滤器功能都不同 // to_Login不要拦截 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //配置关闭csrf机制,现在都是前后端分离的,不再使用csrf,而是在请求头中添加token http.csrf(AbstractHttpConfigurer::disable); //配置请求拦截方式 // permitALl: 随意访问 http.authorizeHttpRequests(auth -> auth.requestMatchers("/to_login") .permitAll().anyRequest().authenticated()); return http.build(); } } ``` 请求接口 ```java @Controller public class LoginController { @GetMapping("to_login") public String toLogin(){ return "login"; } } ``` #### 登录实现 系统中有资源,没有登陆时,访问自动跳转到登录页面,登陆功则可以正常访问 登陆成功之后自动跳转到首页 定义跳转接口 ```java @GetMapping("index") public String index() { return "home"; } ``` 配置 ```java @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //配置关闭csrf机制,现在都是前后端分离的,不再使用csrf,而是在请求头中添加token http.csrf(AbstractHttpConfigurer::disable); //配置请求拦截方式 // permitALl: 随意访问 // requestMatchers:匹配资源路径 可以使用:/** Ant语法,也可以使用正则表达式 // anyRequest:其他任意请求 // authenticated:而要认证之后 http.authorizeHttpRequests(auth -> auth.requestMatchers("/to_login") .permitAll().anyRequest().authenticated()); //表单、Basic等等 http.formLogin(form->form.loginPage("/to_login")//跳转到自定的登录页面 .loginProcessingUrl("/toLogin")//处理前端的请求,与form表单的action一致 .usernameParameter("username")//用户名 .passwordParameter("password")// 密码 .defaultSuccessUrl("/index"));//请求到index接口上 return http.build(); } ``` #### 权限 ##### 两种鉴权方法 前面部分都属于登录,认证 用户认证之后,会去存储用户对应的权限,并且给资源设置对应的权限,SpringSecurity支持两种粒度的权限 https://docs.spring.io/spring-security/reference/features/authorization/index.html - 基于请求的:再配置文件中配置路径,可以使用**的通配符。 https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html - 基于方法的:再方法上使用注解实现。https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html - 动态权限:用户权限被修改之后,不需要用户退出,会自动刷新,也不需要修改代码 ##### 基于请求鉴权 添加接口test1 ```java @RestController public class TestController { @GetMapping("test1") public String index() { return "INTO Test1"; } } ``` ###### 访问角色配置 ```java http.authorizeHttpRequests(auth -> auth.requestMatchers("/test1").hasRole("admin") ``` 根据用户,只有admin有admin的角色权限,该账号登录之后可访问test1接口,而vipUser用户没有,登录后也无法访问 ```java UserDetails admin = User.withUsername("admin") .password("$2a$10$/hSNatFmlTgIAyA2QIQpjOZNI4U2svT8Eq3mpGl0S/hr5Nqjy6rgq") .roles("admin").build(); UserDetails vip = User.withUsername("vipUser") .password("$2a$10$/hSNatFmlTgIAyA2QIQpjOZNI4U2svT8Eq3mpGl0S/hr5Nqjy6rgq") .roles("user").build(); ``` 含有任何一个角色可有访问权限,此时admin与vipUser都有访问权限 ```java http.authorizeHttpRequests(auth -> auth.requestMatchers("/test1").hasAnyRole("admin","user") ``` ###### 访问权限配置 此时只有vipUser都有访问权限 ```java http.authorizeHttpRequests(auth -> auth.requestMatchers("/test1").hasAuthority("test1:show") ``` 给用户配置权限 ```java UserDetails vip = User.withUsername("vipUser") .password("$2a$10$/hSNatFmlTgIAyA2QIQpjOZNI4U2svT8Eq3mpGl0S/hr5Nqjy6rgq") .roles("user") .authorities("test:show") .build(); ``` 任何一个权限 ```java http.authorizeHttpRequests(auth -> auth.requestMatchers("/test1").hasAnyAuthority("test1:show","user:show") ``` ##### 基于方法鉴权 在SpringSecurity6版本中@EnableGlobalMethodSecurity被弃用,取而代之的是@EnableMethodSecurity.默认情况下,会激活pre-post注解,并在内部使用AuthorizationManager。 ###### 新老API区别 此@EnableMethodSecurity替代了@EnableGlobalMethodSecurity。提供了以下改进: 1. 使用简化的AuthorizationManager。 2. 支持直接基于bean的配置,而不需要扩展GlobalMethodSecurityConfiguration 3. 使用SpringAOP构建,删除抽象并允许您使用SpringAOP构建块进行自定义 4. 检查是否存在冲突的注释,以确保明确的安全配置 5. 符合JSR-250 6. 默认情况下启用@PreAuthorize、@PostAuthorize、@PreFilter和@PostFilter 此时定义SecurityConfig需要添加**@EnableMethodSecurity**注解,去掉里面的基于方法鉴权 ```java @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { // 自定义用户密码 @Bean public UserDetailsService userDetailsService() { UserDetails admin = User.withUsername("admin") .password("$2a$10$/hSNatFmlTgIAyA2QIQpjOZNI4U2svT8Eq3mpGl0S/hr5Nqjy6rgq") .roles("admin").build(); UserDetails vip = User.withUsername("vipUser") .password("$2a$10$/hSNatFmlTgIAyA2QIQpjOZNI4U2svT8Eq3mpGl0S/hr5Nqjy6rgq") .roles("user") // 如果是使用@PreAuthorize("hasRole()的话,这样要写成:ROLE_test:show //.authorities("ROLE_test:show") .authorities("test:show","user:name") .build(); InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager(); userDetailsManager.createUser(admin); userDetailsManager.createUser(vip); return userDetailsManager; } @Bean public PasswordEncoder PasswordEncoderParser() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable); http.authorizeHttpRequests(auth -> auth .requestMatchers("/to_login") .permitAll().anyRequest().authenticated()); http.formLogin(form -> form.loginPage("/to_login") .loginProcessingUrl("/toLogin") .usernameParameter("username") .passwordParameter("password") .defaultSuccessUrl("/index")); return http.build(); } } ``` ###### @PreAuthorize 方式基于**@PreAuthorize**注解:role和authorities关系是:role是一种复杂的写法,有RoLE_前缀,authorities是role的简化写法 ```java @RestController public class TestController { /** * 激试@PreAuthorize注解 * 作用:使用类或者方法上,拥有指定的权限才可以访问 * String类型的参数:语法是Spring的EL表达式 * 有权限:test1:show权限 * hasRole:回去匹配authorities,但是会在hasRoLe的参数前加一个ROLE_前缀,所以在定义权限的时候需要将ROLE_前缀 * role和authorities关系是:role是一种复杂的写法,有RoLE_前缀,authorities是role的简化写法 */ // @PreAuthorize("hasRole('test:show')") @PreAuthorize("hasAuthority('test:show')") @GetMapping("pre/test") public String preTest() { System.out.println("preTest==========>"); return "preTest"; } } ``` ###### @PostAuthorize和@PostFilter @PostAuthorize:方法返回时的校验,返回用户名,如果用户名的长度大于3位认为是合法的 @PreFilter:过滤符合条件的数据进入到接口 ```java /** * @PostAuthorize:方法返回时的校验,返回用户名,如果用户名的长度大于3位认为是合法的 * returnObject:是固定写法,就是返回值对象了 */ @PreAuthorize("hasAuthority('user:name')") @PostAuthorize("returnObject.length()>3") @GetMapping("post/authorize") public String postAuthorize() { System.out.println("获取用户名称"); return "张三qq"; //如果是张三长度小于3报错;此出长度大于3,正常返回 } /** * @PreFilter:过滤符合条件的数据进入到接口 * @PostFilter:过滤符合条件的数据返回 */ @PostFilter("filterObject.length()>3") @GetMapping("user/list") public List
userList() { System.out.println("返回用户名"); List
userList = new ArrayList<>(); userList.add("提莫"); userList.add("卑尔维斯"); userList.add("三个字"); return userList; // 大于3的数据只有:卑尔维斯,数组中只返回该数据 } ``` ###### @PreFilter @PreFilter:筛选掉符合条件的数据,数据必须是CoLLection、map、Array【数组】 定义类MyUser ```java public class MyUser { private String userName; private String password; // get/set方法 } ``` 接口定义注解 ```java /** * @PreFilter:筛选掉符合条件的数据,数据必须是CoLLection、map、Array【数组】 * 名字超过3位的才接收 */ @PreFilter(value = "filterObject.userName.length() >3") @PostMapping("user") public List
addUser(@RequestBody List
userList) { for (MyUser myUser : userList) { System.out.println("username==========)" + myUser.getUserName()); } return userList; } ``` 请求接口参数: ```json [{"userName": "张三","password": "123456"}, {"userName": "李四","password": "123456"}, {"userName": "王二麻子","password": "123456"}] ``` 返回结果只有:王二麻子,它的userName长度大于3 ##### 请求级别和方法级别对比 | | 请求级别 | 方法级别 | | -------- | -------------- | ------------ | | 授权类型 | 粗粒度 | 细粒度 | | 配置位置 | 在配置类中配置 | 在方法上配置 | | 配置样式 | DSL | 注解 | | 授权定义 | 编程式 | SpEL表达式 | 主要的权衡似乎是您希望您的授权规则位于何处。重要的是要记住,当您使用基于注释的方法安全性时,未注释的方法是不安全的。为了防止这种情况,请在HttpSecurity实例中声明一个兜底授权规则。 如果方法上也定义了权限,则会覆盖类上的权限 注意:使用注解的方式实现,如果接口的权限发生变化,需要修改代码了。后期会学习动态权限,无需修改代码就可以实现接口权限的修改 ## 第二阶段 基于数据库实现认证和授权 #### 流程 - 提供数据表:单表 - 创建Maven项目:引入相关依、赖配置mysql - 实体类:mapper和service、controller:提供登陆接口 - 配置SpringSecurity 具体的代码: 实体类:UmsSysUser ```java package domain.entity; @Data @TableName("ums_sys_user") public class UmsSysUser implements Serializable, UserDetails { @TableId private Long id; private String username; private String nickname; private String email; private Integer sex; private String avatar; private String password; private Integer status; private Long creator; private Long updater; private LocalDateTime createTime; private LocalDateTime updateTime; @TableLogic // 表字段逻辑处理注解(逻辑删除):value: 默认逻辑未删除值(该值可无、会自动获取全局配置);delval: 默认逻辑删除值(该值可无、公自动获取全局配置) private Integer deleted; private String remark; // @Override => getAuthorities,isAccountNonExpired,isAccountNonLocked ... } ``` 定义mapper: ```java @Mapper public interface UmsSysUserMapper extends BaseMapper
{} ``` 定义service: ```java public interface IUmsSysUserService extends IService
{ String login(LoginParams loginParams); } ``` ```java @Service public class IUmsSysUserServiceImpl extends ServiceImpl
implements IUmsSysUserService { /** * 这个认证需要springsecurity去实现 */ @Override public String login(LoginParams loginParams) { // 实现登录认证,获取token return null; } } ``` 定义controller ```java @RestController @RequestMapping("auth") public class AuthController { private final IUmsSysUserService sysUserService; public AuthController(IUmsSysUserService sysUserService) { this.sysUserService = sysUserService; } @PostMapping("login") private String login(@RequestBody LoginParams loginParams) { String token = sysUserService.login(loginParams); return token; } } ``` 定义请求参数: ```java package domain.dto; @Data public class LoginParams implements Serializable { private String username; private String password; } ``` #### 认证逻辑 文档地址:https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/index.html - 创建—个UserDetailsService实现SpringSecurity的UserDetailsService接口: 写的是查询用户的逻辑 ```java @Service @Slf4j public class UmsSysUserDetailsService implements UserDetailsService { private final UmsSysUserMapper umsSysUserMapper; public UmsSysUserDetailsService(UmsSysUserMapper umsSysUserMapper) { this.umsSysUserMapper = umsSysUserMapper; } // 根据用户名查询用户:如果没有查到用户会出异常UsernameNotFoundException【用户名不存在】 // 返回:UserDetails,SpringSecurity定义的类,用来存储用户信息UmsSysUser:实现了UserDetails接口了,根据多态,它就是一个UserDetails @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return umsSysUserMapper.selectOne(new LambdaQueryWrapper
().eq(UmsSysUser::getUsername, username)); // // TODO: 2024/10/21 后期可以查看权限,角色等等 } } ``` - 通过配置类对AuthenticationManager与自定义的UserDetailsService进行关联:SpringSecurity是通过AuthenticationManager实现的认证,会判断用户名和密码对不对 ```java @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired public UmsSysUserDetailsService detailsService; // 配置过滤拦截:对login接口放行 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable); http.authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**").permitAll() .anyRequest().authenticated()); return http.build(); } /** * AuthenticationManager:负责认证的 * DaoAuthenticationProvider:负责将 sysUserDetailsService、passwordEncoder融合起来送到AuthenticationManager中 */ @Bean public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder) { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(detailsService); //管理使用的解密器 provider.setPasswordEncoder(passwordEncoder); //将provider放置进AuthenticationManager中,包含进去 ProviderManager providerManager = new ProviderManager(provider); return providerManager; } @Bean public PasswordEncoder PasswordEncoderParser() { // 构建密码编码器 return new BCryptPasswordEncoder(); } } ``` - 在登录方法所在的类中注入AuthenticationManager,调用authenticate实现认证逻辑: 认证之后返回认证后的用户信息 ```java @Slf4j @Service public class IUmsSysUserServiceImpl extends ServiceImpl
implements IUmsSysUserService { private final AuthenticationManager authenticationManager; public IUmsSysUserServiceImpl(AuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; } /** * 这个认证需要springsecurity去实现 */ @Override public String login(LoginParams loginParams) { // 实现登录认证,获取token // 传入用户密码 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginParams.getUsername(), loginParams.getPassword()); //实现登录逻辑,此时就会去调用LoadUserByUsername方法 // 返回的Authentication其实就是UserDetails Authentication authenticate = authenticationManager.authenticate(authentication); // 获取返回的用户 UmsSysUser umsSysUser = (UmsSysUser) authenticate.getPrincipal(); // 生成一个token,返回给前端 String token = UUID.randomUUID().toString().replaceAll("-", ""); log.info("登陆后的用户========》{}", umsSysUser); return token; } } ``` - 测试,Post请求/auth/login,用户密码 => 返回token数据 ##### 认证原理 思考:loadUserByUsername为什么被调用,验证需要使用debug ProviderManager是AuthenticationManager的常用实现类,主要用来认证用户的,根据DaoAuthenticationProvider设置的UserDetailsService和PasswordEncoder认证用户,判断用户名和密码是否正确 IAuthenticationException:认证过程中的异常 Authentication:存储用户的认证信息,用户的基本信息,权限等 ![](https://docs.spring.io/spring-security/reference/_images/servlet/authentication/architecture/securitycontextholder.png) 先根据用户名获取用户,再判断用户的状态是否被禁用,是否锁定等,再通过PasswordEncoder去匹配密码,成功之后将用户信息封装到Authentication中返回。 SpringSecurity的认证是通过AuthenticationManager的authenticate方法实现,该方法接收一个Authentication对象,通过也返回一个Authentication对象,Authentication中存储用户的主体【账号】、密码、权限等信息。 同时AuthenticationManager是一个接口,上述的例子是通过他的常用实现类ProviderManager实现的,所以看明白ProviderManager的authenticate方法就可以了。 ```java UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginParams.getUsername(), loginParams.getPassword()); ``` 首先上述代码是构建一个Authentication对象。通过UsernamePasswordAuthenticationToken这个实现类。该 类 用户名和密码读取进来,并进行存储,并设置认证标记为false。具体的认证代码如下: ```java Authentication authenticate = authenticationManager.authenticate(authentication); ``` 首先进入ProviderManager类中,执行authenticate方法,方法最后将用户返回 ```java public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean { public Authentication authenticate(Authentication authentication) throws AuthenticationException { Class extends Authentication> toTest = authentication.getClass(); //获取Authentication的类型,此处为UserPassword...Token AuthenticationException lastException = null; AuthenticationException parentException = null; Authentication result = null; Authentication parentResult = null; int currentPosition = 0; int size = this.providers.size(); Iterator var9 = this.getProviders().iterator(); while(var9.hasNext()) { AuthenticationProvider provider = (AuthenticationProvider)var9.next(); //获取AuthenticationProvider,此处为配置类中配置的DaoAuthenticationProvider if (provider.supports(toTest)) { if (logger.isTraceEnabled()) { Log var10000 = logger; String var10002 = provider.getClass().getSimpleName(); ++currentPosition; var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size)); } try { result = provider.authenticate(authentication); if (result != null) { this.copyDetails(authentication, result); break; } .... } } } ``` 整个逻辑是: - UsernamePasswordAuthenticationToken将用户填写的用户名密码存储下来,设置认证状态为false,它就是一个Authentication类型的对象 - 调用AuthenticationManager.authenticate(Authenticationauthentication)进行用户认证,并返回Authentication,其中记录用户信息和认证状态。 - 首先执行loadUserByUsername方法,根据用户名获取用户。 - 再判断用户状态。 - 再判断密码是否正确。 - 如果失败则重试,最终错误抛出异常 - 如果正确就返回Authentication ##### 处理用户密码错误 修改login中逻辑 ```java Authentication authenticate = null; try { authenticate = authenticationManager.authenticate(authentication); } catch (AuthenticationException e) { log.error("用户名或者密码错误"); return "用户名或者密码错误"; } ``` #### 授权流程 此处我们介绍权限的设计和SpringSecurity权限管理,首先介绍一下比较常见的权限设计控制模型有自主访问控制(DAC)、强制访问控制(MAC)、基于角色的访问控制(RBAC)、基于属性的访问控制(ABAC)、访问控制列表(ACL)等。目前使用广泛的是RBAC权限模型。 ##### DAC模型 允许用户自由地选择他们想要访问的资源。 **用户 =>资源** 以上模型的缺点在于,如果用户拥有相同的权限,新用户还需要一一关联,如果这些相同用户的权限需要修改,同样需要一一修改非常繁琐。由此引I出了RBAC权限模型。 ##### RBAC权限模型 RBAC【Role-based access control】,增加了角色的概念,即基于角色的访问控制,将权限分配给角色,再将角色分配给用户。简单来说,就是【用户关联角色,角色关联权限】。 **用户 => 角色 => 资源** 角色:一组权限的集合 用户:一组角色的集合 RBAC遵循三条安全原则: - 最小权限原则:给角色配置最小但能满足使用需求的权限。 - 责任分离原则:给比较重要或者敏感的事件设置不同的角色,不同的角色间是相互约束的,由其一同参与完成。 - 数据抽象原则:每个角色都只能访问其需要的数据,而不是全部数据,不同的角色能访问到的数据也不同。 由上图可见: - 用户可以有多个角色,一个角色可以分配给多个用户,用户和角色是多对多关系 - 角色可以包含多个权限,一个权限也可以分配给多个角色,用户和角色也是多对多关系 如果要存储到数据库中的话表设计如下: ```sql CREATE TABLE `ums_sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` varchar(30) NOT NULL COMMENT '用户账号', `nickname` varchar(30) NOT NULL COMMENT '用户昵称', `email` varchar(50) DEFAULT '' COMMENT '用户邮箱', `mobile` varchar(11) DEFAULT '' COMMENT '手机号码', `sex` int(11) DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)', `avatar` varchar(100) DEFAULT '' COMMENT '头像地址', `password` varchar(100) DEFAULT '' COMMENT '密码', `status` int(11) DEFAULT '0' COMMENT '帐号状态(0正常 1停用)', `creator` bigint(20) DEFAULT '1' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `updater` bigint(20) DEFAULT '1' COMMENT '更新者', `update_time` datetime DEFAULT NULL COMMENT '更新时间', `remark` varchar(500) DEFAULT NULL COMMENT '备注', `deleted` tinyint(4) DEFAULT '0', PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `ums_sys_user_role` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id', `user_id` bigint(20) NOT NULL COMMENT '用户id', `role_id` bigint(20) NOT NULL COMMENT '角色id', PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; CREATE TABLE `ums_role` ( `role_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色id', `role_label` varchar(255) DEFAULT NULL COMMENT '角色标识', `role_name` varchar(255) DEFAULT NULL COMMENT '角色名字', `sort` int(11) DEFAULT NULL COMMENT '排序', `status` int(11) DEFAULT NULL COMMENT '状态:0:可用,1:不可用', `deleted` int(11) DEFAULT NULL COMMENT '是否删除:0: 未删除,1:已删除', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', PRIMARY KEY (`role_id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; CREATE TABLE `ums_role_menu` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `role_id` bigint(20) DEFAULT NULL, `menu_id` bigint(20) DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表'; CREATE TABLE `ums_menu` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `parent_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '父id', `menu_name` varchar(255) DEFAULT NULL COMMENT '菜单名', `path` varchar(255) DEFAULT NULL COMMENT '访问路径', `sort` int(11) DEFAULT '0' COMMENT '排序', `perms` varchar(255) DEFAULT NULL COMMENT '权限标识', `menu_type` int(11) DEFAULT NULL COMMENT '类型:0,目录,1菜单,2:按钮', `icon` varchar(255) DEFAULT NULL COMMENT '图标', `deleted` int(11) DEFAULT NULL COMMENT '是否删除', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '修改时间', PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单表' ``` ##### SpringSecurity权限认证 SpringSecurity要求将身份认证信息存到GrantedAuthority对象列表中。代表了当前用户的权限。GrantedAuthority对象由AuthenticationManager插入到Authentication对象中,然后在做出授权决策时由AccessDecisionManager实例读取。 GrantedAuthority接口只有一个方法 ```java String getAuthority() ``` AuthorizationManager实例通过该方法来获得GrantedAuthority。通过字符串的形式表示,GrantedAuthority可以很容易地被大多数AuthorizationManager实现读取。如果GrantedAuthority不能精确地表示为String,则GrantedAuthorization被认为是复杂的getAuthority()必须返回null > 我们应该告诉SpringSecurity,当前登陆的用户有什么权限【登陆后使用软件的过程中,权限也可能会被修改】 ###### 告知权限的流程 - 登陆的时候需要查询用户权限,基于RBAC模型实现的权限设计。 - 先获取用户的角色,根据角色获取用户权限。 - 因为SpringSecurity识别权限的数据类型时String,所以我们需要将查询出的权限对象,封装到String类型的集合中【Set集合,数据不可重复】 - 后续操作的时候,需要携带登陆的标记【token或者cookie】,根据token获取用户的信息,在后续的操作过程中继续识别权限。 - 后续的操作尽量不要每访问一次就查一次数据库,对数据库压力很大 ###### 根据数据表创建对应的实体 ```java @Data @TableName("ums_menu") public class UmsMenu implements Serializable { @TableId private Long id; private Long parentId; private String menuName; private String path; private Integer sort; private String perms; private Integer menuType; private String icon; @TableLogic private Integer deleted; private LocalDateTime createTime; private LocalDateTime updateTime; } @Data @TableName("ums_role") public class UmsRole implements Serializable { @TableId private Long roleId; private String roleLabel; private String roleName; private Integer sort; private Integer status; @TableLogic private Integer deleted; private String remark; private LocalDateTime createTime; private LocalDateTime updateTime; } @Data @TableName("ums_sys_user") public class UmsSysUser implements Serializable, UserDetails { @TableId private Long id; private String username; private String nickname; private String email; private Integer sex; private String avatar; private String password; private Integer status; private Long creator; private Long updater; private LocalDateTime createTime; private LocalDateTime updateTime; @TableLogic // 表字段逻辑处理注解(逻辑删除):value: 默认逻辑未删除值(该值可无、会自动获取全局配置);delval: 默认逻辑删除值(该值可无、公自动获取全局配置) private Integer deleted; private String remark; // 角色信息 private Set
roleSet = new HashSet<>(); //权限的信息 private Set
perms = new HashSet<>(); /** * SpringSecurity根据 getAuthorities 方法获取当前用户的权限信息 */ @Override public Collection extends GrantedAuthority> getAuthorities() { // 将权限告知SpringSecurity,通过lambda表达式将Set
转成Collection
if(perms != null && perms.size() > 0) { // 返回权限信息 return perms.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet()); } return null; } @Override public boolean isAccountNonExpired() { return status == 0; } @Override public boolean isAccountNonLocked() { return status == 0; } @Override public boolean isCredentialsNonExpired() { return status == 0; } @Override public boolean isEnabled() { return status == 0; } } ``` ###### 创建对应service和mapper ```java @Mapper public interface UmsMenuMapper extends BaseMapper
{ Set
selectMenuByRoleId(@Param("roleIds") Set
roleIds); } @Mapper public interface UmsRoleMapper extends BaseMapper
{} @Mapper public interface UmsSysUserMapper extends BaseMapper
{ UmsSysUser selectUserByUsername(@Param("username") String username); } ``` xml ```xml
select u.id,u.username,u.nickname,u.email, u.sex, u.avatar, u.password, u.status, u.creator, u.updater, u.create_time, u.update_time, u.deleted, u.remark, r.role_id, r.role_label, r.role_name, r.sort, r.status, r.deleted, r.remark, r.create_time, r.update_time from ums_sys_user u left join ums_sys_user_role sur on u.id = sur.user_id left join ums_role r on sur.role_id = r.role_id where u.deleted = 0 and r.deleted = 0 and u.username = #{username}
``` ```xml
select m.id, m.parent_id, m.menu_name, m.sort, m.perms, m.menu_type, m.icon, m.deleted, m.create_time, m.update_time from ums_menu m left join ums_role_menu urm on m.id = urm.menu_id where urm.role_id in
#{roleId}
``` ###### 在userDetailsService实现类中,查询用户的权限信息 ```java @Service @Slf4j public class UmsSysUserDetailsService implements UserDetailsService { private final UmsSysUserMapper umsSysUserMapper; private final UmsMenuMapper menuMapper; public UmsSysUserDetailsService(UmsSysUserMapper umsSysUserMapper, UmsMenuMapper menuMapper) { this.umsSysUserMapper = umsSysUserMapper; this.menuMapper = menuMapper; } // 根据用户名查询用户:如果没有查到用户会出异常UsernameNotFoundException【用户名不存在】 // 返回:UserDetails,SpringSecurity定义的类,用来存储用户信息UmsSysUser:实现了UserDetails接口了,根据多态,它就是一个UserDetails @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // UmsSysUser umsSysUser = umsSysUserMapper.selectOne(new LambdaQueryWrapper
().eq(UmsSysUser::getUsername, username)); // log.info("响应数据:{}", umsSysUser.toString()); // TODO: 2024/10/21 后期可以查看权限,角色等等 可以用join查询 join查询一般不要超过三张表联查 // 做用户信息查询,不要多次访问数据库,尽量一次查出需要的数据,多表查询不要超过3张表 // 1、查询用户的角色信息 UmsSysUser umsSysUser = umsSysUserMapper.selectUserByUsername(username); // 2、查询用户的权限信息 if(umsSysUser != null) { Set
roleSet = umsSysUser.getRoleSet(); // 存储角色id,进行批量查询,不要在for循环中查询数据库 Set
roleIds = new HashSet<>(roleSet.size()); // 获取用户的权限列表 Set
perms = umsSysUser.getPerms(); for (UmsRole umsRole : roleSet) { roleIds.add(umsRole.getRoleId()); } // 权限查询 Set
menus = menuMapper.selectMenuByRoleId(roleIds); for (UmsMenu menu : menus) { String perm = menu.getPerms(); // 添加用户权限到set中 perms.add(perm); } } return umsSysUser; } } ``` #### 携带登录信息 HTTP协议是无状态请求,即一次会话不会记录上一次会话的内容。所以需要通过携带数据的方式告知服务器我是谁,以便服务器知道这个人有没有权限访问这个数据。最初使用cookie的方式携带 ##### cookie Cookie,有时也用其复数形式Cookies。类型为"小型文本文件”,是某些网站为了辨别用户身份,进行Session跟踪而储存在用户本地终端上的数据(通常经过加密),由用户客户端计算机暂时或永久保存的信息。 请求【request] -> 响应【response】+cookie -> 请求【request】+cookie cookie中存储用户的相关信息,但是如果信息越来越多,那么cookie就会越来越大。此时就引I出了session ##### seesion 考虑将用户的信息存储到服务器端,请求时只携带可以识别用户身份的身份信息就可以了,再根据用户身份获取用户的其他资料比如:实名信息,订单信息,访问记录等 登录 - > (服务端)生成session -> 响应 【response】+sessionld【存情在cookie中】-> 请求【request]+ sessionld -> (服务端)返回用户需要的数据 -> 响应+sessionld 1. 首先是用户登录,服务端会生成一个session,再生成该session的唯一标识sessionld,这个sessionld可以得知是哪个用户,再将此sessionld通过cookie告知客户端 2. 之后客户端的请求都再cookie中携带这个sessionld,服务端获取sessionld,找到对应的用户,再去处理请求就可以了 session的作用就是缩小了cookie的体积,并且cookie存储在客户端,session存储在服务端。 随着网民增长,软件用户增加,服务会采用分布式,集群等方式部署,保障可以为更多用户提供服务,session就会出现问题,即session如果在A服务器生成,后续请求如果发送到B服务,则B服务器就无法得知是哪个用户.此时可以采用:**session复制**: 这种方式有延迟,而且复杂 ##### token 还有一种方式就是将用户信息加密变成字符串,直接发给客户端,客户端后边请求都携带这个加密字符串,我们称之为Token【令牌】,服务端拿到字符串之后可以解密获取到用户信息,这样就不需要查询数据库了。多个服务端加密算法相同,也就可以完成解密工作,这个生成Token的技术可以选用JWT【lsonWebTokens】。而目这个字符串我们会放到请求头【header】中,这个字段根据自己的需求定义 ###### JWT结构 JWT是一个很长的字符串,由三部分组成。但是JWT内部并没有换行,而是由.隔开 - Header (头部) - Payload(负载) - Signature(签名) ###### Header Header部分是一个JSON对象,描述IWT的元数据,通常是下面的样子 ```json { "alg":"HS256" "typ": "JWT" } ``` - alg:表示签名使用的算法,默认为HMAC SHA256(写为HS256) - typ:表示令牌的类型,JWT令牌统一写为JWT。 最后,使用Base64URL算法将上述JSON对象转换为字符串保存。 ###### Payload Payload部分也是一个JSON对象,用来存放实际需要传递的数据。JWT规定了7个官方字段,供选用 - iss(issuer):签发人/发行人 - sub (subject):主题 - aud(audience):用户 - exp(expiration time):过期时间 - nbf(NotBefore):生效时间,在此之前是无效的 - iat(lssued At):签发时间 - jti(WTID):用于标识该JWT 除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。 ``` {"sub":"1234567890""name”:"John Doe”,"admin":true} ``` 注意,WT默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。这个JSON对象也要使用Base64URL算法转成字符串。 ###### Signature Signature部分是对前两部分的签名,防止数据算改。 首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用Header里面指定的签名算法(默认是HMACSHA256),按照下面的公式产生签名。 算出签名以后,把Header、Payload、Signature三个部分拼成一个字符串,每个部分之间用"点”(.)分隔,就可以返回给用户。 ###### Base64URL 前面提到,Header和Payload串型化的算法是Base64URL。这个算法跟Base64算法基本类似,但有一些小的不同。 JWT作为一个令牌(token),有些场合可能会放到URL(比如api.example.com/7token=xxx)。Base64有三个字符+、/和=,在里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_。这就是Base64URL算法 ##### 引入JWT ```xml
io.jsonwebtoken
jjwt
0.9.1
javax.xml.bind
jaxb-api
2.3.1
``` ##### JWT两个核心操作 ###### 生成token 基于jwt将用户信息转换成字符串,并且加密 ###### 解析用户信息 根据字符串,解析出用户信息 ```java @Component public class JwtUtils { private String secret = "qwertyuioplkjnbvfdcxsaz"; /** * 生成token */ public String createToken(Map
map) { String token = Jwts.builder() .setClaims(map) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); return token; } /** * 根据token解析出用户信息 */ public Claims parseToken(String token) { // 解析token,需要使用和创建token时相同的秘钥 Claims claims = Jwts.parser().setSigningKey(secret) .parseClaimsJws(token) .getBody(); return claims; } } ``` SysUserServiceImpl中返回JWT加密的token ```java @Slf4j @Service public class IUmsSysUserServiceImpl extends ServiceImpl
implements IUmsSysUserService { @Autowired private final JwtUtils jwtUtils; @Override public String login(LoginParams loginParams) { .... // 将用户信息通过JWT生成token,返回给前端 Map
map = new HashMap<>(); map.put("id",umsSysUser.getId()); map.put("username",umsSysUser.getUsername()); map.put("avatar",umsSysUser.getAvatar()); map.put("perms",umsSysUser.getPerms()); return jwtUtils.createToken(map); } } ``` ##### 鉴权 访问其他接口的时候携带token,在接口上添加权限校验【基于方法的权限校验】。我们就需要在请求时获取请求头的token字段,我们需要自定义过滤器,并且将过滤器添加到SpringSecurity的过滤器链中。【使用了责任链模式】 ```java /** * 捕获请求中的请求头,获取token字段,判断是否可以获取用户信息
* 我们可以继承 OncePerRequestFilter 抽象类
* * 1、获取到用户信息之后,需要将用户的信息告知SpringSecurity,SpringSecurity会去判断你访问的接口是否有相应的权限
* 2、告知SpringSecurity 就是使用Authentication告知框架,SpringSecurity、会将信息存储到SecurityContext中-----》SecurityContextHolder中
* * 登录的时候,放置的数据是用户名和密码。是要查找用的
* 后边请求,判断权限的时候,放置进去的数据是用户的信息。密码就不需要了,还有用户的权限 */ @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtils jwtUtils; /** * 该方法会被doFilter调用 */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 获取token String token = request.getHeader("Authorization"); System.out.println("token======>" + token); // login请求就没token,直接放行,因为后边有其他的过滤器 if(token == null) { doFilter(request,response,filterChain); return; } // 有token,通过jwt工具类,解析用户信息 Claims claims = null; try { claims = jwtUtils.parseToken(token); }catch (SignatureException e){ // 需要返回401,重新登陆 response.setCharacterEncoding("UTF-8"); response.getWriter().write("验签失败!!!"); return; } System.out.println("claims======>" + claims); // 获取到了数据,将数据取出,放到UmsSysUser中 Long id = claims.get("id", Long.class); String username = claims.get("username", String.class); String avatar = claims.get("avatar", String.class); List
perms = claims.get("perms", ArrayList.class); // 将信息放到User类中 UmsSysUser umsSysUser = new UmsSysUser(); umsSysUser.setId(id); umsSysUser.setUsername(username); umsSysUser.setAvatar(avatar); umsSysUser.setPerms(new HashSet<>(perms)); System.out.println("umsSysUser======>" + umsSysUser); // 将用户信息放到SecurityContext中 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(umsSysUser, null, umsSysUser.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); // 放行 doFilter(request,response,filterChain); } } ``` 在SecurityConfig添加上该过滤器 ```java @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable); http.authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**").permitAll() .anyRequest().authenticated()); // 将过滤器添加到过滤器链中 // 将过滤器添加到 UsernamePasswordAuthenticationFilter 之前 http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } } ``` ##### 自定义过滤器 jwt解析数据时,集合类型,会转换为ArrayList #### 安全性 ##### CSRF攻击 攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并运行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过(cookie里带来sessionld等身份认证的信息),所以被访问的网站会认为是真正的用户操作而去运行。 比如用户登录了某银行网站(假设为http://www.examplebank.com/,并且转账地址为http://www.examplebank.com/withdraw?amount=1000&transferTo=PayeeName),登录后cookie里会包含登录用户的sessionid,攻击者可以在另一个网站上放置如下代码 ```
``` 那么如果正常的用户误点了上面这张图片。由于相同域名的请求会自动带上cookie,而cookie里带有正常登录用户的sessionid,类似上面这样的转账操作在server就会成功,会造成极大的安全风险 CSRF攻击的根本原因在于对于同样域名的每个请求来说,它的cookie都会被自动带上,这个是浏览器的机制决定的,所以很多人据此认定cookie不安全 使用token确实避免了CSRF的问题,但正如上文所述,由于toker保存在localstorage,它会被JS读取,从存储角度来看也不安全(实际上防护CSRF攻击的正确方式是用CSRF token) 所以不管是cookie还是token,从存储角度来看其实都不安全,都有暴露的风险,我们所说的安全更多的是强调传输中的安全,可以用HTTPS协议来传输,这样的话请求头都能被加密,也就保证了传输中的安全。 如果有人间cookie和token有什么区别: ## 第三节点 动态权限控制 - 动态权限控制 - rememberMe功能 - OAuth2.0 - 自定义登录成功 - 自定义登录失败 #### 动态权限控制 SpringSecurity中我们可以使用基于注解的方式实现,可以控制方法级别的权限认证,有一个问题在于项目中的接口权限如果发生变化,此时代码就要修改,修改之后要重新上线。基于此我们希望可以通过修改配置的方式,去控制接口的权限,实现动态控制。此处我们是通过数据库配置的方式修改【此处也可以将数据缓存到Redis中,因为接口权限不会经常变化】,在SpringSecurity6中实现动态权限控制: - SpringSecurity配置文件中再其它请求的配置后边,添加access配置,传入一个【AuthorizationManager】 - 自定义类实现AuthorizationManager接口 #### 实现思路 在实现类中查询当前访问的接口所需要的权限,并且根据当前认证的用户所拥有的权限,判断是否包含接口所需权限,所有数据库中应该有数据记录接口的访问权限 访问路径:其实是一个url,可以对url进行权限设置。比如sys/employee/list他的作用是访问员工列表,他需要sys:employee:list【这个权限也是存到数据库中】 ##### 实现AuthorizationManager接口 在该接口中,获取访问路径【URI】,再获取用户的权限列表,做判断就行了,写完之后需要配置到配置类中 ```java /** * 判断请求路径是否有权限访问 */ @Component @Slf4j public class SttAuthorizationManager implements AuthorizationManager
{ private final UmsMenuMapper menuMapper; public SttAuthorizationManager(UmsMenuMapper menuMapper) { this.menuMapper = menuMapper; } @Override public AuthorizationDecision check(Supplier
authentication, RequestAuthorizationContext requestAuthorizationContext) { // 获取请求路径,获取HttpServletRequest HttpServletRequest request = requestAuthorizationContext.getRequest(); String uri = request.getRequestURI(); String url = request.getRequestURL().toString(); log.info("uri=============>{}",uri); log.info("url=============>{}",url); // 有些请求不需要认证 if("/auth/login".equals(uri) || "/logout".equals(uri) || "/error".equals(uri)) { return new AuthorizationDecision(true); } // 根据uri获取路径的权限 UmsMenu umsMenu = menuMapper.selectOne(new LambdaQueryWrapper
().eq(UmsMenu::getPath, uri.replaceFirst("/",""))); if(umsMenu == null) { return new AuthorizationDecision(false); } // 获取路径访问权限 String menuPerm = umsMenu.getPerms(); log.info("路径权限=============>{}",menuPerm); if(menuPerm == null || menuPerm.trim().equals("")) { return new AuthorizationDecision(true); } // 与用户权限集合做判断 Collection extends GrantedAuthority> authorities = authentication.get().getAuthorities(); for (GrantedAuthority authority : authorities) { String userPerm = authority.getAuthority(); log.info("用户权限=============>{}",userPerm); if(userPerm.equals(menuPerm)) { return new AuthorizationDecision(true); } } return new AuthorizationDecision(false); } } ``` 然后再SecurityConfig中控制 ```java @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private SttAuthorizationManager authorizationManager; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable); http.authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**").permitAll() .anyRequest().access(authorizationManager)); .... } } ``` ##### 动态权限问题 1. 请求时每次都需要查询数据库菜单的权限,会对数据库造成压力。可以通过redis来缓解数据库压力,引入redis之后需要解决redis与mysql的数据一致性问题 2. 用户的权限是在登陆时就获取到的,后续的操作并不会获取最新的权限,此时也是可以通过redis来存储用户信息【包含权限信息】,修改权限之后需要修改: - redis中缓存的权限数据 - 用户的权限数据: 用户很多,修改繁琐; 用户量不大,改了也就改了 > 权限这种数据—般很少修改 #### rememberMe功能 rememberMe也就是记住我功能,在登陆时可以勾选此选项,客户端会记住我们的登录信息,下次访问时可以记住登录信息,实现自动登录。 QQ邮箱 163邮箱 记住我或持久登录身份验证是指网站能够在会话之间记住主体的身份。这通常是通过向浏览器发送cookie来实现的,cookie在后续的会话中被检测到,并导致自动登录。SpringSecurity为这些操作提供了两个具体的实现。 - 一种使用哈希来保持基于cookie的令牌的安全性 - 另一种使用数据库或其他持久存储机制来存储生成的令牌。 请注意,这两个实现都需要UserDetailsService。 ##### 简单哈希方法 这种方法使用散列来实现有用的记住我的策略。本质上,一个cookie在成功的交互验证后被发送到浏览器,cookie的组成如下: ``` base64(username +":"+ expirationTime+":"+ algorithmName+":" algorithmHex(username +":"+ expirationTime+":"+ password + ":"+ +key)) ``` - username:可由userDetailsService识别 - password:与检索到的userDetai1s中的匹配 - expirationTime:“记住我“令牌过期的日期和时间,以毫秒表示 - key:防止修改记住我令牌的私钥 - algorithmName:用于生成和验证记住我的令牌签名的算法 记住我令牌仅在指定的时间段内有效,并且仅在用户名、密码和密钥未更改的情况下有效。值得注意的是,这有一个潜在的安全问题,因为捕获的记住我令牌在令牌到期之前可以从任何用户代理使用。如果主体知道令牌被捕获,他们可以很容易地更改密码,并立即使所有有问题的记住我的令牌无效。如果需要更重要的安全性,应该使用持久化令牌。或者,根本不应该使用“记住我”服务。 ```java @Configuration @EnableWebSecurity public class SecurityConfig { // 配置,启用rememberMe功能 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 关闭csrf http.csrf(csrf -> csrf.disable()); // 配置请求拦截策略 http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); // 使用SpringSecurity默认的登录页面 http.formLogin(Customizer.withDefaults()); // 开启默认的记住我功能 http.rememberMe(remember -> remember.rememberMeCookieName("rememberMe")); return http.build(); } ``` ##### 持久化令牌 持久化令牌就是将令牌存储在数据库总,数据库应包含一个persistent_logins表,该表通过使用以下SQL(或等效SQL)创建: ```sql CREATE TABLE `persistent_logins` ( `username` varchar(64) NOT NULL, `series` varchar(64) NOT NULL, `token` varchar(64) NOT NULL, `last_used` timestamp NOT NULL, PRIMARY KEY (`series`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` 创建PersistentLogins的实体和mapper 实现PersistentTokenRepository:自定义实现类对数据表进行操作,其实就是在前端访问的时候,需要生成token,存到数据表中,退出登录需要将tokne删除掉,访问其它接口的时候需要获取token【判断是否登录】,或者说下次来访问的时候,需要判断他有没有选择自动登录。还回去更新token。其实就是在实现类中实现token的,增删改查方法。 ```java @Component @Slf4j public class DaoCaoPersistentTokenRepositoryImpl implements PersistentTokenRepository { private final PersistentLoginsMapper persistentLoginsMapper; public DaoCaoPersistentTokenRepositoryImpl(PersistentLoginsMapper persistentLoginsMapper) { this.persistentLoginsMapper = persistentLoginsMapper; } /** * 创建token * @param token */ @Override public void createNewToken(PersistentRememberMeToken token) { log.info("createNewToken=====>"); PersistentLogins persistentLogins = new PersistentLogins(); persistentLogins.setSeries(token.getSeries()); persistentLogins.setUsername(token.getUsername()); persistentLogins.setToken(token.getTokenValue()); persistentLogins.setLastUsed(token.getDate()); // 存储到数据库中 persistentLoginsMapper.insert(persistentLogins); } @Override public void updateToken(String series, String tokenValue, Date lastUsed) { LambdaUpdateWrapper
updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.set(PersistentLogins::getToken,tokenValue).set(PersistentLogins::getLastUsed,lastUsed) .eq(PersistentLogins::getSeries,series); // 调用修改方法 persistentLoginsMapper.update(null,updateWrapper); } /** * 获取token * @param seriesId * @return */ @Override public PersistentRememberMeToken getTokenForSeries(String seriesId) { LambdaQueryWrapper
queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(PersistentLogins::getSeries,seriesId); // 调用查询方法 PersistentLogins result = persistentLoginsMapper.selectOne(queryWrapper); PersistentRememberMeToken retrunResult = new PersistentRememberMeToken( result.getUsername(),result.getSeries(),result.getToken(),result.getLastUsed() ); return retrunResult; } @Override public void removeUserTokens(String username) { LambdaUpdateWrapper
updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(PersistentLogins::getUsername,username); persistentLoginsMapper.delete(updateWrapper); } } ``` 在实现 ```java @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private DaoCaoPersistentTokenRepositoryImpl persistentTokenRepository; // 配置,启用rememberMe功能 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 关闭csrf http.csrf(csrf -> csrf.disable()); // 配置请求拦截策略 http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); // 使用SpringSecurity默认的登录页面 http.formLogin(Customizer.withDefaults()); // 开启默认的记住我功能 http.rememberMe(remember -> remember.rememberMeCookieName("rememberMe").tokenRepository(persistentTokenRepository)); return http.build(); } ``` ##### 实现原理 RememberMeAuthehticationFilter ###### 总结 1. RememberMe实质就是将令牌以Cookie的形式响应给浏览器,然后浏览器进行本地存储,等下次再次访问资源的时候,即会拿该令牌连请求一起到服务器端,令牌会使得后台进行自动登录(即用户认证)。 2. 持久化token方式在关闭浏览器再次访问时存储的cookie值会发生变化,而哈希方式并不会。 3. 当我们自定义SecurityFilterChan安全过滤器链的时候,如果配置了rememherMel)的话RememberMeAuthenticationFilter即会是过滤器链中的一员。 4. 阐述一下流程(实现该功能最主要的类是RememberMeServices,首次认证的响应和自动登录的认证它都是主角):在没有自定义过滤器的情况下,一般都是使用UsernamePasswordAuthenticationFilter进行用户认证的,当点击了checkbox按钮连同用户信息一起向服务器端请求时,首先是UsernamePasswordAuthenticationFilter去完成认证,如果认证成功会调用RememberMeServices中的onLoginSuccess方法(这个看具体实现),此时响应中就已存在其令牌信息了(即Cookie)。当关闭浏览器或Session过期,访问服务器端需要认证的资源时,请求报文中会带着这个Cookie一起到服务器端,会进入到RememberMeAuthenticationFilter过滤器中,它会调用RememberMeServices中的autoLogin方法进行自动登录,在autoLogin自动登录过程中,会调用processAutoLoginCookie方法进行令牌认证(该方法取决于RememberMeServices的实现类),autoLogin方法执行完成认证成功后,返回了用户数据源信息,接下来就是一些封装数据信息到SecurityContextHolder中等等一些操作。 5. RememberMeServices是核心类,Spring Security中提供了两种实现类,一种是TokenBasedRememberMeServices,这种方式就是将用户名、超时时间、密码等信息进行Base64编码即一些操作组成的令牌给服务器,验证令牌就是对浏览器中发来的进行反编码看看是否一致,这种方式安全度很低;另一种实现是PersistentTokenBasedRememberMeServices,它内部依赖于PersistentTokenRepository仓库,提供了基于内存和基于Idbc的实现,它比前一种安全,原因是它每次自动认证后会更新令牌(即Cookie),如果在自动认证过程中发现令牌不一致会及时剔除(即从PersistentTokenRepository仓库中删除),报Cookie被盗窃异常。 6. PersistentTokenBasedRememberMeServices中同TokenBasedRememberMeServices一样的是它t也使用的是Base64编码后进行令牌设置,自动登录认证令牌的时候也需要解码;不同的是它是由两数据组成的(序列号(固定的),token(会变化的)),序列号在onLoginSuccess方法中生成的,然后存在浏览器上,在processAutoLoginCookie方法中不会改变这个序列号,只会变token,可以说浏览器Cookie解码后的序列号是固定死的(没有重新登录验证的情况下)。 7. 使用起来很简单(一般使用的是PersistentTokenBasedRememberMeServices,且使用的仓库实现是IdbcTokenRepositorylmpl),在配置rememberMeConfigurer时,配置—个tokenRepository(PersistentTokenRepository)即可,它会自动为我们配置rememberMeServices的,因为SpringSecurity就俩那实现,如果你配置了PersistentTokenRepository.实质就默认你是使用了PersistentTokenBasedRememberMeServices #### OAuth2.0 OAuth全称【OpenAuthorization】是一个开放的授权标准,允许用户让第三方应用访问该用户在本应用中的数据,而无需将账号密码提供给第三方应用,OAuth通过颁发令牌的方式进行授权。所以OAuth是安全的。 每一个令牌授权一个特定网站在特定时间访问特定资源,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。为简化客户端开发提供了特定的授权流,包括Web应用、桌面应用、移动端应用等。 其中OAuth2.0是OAuth的延续版本,并不向前兼容,完全废弃了OAuth1.0 ##### OAuth2.0中的角色 | 角色 | 作用 | | ------------------- | ------------------------------------------------------------ | | AuthorizationServer | 认证服务器。用于认证用户,颁发token。如果客户端认证通过,则发放访问资源服务器的令牌 | | Resource Server | 资源服务器。拥有受保护资源,对非法请求拦截,对请求token解析。如果请求包含正确的访问令牌,则可以访问资源 | | Client | 客户端。它请求资源服务器时,会带上访问令牌,从而成功访问资源 | | Resource Owner | 资源拥有者。最终用户,他有访问资源的账号与密码(指的是用户) | ##### 实现流程 - 申请对应认证服务器的OAuth权限 - 引入OAuth客户端依赖 - 实现OAuth2.0授权 ##### 申请OAuth2.0权限 ##### 引入依赖 ```xml
org.springframework.boot
spring-boot-starter-oauth2-client
``` ##### Gitee授权登录 ###### 基于默认页面 yml配置 ```yaml spring: security: oauth2: client: registration: #OAuth授权平台 gitee: client-id: xxxx client-secret: xxxx #授权模式,固定为authorization_code,其他值可以参考AuthorizationGrantType authorization-grant-type: authorization_code #回调地址,接收授权码 redirect-uri: http://localhost:8080/oauth/notify #权限范围,可配置项在码云应用信息中查看 scope: #个人用户信息 - user_info provider: gitee: #申请授权地址 authorization-uri: https://gitee:com/oauth/authorize #获取访问令牌地址 token-uri: https://gitee:com/oauth/token #查询用户信息地址 user-info-uri: https://gitee:com/api/v5/user #码云用户信息中的用户名字段 user-name-attribute: name ``` 获取用户信息 ```java @RestController @RequestMapping("oauth") public class OAuthController { // gitee的回调 @GetMapping("notify") public String gitee(@RequestParam("code") String code) { // 获取token Map
map = new HashMap<>(); map.put("grant_type","authorization_code"); map.put("code",code); map.put("client_id","2b6d84c6dde313dc3c85c0693182e3cd8319f94ab31ef0beb56d01c149f8b4ce"); map.put("redirect_uri","http://localhost:8080/oauth/notify"); map.put("client_secret","d1d1eb6c4ba0098da76c08e6339f3f31122dad8527ea50b25238d42c446e0122"); // https://gitee.com/oauth/token?grant_type=authorization_code&code={code}&client_id={client_id}&redirect_uri={redirect_uri}&client_secret={client_secret} // 通过code获取access_token String post = HttpUtil.post("https://gitee.com/oauth/token", map); System.out.println("post==========>" + post); // 根据access token获取用户信息,调用openApi GiteeBody giteeBody = JSONUtil.toBean(post, GiteeBody.class); System.out.println("giteeBody=-====>" + giteeBody.getAccessToken()); String userInfo = HttpUtil.get("https://gitee.com/api/v5/user?access_token=" + giteeBody.getAccessToken()); String name = (String)JSONUtil.parseObj(userInfo).get("name"); System.out.println("用户名===》" + name); return name; } } ``` 释放该接口拦截 ```java @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable); http.authorizeHttpRequests(auth -> auth.requestMatchers("oauth/notify").permitAll().anyRequest().authenticated()); http.formLogin(Customizer.withDefaults()); http.oauth2Login(Customizer.withDefaults()) ... ``` 自定义登录页面 ```java @Configuration @EnableWebSecurity public class SecurityConfig { // 配置,启用rememberMe功能 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 关闭csrf http.csrf(csrf -> csrf.disable()); // 配置请求拦截策略 http.authorizeHttpRequests(auth -> auth.requestMatchers("/oauth/notify","/to_login").permitAll().anyRequest().authenticated()); // 使用SpringSecurity默认的登录页面 http.formLogin(Customizer.withDefaults()); // 开启oauth登陆 http.oauth2Login(Customizer.withDefaults()); return http.build(); } @Bean public ClientRegistrationRepository clientRegistrationRepository() { return new InMemoryClientRegistrationRepository(this.giteeClientRegistration()); } // 配置giee的授权登录信息 private ClientRegistration giteeClientRegistration() { return ClientRegistration.withRegistrationId("gitee") .clientId("2b6d84c6dde313dc3c85c0693182e3cd8319f94ab31ef0beb56d01c149f8b4ce") .clientSecret("d1d1eb6c4ba0098da76c08e6339f3f31122dad8527ea50b25238d42c446e0122") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("http://localhost:8080/login/oauth2/code/gitee") .scope("user_info") .authorizationUri("https://gitee.com/oauth/authorize") .tokenUri("https://gitee.com/oauth/token") .userInfoUri("https://gitee.com/api/v5/user") .userNameAttributeName("name") .build(); } } ``` controller登录 ```java @Controller public class PageController { @GetMapping("to_login") public String login() { return "login"; } } ``` 登录页面login.html ```html
登陆页面
gitee
``` #### 退出登录 就是访问退出登录接口之后,需要做什么事情 - 清除cookie - 认证信息需要删除【redis】 - 记录用户的退出日志 - 跳转页面【前端做的,后端返回状态就可以了】 定义退出登录handler ```java @Component @Slf4j public class MyLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { log.info("退出登录========》"); HashMap
map = new HashMap<>(); map.put("code",200); map.put("msg","退出登录成功"); response.setCharacterEncoding("UTF-8"); response.getWriter().write(JSONUtil.toJsonStr(map)); } } ``` 配置handler ```java @Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private MyLogoutSuccessHandler logoutSuccessHandler; /** * 配置过滤器链,对login接口放行 */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()); // 放行login接口 http.authorizeHttpRequests(auth -> auth.requestMatchers("/auth/**","/logout").permitAll() .anyRequest().authenticated() ); // 配置退出登录 http.logout(logout -> logout.logoutSuccessHandler(logoutSuccessHandler).deleteCookies("rememberMe")); return http.build(); } } ``` 源码地址:https://gitee.com/stt0626/stt-study 1. 稻草项目: 项目文档:https://www.yuque.com/shitiantian-my2mt/dd1kk8?#《稻草前后端权限系统》 密码:ur2p 代码仓库:https://gitee.com/stt0626/daocao_study 2. 技术点 文档地址:https://www.yuque.com/shitiantian-my2mt/uu4rgv?#《Java技术栈》 密码:teyv 代码仓库:https://gitee.com/stt0626/stt-study