# Spring-Security **Repository Path**: janoandbiscuityeah/Spring-Security ## Basic Information - **Project Name**: Spring-Security - **Description**: Springsecurity安全框架整合JWT,登陆校验,权限验证的使用 - **Primary Language**: Unknown - **License**: EPL-1.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2022-07-26 - **Last Updated**: 2023-07-29 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Security框架总结 >首先这个框架的目的就是为了更方便的构建权限认证,以前我们自己写权限认证虽然可以基本满足需求但是每一次重写过于复杂,而Security框架不仅提供非常多种的验证 > 方法,并且颗粒度更加细致
> 具体的做法总结:首先根据框架的规范进行开发,当想要实现哪个功能那么就需要找他在框架中的实现接口,根据接口进行开发,我现在需要使用这个Security框架在前后端分离的情况下 > 实现登陆的认证和校验以及权限的授权和校验那么我就需要搞清楚以下思路
> **1.登陆的认证 授权(默认对登陆接口放行)**
> 前端传来用户的登陆信息,当登陆接口接收后就开始进行登陆的认证,那么Security框架默认是从内存根据当前用户的名字中读取对应的用户信息返回进行对比看是否相等 > 但是我们的用户信息是存放在数据库的所以我们第一个实现的地方就是这里,其验证比对的逻辑我们可以不用再写,Security框架提供,我只需要将用户的信息按照接口的规范 > 进行封装传入即可(注意:从数据库中读取的用户信息就包含了其对应的权限信息,如果用户认证通过自然会将其对应的权限信息进行赋值),那么第二个实现的地方自然就是按照接口的要求 > 对用户信息进行正确的封装,前端传来的用户信息,从数据库读取的用户信息 都需要正确的封装
> **2.登陆的校验,权限的校验**
> 当登陆成功后会返回token(JWT),前端每次访问页面时都会带上这个token,我们就利用这个token保持会话,注意:我会写一个过滤器将它放在Security框架登陆认证过滤器之前,有什么作用呢? > 当前端传来token如果能获取且不为空,并且JWT校验正确的话我在这个过滤器中就可以再次进行授权(因为登陆成功的时候不仅会签发token,还会将当前登陆成功的用户信息放入redis),这些操作 > 下来,用户就是已经被认证的状态了,如果当前用户具有当前页面所需要的权限自然也可以被访问.
> **3.异常处理**
> Security框架主要有两大异常
> 认证异常
>AuthenticationException =====> 调用AuthenticationEntryPoint对象的方法去进行异常处理
>授权异常
AccessDeniedException =====> 调用AccessDeniedHandler对象的方法去进行异常处理
> 实现这些异常处理的接口在配置类中进行注册,自然可以在出现异常的时候返回给前端封装好的JSON格式错误信息
> 除了这两个处理器外,Security框架还提供了认证成功处理器、认证失败处理器、注销成功处理器 可以根据具体的业务需求进行接口实现
>**4.密码加密存储:**
> 新版本的Spring security规定必须设置一个默认的加密方式,不允许使用明文。这个加密方式是用于在登录时验证密码、注册时需要用到。
> Spring-Security默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。 我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder BCryptPasswordEncoder:内部加密的时候会产生随机的盐,所以每次加密后产生的 > 数据形式都不同,在注册用户的时候我们就需要在数据库中存储加密后的内容,验证时调用 > 其BCryptPasswordEncoder.matches方法即可
> > Security框架 可以根据业务的需求有更多的具体实现方案,现在按照我的业务需求来说完成上面的业务逻辑开发完全可以解决我的需求!!! ```java //配置类基本写法 @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; //创建BCryptPasswordEncoder注入容器 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } //获取AuthenticationManager @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } //权限异常处理 @Autowired private AuthenticationEntryPoint authenticationEntryPoint; //认证异常处理 @Autowired private AccessDeniedHandler accessDeniedHandler; @Override protected void configure(HttpSecurity http) throws Exception { http //关闭csrf .csrf().disable() //不通过Session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 对于登录接口 允许匿名访问 .antMatchers("/user/login").anonymous() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); //把token校验过滤器添加到过滤器链中 加在UsernamePasswordAuthenticationFilter之前 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); //配置异常处理器 http.exceptionHandling() //配置认证失败处理器 .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler); //允许跨域 http.cors(); } } ``` ![image](src/main/resources/myImg/权限认证.png) ## 回顾 >###token机制保持会话(JWT)
>我们原来设置了token的拦截器、权限的拦截器,我们只对部分接口进行了放行比如:登陆接口,以及刷新令牌接口 > 而对于其他的接口来说,每一次用户的访问都需要经过token的校验以及权限的验证,我们通过token去获得当前 > 用户的状态还有信息,但是这整个过程来说我们花费的时间太多,Security框架也是基于这样的原理但是它不是 > 使用的拦截器而是使用的过滤器,并且对登陆校验,权限验证等功能进行了封装,我们只需要按照业务需求传入对应的 > 校验参数就可以实现权限验证,登陆校验等功能
> **原始的使用登陆校验功能** >
**第一个:拦截器1:对前端传入的token进行校验** >
**第二个:拦截器2:在token校验合法的前提下从token中获取用户信息**
**1.获取当前用户拥有的权限资格**
`List hasPermissions = permissionService.findByUsername(redisUser.getUsername())`

**2.与当前访问的@Controller所需权限进行比较判断**
资源本身对应的权限获取方法如下:
`控制器--方法--找到自定义权限注解--value(自定义注解的值)`
在拦截器中的Object对应此处访问的控制器对象 HandlerMethod hm =(HandlerMethod) handler;获取其方法上的注解进行逻辑判断即可

**3.最后通过Stream流的方式判断当前用户所拥有的权限是否包含当前访问的@Controller层上@Auth注解中的value值即所需权限, 如果包含那么就可以放行** ## Security框架 > Security框架流程图、原理图如下 ![image](src/main/resources/myImg/Security流程.png) ![image](src/main/resources/myImg/Security流程原理.png) ![完整的过滤器流程](src/main/resources/myImg/完整的过滤器流程.png)
`WebAsyncManagerIntegrationFilter`:此过滤器用于集成SecurityContext到Spring异步执行机制中的WebAsyncManager。 实现安全上下文从调用者线程 到被调用者线程的传播
`SecurityContextPersistenceFilter`:从session中根据springSecurityContextKey获取SecurityContext对象, 没有就直接new一个,然后set到SecurityContextHolder里面,请求执行完了之后调用SecurityContextHolder.clearContext()清除
`HeaderWriterFilter`:在一个请求的处理过程中为响应对象增加一些头部信息。头部信息由外部提供,比如用于增加一些浏览器保护的头部, 比如X-Frame-Options, X-XSS-Protection和X-Content-Type-Options等
`CsrfFilter(默认开启)`:防止跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token信息,如果不包含,则报错。起到防止csrf攻击的效果。 `logoutFilter`:处理登出请求的过滤器,当请求经过LogoutFilter时,过滤器会请判断当前请求的URL是否是登出URL,如果匹配,就执行遍历执行处理登出的handlers。 默认情况下,会清空SecurityContextHolder的身份认证信息,以及发送一个登出成功的事件。 **UsernamePasswordAuthenticationFilter**:用户密码认证过滤器,主要用来处理通过指定的登录的POST方式的请求url来进行认证. 如果我们的项目中实现了JWT+Spring security的话,一般我们的我们会将自定义实现的JWT过滤器也加入到这条执行链中, 并且执行位置放到UserNamePasswordAuthenticationFilter之前。 > 表单提交了username和password,被封装成UsernamePasswordAuthenticationToken对象进行一系列的认证,便是通过这个过滤器完成的,即调用了 > AuthenticationManager.authenticate(usernamePasswordAuthenticationToken);在表单认证的方法中,这是最关键的过滤器 >
具体步骤:
AbstractAuthenticationProcessingFilter(抽象类)被UsernamePasswordAuthenticationFilter继承,实现了其中的方法
> (1)调用AbstractAuthenticationProcessingFilter.doFilter()方法执行过滤器
> (2)调用UsernamePasswordAuthenticationFilter.attemptAuthentication()方法
> (3)调用AuthenticationManager().authenticate()方法(注意这里的调用在2中,这里实际上是委托给AuthenticationProvide的实现类来处理) `DefaultLoginPageGeneratingFilter`:当开发人员在安全配置中没有配置登录页面时,Spring Security Web会自动构造一个登录页面给用户。 完成这一任务是通过一个过滤器来完成的,该过滤器就是DefaultLoginPageGeneratingFilter。 `DefaultLogoutPageGeneratingFilter`:用于生成一个缺省的用户退出登录页面,默认情况下,当用户请求为GET /logout时,该过滤器会起作用, 生成并展示相应的用户退出登录表单页面。用户点击其中的表单提交按钮会提交用户退出登录请求到POST /logout,缺省情况下,也就是由LogoutFilter过滤器来执行相应的用户退出登录逻辑。 `BasicAuthenticationFilter`:处理HTTP请求中的BASIC authorization头部,把认证结果写入SecurityContextHolder。 当一个HTTP请求中包含一个名字为Authorization的头部,并且其值格式是Basic xxx时,该Filter会认为这是一个BASIC authorization头部,其中xxx部分应该是一个base64编码的{username}:{password}字符串。比如用户名/密码分别为 admin/secret, 则对应的该头部是 : Basic YWRtaW46c2VjcmV0 。 该过滤器会从 HTTP BASIC authorization头部解析出相应的用户名和密码然后调用AuthenticationManager进行认证,成功的话会把认证了的结果写入到SecurityContextHolder中SecurityContext的属性authentication上面。同时还会做其他一些处理,比如Remember Me相关处理等等。 如果头部分析失败,该过滤器会抛出异常BadCredentialsException。 如果认证失败,则会清除SecurityContextHolder中的SecurityContext。并且不再继续filter chain的执行 `RequestCacheAwareFilter`:内部维护了一个RequestCache,用于缓存request请求 `SecurityContextHolderAwareRequestFilter`:此过滤器对ServletRequest进行了一次包装,使得request具有更加丰富的API `AnonymousAuthenticationFilter`:匿名身份过滤器 `SessionManagementFilter` 和session相关的过滤器,内部维护了一个SessionAuthenticationStrategy来执行任何与session相关的活动 **ExceptionTranslationFilter** >异常转换过滤器,这个过滤器本身不处理异常,而是将认证过程中出现的异常(AccessDeniedException and AuthenticationException) > 交给内部维护的一些类去处理,它位于整个SpringSecurityFilterChain的后方,用来转换整个链路中出现的异常,将其转化,其本身不会处理 > 一般只处理两大类AccessDeniedException访问异常和AuthenticationException认证异常 > 他将Java中的异常和Http的响应连接在了一起,这样在处理异常的时候,我们不用考虑密码错误该跳到什么页面,账号锁定该如何,只需要关注 > 自己的业务逻辑抛出相应的异常即可,如果该过滤器监测到AccessDeniedException则会交给内部的AuthenticationEntryPoint,否则会委托 > AccessDeniedHandler去处理,而AccessDeniedHandler的默认实现是AccessDeniedHandlerImpl **FilterSecurityInterceptor** > 这个过滤器决定了访问特定路径应该具备的权限,这些受限的资源访问需要什么权限或者角色,这些判断和处理都是由该类进行的 > (1)调用FilterSecurityInterceptor.invoke()方法执行过滤器 > (2)调用AbstractSecurityInterceptor.beforeInvocation方法 > (3)调用AccessDecisionManager.decide()方法决策判断是否有该权限
SpringSecurity本质是用一个Filter链实现认证的功能,利用过滤器过滤需要认证的Request,然后进行认证
首先:要明确我要用这个框架做什么?
> 1.登陆认证 2.权限验证
### 登陆认证 权限认证 在我们没有配置的情况下,Security框架总结会默认的给我们提供登陆页面,默认的账号(user)密码(随机控制台打印),同时默认的查找用户 信息是在内存中查找的是我们自己给的不是动态从数据库中读取的,并且我们在前后端分离的项目中前端页面后与服务器端是分离的我们只需要 提供登陆接口即可,所以需要对Security框架进行改写。
首先:从上面的流程图可以看出最重要的三个过滤器`UsernamePasswordAuthenticationFilter`、`ExceptionTranslationFilter`、 `FilterSecurityInterceptor`通过对这三个过滤器中调用的方法进行重写,就可以实现我们构想的登陆认证以及权限认证的方案!! ```java //最终的登陆接口如下 @RestController public class loginController { @Autowired loginService loginService; @RequestMapping("user/login") public JsonResult login(@RequestBody User user){ //登陆 JsonResult loginResult = loginService.login(user); return loginResult; } @RequestMapping("user/logout") public JsonResult loginOut(){ //退出 JsonResult loginResult = loginService.logout(); return loginResult; } } /**=========================================================*/ //业务层方法如下 /** * @author zoumaoji * @date 2022/07/24 19:17 **/ @Service public class loginServiceImpl extends AbstractBaseServiceImpl implements loginService { @Autowired AuthenticationManager authenticationManager; @Autowired RedisCache redisCache; @Override public JsonResult login(User user) { //AuthenticationManager authenticate 进行用户认证 注意这里的有参构造 两个 和 三个的情况是不同的 那么this.setAuthenticated(false);会出现未认证状态 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword()); Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken); //如果认证没有通过,给出相应的提示 throwIfNull(authenticate, "登陆失败!!!!!!"); //如果认证通过了,使用userId生成一个jwt jwt存入JsonResult返回 LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); String userId = loginUser.getUser().getId().toString(); String userName = loginUser.getUser().getUserName(); //签发token Map claims = new HashMap<>(); claims.put(JwtConstant.JWT_USER_NAME, userName); //签发jwtToken String jwtToken = JwtTokenUtil.getAccessToken(userId, claims); //把完整的用户信息存入redis userId作为key redisCache.setCacheObject("login:"+userId,loginUser); return new JsonResult().setCode(HttpStatus.OK.value()).setData(jwtToken); } @Override public JsonResult logout() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); Long userid = loginUser.getUser().getId(); redisCache.deleteObject("login:"+userid); return new JsonResult().setCode(HttpStatus.OK.value()).setData("退出成功"); } } ``` ### UsernamePasswordAuthenticationFilter ```java //部分UsernamePasswordAuthenticationFilter代码 public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { String username = this.obtainUsername(request); String password = this.obtainPassword(request); if (username == null) { username = ""; } if (password == null) { password = ""; } username = username.trim(); //注意这里的username,password UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); this.setDetails(request, authRequest); //注意这里调用的AuthenticationManager方法 return this.getAuthenticationManager().authenticate(authRequest); } } ``` ### SecurityConfig配置类⚠️ ### 注意点⚠️ >**1.密码加密存储:**
> 新版本的Spring security规定必须设置一个默认的加密方式,不允许使用明文。这个加密方式是用于在登录时验证密码、注册时需要用到。
> Spring-Security默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。 我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder BCryptPasswordEncoder:内部加密的时候会产生随机的盐,所以每次加密后产生的 > 数据形式都不同,在注册用户的时候我们就需要在数据库中存储加密后的内容,验证时调用 > 其BCryptPasswordEncoder.matches方法即可
> **2.获取AuthenticationManager** 注册到Spring容器当中,在登陆接口中方案调用
> **3.注意相关配置** ```java @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; //创建BCryptPasswordEncoder注入容器 @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } //获取AuthenticationManager @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http //关闭csrf .csrf().disable() //不通过Session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 对于登录接口 允许匿名访问 .antMatchers("/user/login").anonymous() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); //把token校验过滤器添加到过滤器链中 加在UsernamePasswordAuthenticationFilter之前 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); } } ``` 注意⚠️:在配置器中对/user/login接口进行了放行,所以我们才可以自定义的去对登陆用户进行登陆校验,以及JWtToken的签发,那么比较重要的思路在于 会将自定义实现的JWT过滤器也加入到执行链中,并且执行位置放到UserNamePasswordAuthenticationFilter之前。 在登陆成功后前端会得到签发的token,在后面的请求中只要携带了token且不为空且验证无误那么在用户到达UserNamePasswordAuthenticationFilter之前 我就可以通过我自定义实现的JWT过滤器对他进行认证通过的操作然后通过权限校验如果符合就可以存入SecurityContextHolder当中,那么后面的过滤器看到是 已经认证的状态就会放行,用户就可以直接访问页面,通过这样的方法就可以实现token保持会话状态 而当前框架的意义在于简便了我的权限验证操作以及登陆时对用户信息的校验(配合数据库) > 当请求进行到Security框架中的UsernamePasswordAuthenticationFilter过滤器(用户密码认证过滤器),此时前端传来的用户和密码就会在这里 > 传入到此过滤器中的`public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)` > 方法进行认证
> **此方法中三个注意点**
> 1.在此方法中默认是对session中包含的用户信息进行`request.getParameter();`读取,但是我们需要的是客户端无状态session是不会用的了 > 那么此时登陆接口中用户信息我们直接从前端传来的User对象获取
> 2.此方法中还调用了AuthenticationManager.authenticate()进行认证,如果认证通过返回值不为null,那么我的登陆接口就可以签发token将用户的具体信息存放在redis,此方法中传入的参数类型是`UsernamePasswordAuthenticationToken`
```java //UsernamePasswordAuthenticationToken有参构造器 public UsernamePasswordAuthenticationToken(Object principal, Object credentials) { super((Collection)null); this.principal = principal; this.credentials = credentials; this.setAuthenticated(false); } public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection authorities) { super(authorities); this.principal = principal; this.credentials = credentials; super.setAuthenticated(true); } ``` > 3.⚠️注意这里的UsernamePasswordAuthenticationToken它的构造方法如上所示,主要的区别在于`setAuthenticated`是否为false,当`setAuthenticated` > 为false代表没有认证 为true代表已经认证,‼️在这里我们肯定是传入选中第一个有参构造,因为我们现在是登陆接口我们正需要去使用其验证方法通过数据库的比对 > 去判断认证用户是否合法,如果这里我们传入了三个参数代表我们手动的去决定他是已经认证过的了显然不对,其次注意传入的参数第一个username第二个password > 这都是根据其上面UsernamePasswordAuthenticationFilter源码来的
> **总结**:
> 登陆接口1.将前端传来的User对象封装为UsernamePasswordAuthenticationToken对象,第一个参数传入username,第二个参数传入password > 2.调用AuthenticationManager.authenticate方法传入UsernamePasswordAuthenticationToken对象即可,返回值不为空即代表认证通过可以签发JwtToken ### AuthenticationManager > AuthenticationManager.authenticate()是登陆接口中调用来进行用户信息校验的方法 > 初次接触Spring Security的朋友相信会被AuthenticationManager,ProviderManager ,AuthenticationProvider … > 这么多相似的Spring认证类搞得晕头转向,但只要稍微梳理一下就可以理解清楚它们的联系和设计者的用意。AuthenticationManager(接口) > 是认证相关的核心接口,也是发起认证的出发点,因为在实际需求中,我们可能会允许用户使用用户名+密码登录,同时允许用户使用邮箱+密码, > 手机号码+密码登录,甚至,可能允许用户使用指纹登录(还有这样的操作?没想到吧),所以说AuthenticationManager一般不直接认证, > AuthenticationManager接口的常用实现类ProviderManager 内部会维护一个List列表,存放多种认证方式,实际上这是委托者模式的应用 > (Delegate)。也就是说,核心的认证入口始终只有一个:AuthenticationManager,不同的认证方式:用户名+密码 > (UsernamePasswordAuthenticationToken),邮箱+密码,手机号码+密码登录则对应了三个AuthenticationProvider。 > 这样一来四不四就好理解多了?熟悉shiro的朋友可以把AuthenticationProvider理解成Realm。在默认策略下, > 只需要通过一个AuthenticationProvider的认证,即可被认为是登录成功。 ```java //关键认证部分的ProviderManager源码: public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean { // 维护一个AuthenticationProvider列表 private List providers = Collections.emptyList(); public Authentication authenticate(Authentication authentication) throws AuthenticationException { Class toTest = authentication.getClass(); AuthenticationException lastException = null; Authentication result = null; // 依次认证 for (AuthenticationProvider provider : getProviders()) { if (!provider.supports(toTest)) { continue; } try { result = provider.authenticate(authentication); if (result != null) { copyDetails(authentication, result); break; } } ... catch (AuthenticationException e) { lastException = e; } } // 如果有Authentication信息,则直接返回 if (result != null) { if (eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) { //移除密码 ((CredentialsContainer) result).eraseCredentials(); } //发布登录成功事件 eventPublisher.publishAuthenticationSuccess(result); return result; } ... //执行到此,说明没有认证成功,包装异常信息 if (lastException == null) { lastException = new ProviderNotFoundException(messages.getMessage( "ProviderManager.providerNotFound", new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}")); } prepareException(lastException, authentication); throw lastException; } } ``` _**ProviderManager 中的List,会依照次序去认证,认证成功则立即返回,若认证失败则返回null, 下一个AuthenticationProvider会继续尝试认证,如果所有认证器都无法认证成功,则ProviderManager 会抛出一个ProviderNotFoundException异常**_ >通过对ProviderManager的了解以后,我们只需要明白一个点,AuthenticationManager(接口)一般不直接认证,通常是通过他的 >常用实现类ProviderManager,那么在这个类中提供了很多种的验证方法,注意⚠️:因为它实现了InitializingBean接口所以他有了 > 各种各样的验证方法,刚才登陆接口我们已经写完了,那么现在最重要的是这个用户验证如何去修改,因为默认的ProviderManager是 > 调用的DaoAuthenticationProvider类中的userDetailsService的loadUserByUsername方法,此方法默认是从内存中查找用户出来 > 进行认证比对而我们需要去数据库读取用户出来比较所以我们只需要改写这里
![](src/main/resources/myImg/默认内存读取.png) ![](src/main/resources/myImg/UserDetauk.png)
**总结**
```java AuthenticationManager(接口)==>ProviderManager(实现类)==调用==>DaoAuthenticationProvider==>userDetailsService(接口中的loadUserByUsername方法)==>具体的实现类 ``` **继承UserDetailsService接口,重写loadUserByUsername方法**
注意loadUserByUsername要求返回的是UserDetails(接口) 说明返回的对象需要实现UserDetails接口才可以 ```java //UserDetailsService源码 package org.springframework.security.core.userdetails; public interface UserDetailsService { UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException; } ``` ```java /** * 1.替换了UserDetailsService 实现从数据查询验证的登陆功能 * 2.对UserDetails进行封装 * @author zoumaoji * @date 2022/07/24 17:04 **/ @Service public class UserServiceImpl extends AbstractBaseServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; @Autowired MenuMapper menuMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //查询用户信息 LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(User::getUserName,username); User user = userMapper.selectOne(queryWrapper); //如果没有查询到用户就抛出异常 throwIfNull(user,"==>用户名或者密码错误<=="); //TODO 查询查询对应的权限信息 List permissions = menuMapper.selectPermsByUserId(user.getId()); //把数据封装为UserDetails返回 在loginUser中继承UserDetails接口 重写他的方法进行返回 LoginUser loginUser = new LoginUser(user,permissions); return loginUser; } } ``` > ⚠️注意:这里我们使用LoginUser实现了UserDetails接口,其中除了基本的User对象信息的封装以外,还有一个重要的点在于对于权限信息的封装,实现的接口方法中有一个`public Collection getAuthorities()` 这个方法的返回值就是框架中需要的当前用户权限信息合集,我们可以看到当前方法的返回值遵从上面的泛型即需要是`GrantedAuthority`的实现类,那么有以下几种 ![image](src/main/resources/myImg/GrantedAuthority接口.png) >我们常用的是使用`SimpleGrantedAuthority`实现类封装权限信息返回,那么看下他的源码如下:`public SimpleGrantedAuthority(String role)`这是SimpleGrantedAuthority有参构造 > 所以只需要传入一个权限信息即可 ```java public final class SimpleGrantedAuthority implements GrantedAuthority { private static final long serialVersionUID = 530L; private final String role; public SimpleGrantedAuthority(String role) { Assert.hasText(role, "A granted authority textual representation is required"); this.role = role; } public String getAuthority() { return this.role; } public boolean equals(Object obj) { if (this == obj) { return true; } else { return obj instanceof SimpleGrantedAuthority ? this.role.equals(((SimpleGrantedAuthority)obj).role) : false; } } public int hashCode() { return this.role.hashCode(); } public String toString() { return this.role; } } ``` **最终LoginUser封装如下** ```java /** * 1.1因为UserDetailsService方法的返回值是UserDetails类型, * 所以需要定义一个类,实现该接口,把用户信息封装在其中 * @author zoumaoji * @date 2022/07/24 17:11 **/ @Data @NoArgsConstructor public class LoginUser implements UserDetails { /** *自定义的User对象 * @author zoumaoji * @date 2022/07/24 23:46 * @param null * @return */ private User user; /** * 用户的权限集合 * @author zoumaoji * @date 2022/07/25 19:33 * @param null * @return */ private List permissions; public LoginUser(User user, List permissions) { this.user = user; this.permissions = permissions; } /** * 权限集合 注意这里不讲权限信息序列化到redis中为了安全性 * @author zoumaoji * @date 2022/07/25 19:42 * @param null * @return */ @JSONField(serialize = false) private List authorities; /** * 返回权限信息 * * @return java.util.Collection * @author zoumaoji * @date 2022/07/24 17:11 */ @Override public Collection getAuthorities() { if (authorities!=null){ return authorities; } //把permissions中的权限信息封装成GrantedAuthority的一个实现类SimpleGrantedAuthority对象 List collect = permissions.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); return collect; } /** * 获取用户密码 * * @return java.lang.String * @author zoumaoji * @date 2022/07/24 17:21 */ @Override public String getPassword() { return user.getPassword(); } /** * 获取用户名 * * @return java.lang.String * @author zoumaoji * @date 2022/07/24 17:21 */ @Override public String getUsername() { return user.getUserName(); } /** * 是否未过期(暂时改为了true) * * @return boolean * @author zoumaoji * @date 2022/07/24 17:12 */ @Override public boolean isAccountNonExpired() { return true; } /** * 账户是否未被锁定 * * @return boolean * @author zoumaoji * @date 2022/07/24 17:22 */ @Override public boolean isAccountNonLocked() { return true; } /* *凭据是否未过期 * @author zoumaoji * @date 2022/07/24 17:22 * @return boolean */ @Override public boolean isCredentialsNonExpired() { return true; } /* *是否可用 * @author zoumaoji * @date 2022/07/24 17:12 * @return boolean */ @Override public boolean isEnabled() { return true; } } ``` ### 登陆后的JWT用户状态保持(自定义过滤器放在UserNamePasswordAuthenticationFilter之前) > 这里注意JWT认证通过以后我们需要给UsernamePasswordAuthenticationToken()传入三个参数数,此时他就是以认证的状态,然后存入SecurityContextHolder > 代表当前用户是已经登陆的状态了(SecurityContextHolder是用来存放SecurityContext的对象,默认是使用ThreadLocal实现的) >
> 还需要注意将已认证的用户放入SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);这样的话 > 后面的过滤器就可以放行了 ```java /** * 保证请求只经过一次过滤器 * @author zoumaoji * @date 2022/07/24 22:13 **/ @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired RedisCache redisCache; @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { //获取token String token = httpServletRequest.getHeader("token"); if (StringUtils.isBlank(token)){ //token为空直接放行,利用后面的过滤器过滤,将错误信息返回 filterChain.doFilter(httpServletRequest,httpServletResponse); //防止回来访问这个过滤器的时候会执行下面的方法 return; } //解析token Claims claimsFromToken = JwtTokenUtil.getClaimsFromToken(token); String userId = claimsFromToken.getSubject(); //从redis中获取用户信息 String redisKey ="login:"+userId; LoginUser loginUser = redisCache.getCacheObject(redisKey); if (Objects.isNull(loginUser)){ throw new BusinessException("用户未登录"); } //存入SecurityContextHolder //TODO 获取权限信息 封装到AuthenticationToken UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( loginUser, null, loginUser.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); //放行 filterChain.doFilter(httpServletRequest,httpServletResponse); } } ``` ### ExceptionTranslationFilter >异常转换过滤器,这个过滤器本身不处理异常,而是将认证过程中出现的异常(AccessDeniedException and AuthenticationException) > 交给内部维护的一些类去处理,它位于整个SpringSecurityFilterChain的后方,用来转换整个链路中出现的异常,将其转化,其本身不会处理 >
在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。 ```java 认证异常 AuthenticationException =====> 调用AuthenticationEntryPoint对象的方法去进行异常处理 授权异常 AccessDeniedException =====> 调用AccessDeniedHandler对象的方法去进行异常处理。 我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可 ``` ①自定义实现类 ```java @Component public class AccessDeniedHandlerImpl implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "权限不足"); String json = JSON.toJSONString(result); WebUtils.renderString(response,json); } } ``` ```java /** * @Author 三更 B站: https://space.bilibili.com/663528522 */ @Component public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录"); String json = JSON.toJSONString(result); WebUtils.renderString(response,json); } } ``` ②配置给SpringSecurity 先注入对应的处理器 ```java @Autowired private AuthenticationEntryPoint authenticationEntryPoint; @Autowired private AccessDeniedHandler accessDeniedHandler; ``` 然后我们可以使用HttpSecurity对象的方法去配置 ```java http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint). accessDeniedHandler(accessDeniedHandler); ``` **注意⚠️:这里有容易踩坑的地方,当验证权限失败时抛出AccessDeniedException异常 不允许访问,而我明明配置了SimpleAccessDeniedHandler 来处理异常并返回提示信息。 我仔细检查发现拦截AccessDeniedException异常的是全局异常处理,所以针对这个异常我在处理全局异常的类中进行了此异常的处理如下:** ```java 问题描述 在 Security配置类中 正确配置了 AccessDeniedHandler,但是发现实际运行时 AccessDeniedHandler 没有被触发! 问题原因 出现这种问题的原因一般都是因为项目中还配置了 GlobalExceptionHandler 。 由于GlobalExceptionHandler 全局异常处理器会比 AccessDeniedHandler 先捕获 AccessDeniedException 异常,因此当配置了 GlobalExceptionHandler 后,会发现 AccessDeniedHandler 失效了。 解决方案 原有的 GlobalExceptionHandler 不用修改,只需要增加一个 自定义的 AccessDeniedExceptionHandler 即可 ``` ```java /** * 适用于Security框架中的AccessDeniedException异常 这个异常可能被全局异常所拦截所以在这里处理一下 * @author zoumaoji * @date 2022/07/26 14:44 * @param e * @return com.Jano.dto.JsonResult */ @ExceptionHandler(AccessDeniedException.class) public JsonResult handleAccessDeniedException(AccessDeniedException e){ e.printStackTrace(); int code = HttpStatus.FORBIDDEN.value(); res.setStatus(code); return new JsonResult().setCode(code).setErrMsg("不允许访问"); } ``` ### 认证成功处理器 认证失败处理器 登出成功处理器 ```java 使用方法和上面的异常处理器相同,继承相关处理器的接口 重写其方法 在配置类中进行注册 AuthenticationSuccessHandler ====>认证成功处理器 AuthenticationFailureHandler ====>认证失败处理器 LogoutSuccessHandler ====>登陆成功处理器 ``` ```java //认证成功处理器 @Component public class SGSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("认证成功了"); } } ------------------------------------ //认证失败处理器 @Component public class SGFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { System.out.println("认证失败了"); } } ------------------------------------ //注销成功处理器 @Component public class SGLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { System.out.println("注销成功"); } } =========================================================================== //配置类注册 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationSuccessHandler successHandler; @Autowired private AuthenticationFailureHandler failureHandler; @Autowired private LogoutSuccessHandler logoutSuccessHandler; @Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() // 配置认证成功处理器 .successHandler(successHandler) // 配置认证失败处理器 .failureHandler(failureHandler); http.logout() //配置注销成功处理器 .logoutSuccessHandler(logoutSuccessHandler); http.authorizeRequests().anyRequest().authenticated(); } } ``` ### 授权实现 SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限 ```java 第一步:在配置类中开启相关配置 @EnableGlobalMethodSecurity(prePostEnabled = true) 第二步:@PreAuthorize 例如: @RestController public class HelloController { @RequestMapping("/hello") @PreAuthorize("hasAuthority('test')") public String hello(){ return "hello"; } } ====================================================== 扩展: hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源 @PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')") public String hello(){ return "hello"; } hasRole要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 **ROLE_** 这个前缀才可以 @PreAuthorize("hasRole('system:dept:list')") public String hello(){ return "hello"; } hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以 @PreAuthorize("hasAnyRole('admin','system:dept:list')") public String hello(){ return "hello"; } 自定义权限校验方法 我们也可以定义自己的权限校验方法,在@PreAuthorize注解中使用我们的方法 @Component("ex") public class SGExpressionRoot { public boolean hasAuthority(String authority){ //获取当前用户的权限 Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser = (LoginUser) authentication.getPrincipal(); List permissions = loginUser.getPermissions(); //判断用户权限集合中是否存在authority return permissions.contains(authority); } } 在SPEL表达式中使用 @ex相当于获取容器中bean的名字未ex的对象。然后再调用这个对象的hasAuthority方法 @RequestMapping("/hello") @PreAuthorize("@ex.hasAuthority('system:dept:list')") public String hello(){ return "hello"; } 基于配置的权限控制 我们也可以在配置类中使用使用配置的方式对资源进行权限控制 @Override protected void configure(HttpSecurity http) throws Exception { http //关闭csrf .csrf().disable() //不通过Session获取SecurityContext .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 对于登录接口 允许匿名访问 .antMatchers("/user/login").anonymous() //注意这里⚠️ .antMatchers("/testCors").hasAuthority("system:dept:list222") // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); //添加过滤器 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); //配置异常处理器 http.exceptionHandling() //配置认证失败处理器 .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler); //允许跨域 http.cors(); } ``` ### CSRF CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。 https://blog.csdn.net/freeking101/article/details/86537087
SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。
我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了 ### 跨域 ```java ①先对SpringBoot配置,运行跨域请求 方法有:注解、配置类、拦截器(本项目使用的) @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { // 设置允许跨域的路径 registry.addMapping("/**") // 设置允许跨域请求的域名 .allowedOriginPatterns("*") // 是否允许cookie .allowCredentials(true) // 设置允许的请求方式 .allowedMethods("GET", "POST", "DELETE", "PUT") // 设置允许的header属性 .allowedHeaders("*") // 跨域允许时间 .maxAge(3600); } } ②开启SpringSecurity的跨域访问 @Override protected void configure(HttpSecurity http) throws Exception { .............. //允许跨域 http.cors(); } ```