SpringSecurity
1,SpringSecurity 简介
Spring Security 是 Spring家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。
一般来说中大型的项目都是使用SpringSecurity来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
一般Web应用的需要进行认证和授权。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
授权:经过认证后判断当前用户是否有权限进行某个操作
而认证和授权也是SpringSecurity作为安全框架的核心功能。
2,过滤器链
SpringSecurity 是由一系列过滤器组成的,主要包括以下十五个:
1. WebAsyncManagerIntegrationFilter:将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。简言之就是将SpringSecurity与Spring整合
2. SecurityContextPersistenceFilter:在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,并将SecurityContext给以后的过滤器使用,来为后续filter建立所需的上下文,然后在该次请求处理完成之后,将 SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将 SecurityContextHolder 中的信息清除,例如在Session中维护一个用户的安全信息就是这个过滤器处理的。
3. HeaderWriterFilter:用于将头信息加入响应中。向请求的Header中添加相应的信息,可在http标签内部使用security:headers来控制。但是这个标签仅用于jsp页面。
4. CsrfFilter:csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token信息,如果没有token的话会被拦截到并且后台发出异常。这个拦截器起到防止csrf攻击的效果。
5. LogoutFilter:用于处理退出登录。匹配 URL为/logout的请求,拦截到这个请求后实现用户退出,并且清除认证信息。
6. UsernamePasswordAuthenticationFilter:用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。
7. DefaultLoginPageGeneratingFilter:如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面。简单来说就是没有指定登录页面的时候这个过滤器就会使用内部自带的登录页面
8. BasicAuthenticationFilter:检测和处理 http basic 认证。此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头信息。
9. RequestCacheAwareFilter:通过HttpSessionRequestCache内部维护了一个RequestCache,用于缓存HttpServletRequest。
10. SecurityContextHolderAwareRequestFilter:针对ServletRequest进行了一次包装,使得request具有更加丰富的API
11. AnonymousAuthenticationFilter:检测 SecurityContextHolder 中是否存在 Authentication 对象,如果不存在则会创建一个匿名用户存入到SecurityContextHolder中。SpringSecurity为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。
12. SessionManagementFilter:SecurityContextRepository限制同一用户开启多个会话的数量。
13. ExceptionTranslationFilter:处理 AccessDeniedException 和 AuthenticationException 异常。
14. FilterSecurityInterceptor:可以看做过滤器链的出口。获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权限。
15. RememberMeAuthenticationFilter:当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。
其中比较重要的三个:UsernamePasswordAuthenticationFilter(认证),ExceptionTranslationFilter(异常),FilterSecurityInterceptor(授权)
3,Spring Security安全身份认证流程原理
- 登录用户名和密码被过滤器获取到,封装成 Authentication ,通常情况下是 UsernamePasswordAuthenticationToken 这个实现类。
这个实现类实现 AbstractAuthenticationProcessingFilter 抽象类,在 doFilter() 中调用子类的 attemptAuthentication() 方法 - AuthenticationManager 身份管理器负责验证这个 Authentication。
通过调用 AuthenticationManager.authenticate() 方法。 - AuthenticationManager持有的多个 AuthenticationProvider,AuthenticationManager.authenticate() 方法中遍历调用 AuthenticationProvider.authenticate(),每个 authenticate() 基本两个步骤
第一步:这里会先调用子类实现的 retrieveUser() 方法通过用户名从 UserDetailService.loadUserByUsername() 中获取用户信息。
第二步:如果获取用户信息成功,通过子类的 additionalAuthenticationChecks() 方法验证密码。 - 到此回到第一步 doFilter() 方法,attemptAuthentication() 方法调用成功。successfulAuthentication() 方法中保存了 SecurityContextHolder.getContext().setAuthentication(authResult)
然后通过 successHandler.onAuthenticationSuccess(request, response, authResult) 调用登录成功处理器返回前端信息。
4,核心组件
1,SecurityContextHolder
SecurityContextHolder它持有的是安全上下文 (security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权等等,这些都被保存在SecurityContextHolder中。
SecurityContextHolder.getContext().getAuthentication().getPrincipal()
getAuthentication() 返回了认证信息, getPrincipal() 返回了身份信息。
2,SecurityContext
安全上下文,主要持有 Authentication 对象,如果用户未鉴权,那Authentication对象将会是空的
3,Authentication
鉴权对象,该对象主要包含了用户的详细信息 (UserDetails) 和用户鉴权时所需要的信息,如用户提交的用户名密码、Remember-me Token,或者digest hash值等,按不同鉴权方式使用不同的 Authentication 实现
4,UserDetails
这个接口规范了用户详细信息所拥有的字段,譬如用户名、密码、账号是否过期、是否锁定等。在Spring Security中,获取当前登录的用户的信息,一般情况是需要在这个接口上面进行 扩展,用来对接自己系统的用户
5,UserDetailsService
这个接口只提供一个接口 loadUserByUsername(String username) ,这个接口非常 重要, 一般情况我们都是通过 扩展 这个接口来显示获取我们的用户信息,用户登陆时传递的用户名和密码也是通过这里这查找出来的用户名和密码进行校验,但是真正的校验不在这里,而是由 AuthenticationManager 以及 AuthenticationProvider 负责的
6,AuthenticationManager
AuthenticationManager (接口)是认证相关的 核心接口 ,也是发起认证的出发点,因为在实际需求中,我们可能会允许用户使用用户名+密码登录,同时允许用户使用邮箱+密码,手机号码+密码登录,甚至,可能允许用户使用指纹登录(还有这样的操作?没想到吧),所以说 AuthenticationManager 一般不直接认证,
AuthenticationManager 接口的常用实现类 ProviderManager 内部会维护一个 List
5,UsernamePasswordAuthenticationFilter(认证)
重写认证过滤器(登录)
/** * 登录认证的过滤器 * @author gx * */ public class LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter { /** * 构造方法,设置登录接口和方法 */ public LoginAuthenticationFilter() { super(new AntPathRequestMatcher("/auth/login", "POST")); } /** * 登录认证的方法 * * 注入AuthenticationManager来处理子类中定义的 authenticationToken * 通过 setFilterProcessesUrl 方法设置需要拦截的请求 * 子类通过实现 attemptAuthentication 来尝试认证处理 * 认证成功后,将会将成功认证的信息 Authentication 保存到线程本地 SecurityContext中 * 子类通过注入 rememberMeServices,来进行相应的处理 * 子类通过注入successHandler 和failureHandler来进行相应处理 */ @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { AbstractAuthenticationToken token; //获取登录类型 String type = request.getHeader("loginType"); if(type != null && type.equals(LoginTypeEnum.PHONE.name())){ //返回一个 phone 类型的 token String phone = request.getParameter("phone"); String verificationCode = request.getParameter("verificationCode"); token = new PhoneAuthentication(phone, verificationCode); }else{ String username = request.getParameter("username"); String password = request.getParameter("password"); token = new PasswordAuthentication( username, password); } return getAuthenticationManager().authenticate(token); } /** * 这个方法 SecurityContextHolder.getContext().setAuthentication(authResult) 保存登录用户信息,取消,在登录成功逻辑中存储 * */ @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { getSuccessHandler().onAuthenticationSuccess(request, response, authResult); } }
6,ExceptionTranslationFilter 异常过滤器
这里有个大坑,当第一个请求发生异常,这个烂过滤器会缓存 request,导致第二次依然报错。。。
ExceptionTranslationFilter.doFilter
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; try { chain.doFilter(request, response); logger.debug("Chain processed normally"); } catch (IOException ex) { throw ex; } catch (Exception ex) { // Try to extract a SpringSecurityException from the stacktrace Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex); //1,获取 AuthenticationExeception RuntimeException ase = (AuthenticationException) throwableAnalyzer .getFirstThrowableOfType(AuthenticationException.class, causeChain); //2,获取 AccessDeniedException if (ase == null) { ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType( AccessDeniedException.class, causeChain); } if (ase != null) { if (response.isCommitted()) { throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex); } //3,有这俩异常的时候执行这个 handleSpringSecurityException(request, response, chain, ase); } else { // Rethrow ServletExceptions and RuntimeExceptions as-is if (ex instanceof ServletException) { throw (ServletException) ex; } else if (ex instanceof RuntimeException) { throw (RuntimeException) ex; } // Wrap other Exceptions. This shouldn't actually happen // as we've already covered all the possibilities for doFilter throw new RuntimeException(ex); } } }
异常的处理
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException { if (exception instanceof AuthenticationException) { logger.debug( "Authentication exception occurred; redirecting to authentication entry point", exception); //1,调用这个方法,这个方法会缓存 request sendStartAuthentication(request, response, chain, (AuthenticationException) exception); } else if (exception instanceof AccessDeniedException) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) { logger.debug( "Access is denied (user is " + (authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point", exception); //2,调用这个方法,这个方法会缓存 request sendStartAuthentication( request, response, chain, new InsufficientAuthenticationException( messages.getMessage( "ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource"))); } else { logger.debug( "Access is denied (user is not anonymous); delegating to AccessDeniedHandler", exception); accessDeniedHandler.handle(request, response, (AccessDeniedException) exception); } } }
缓存 request 的坑人操作
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException { // SEC-112: Clear the SecurityContextHolder's Authentication, as the // existing Authentication is no longer considered valid SecurityContextHolder.getContext().setAuthentication(null); requestCache.saveRequest(request, response); logger.debug("Calling Authentication entry point."); authenticationEntryPoint.commence(request, response, reason); }
在 RequestCacheAwareFilter 中,会优先使用 requestCache 中的缓存。。。
点击查看代码
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest wrappedSavedRequest = requestCache.getMatchingRequest( (HttpServletRequest) request, (HttpServletResponse) response); chain.doFilter(wrappedSavedRequest == null ? request : wrappedSavedRequest, response); }
导致问题:
如果网站认证是信息存放在header中。第一次请求受保护资源时,请求头中不包含认证信息 ,验证失败,该请求会被缓存,之后即使用户填写了信息,也会因为request被恢复导致信息丢失从而认证失败(问题描述可以参见这里。
最简单的方案当然是不缓存request
解决方案:
使用 NullRequestCache,实现了 RequestCache,但是没有任何操作
配置 requestCache
http.requestCache().requestCache(new NullRequestCache());
7,FilterSecurityInterceptor(授权)
默认使用 SecurityContextHolder.getContext().getAuthentication() 来获取用户登录信息,我们需要将它替换掉
自定义授权
package com.market.service.authentication; import cn.hutool.core.map.MapUtil; import com.market.service.UserService; import com.market.util.JwtTokenUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; import org.springframework.util.StringUtils; import javax.servlet.http.HttpServletRequest; import java.util.*; import java.util.function.BinaryOperator; import java.util.stream.Collectors; /** * 接口鉴权的方法,从数据库获取替代在 WebSecurityConfigurerAdapter 固定的 antMatch * @author liqi */ @Component("resourcePermissionService") public class ResourcePermissionService { private AntPathMatcher antPathMatcher = new AntPathMatcher(); @Autowired private UserService userService; //public static final Map<String, List<String>> AUTH_MAP = new HashMap<>(); // static { // AUTH_MAP.put("admin", Arrays.asList("/admin")); // AUTH_MAP.put("delete", Arrays.asList("/usr/list")); // AUTH_MAP.put("ROLE_test", Arrays.asList("/test")); // } public boolean hasPermission(HttpServletRequest request, Authentication authentication) { // 当前用户所具有的权限 final Set<String> authorityListToSet = AuthorityUtils.authorityListToSet(authentication.getAuthorities()); Map<String, List<String>> authMap = userService.resourceList(); // 当前请求的url final String url = request.getRequestURI(); if(MapUtil.isEmpty(authMap)){ return true; } Optional<List<String>> permissionReduce = authMap.entrySet().stream() .filter(e -> antPathMatcher.match(e.getKey(), url)) .map(e -> e.getValue()) .reduce((strings, strings2) -> { strings.addAll(strings2); return strings; }); //说明压根没有这个东西 if( !permissionReduce.isPresent() ){ return true; } authorityListToSet.retainAll(permissionReduce.get()); if(authorityListToSet.size() > 0){ return true; } //return false; // for (String roleName : authorityListToSet) { // List<String> list = authMap.get(Integer.parseInt(roleName)); // if (list != null) { // final Optional<String> findAny = list.stream().filter(res -> { // // 通配符路径验证 eg: /sys/login/** --表示任意路径 "*"号 // return antPathMatcher.match(res, url); // }).findAny(); // if (findAny.isPresent()) { // return true; // } // } // } // 异常不是鉴权异常的时候,异常无法向上抛出,异常处理的controller无法返回默认异常,需要把异常处理成鉴权异常 throw new AuthenticationServiceException("没有访问url的权限:" + url); } //根据 token 获取用户名 public Map<String, String> getUsername(String token){ //当 token 没东西时 if(!StringUtils.hasText(token)){ //filterChain.doFilter(req, res); //return; throw new AuthenticationServiceException("token 为空"); } //解析 token String tokenUsername; String BEARER = "Bearer "; if ( token.startsWith(BEARER)) { //从Bearer 之后开始截取 token = token.substring(BEARER.length()); try { tokenUsername = JwtTokenUtil.getUsernameFromToken(token); }catch (Exception e){ throw new AuthenticationServiceException("用户信息验证异常"); } }else{ throw new AuthenticationServiceException("token 格式异常"); } return MapUtil.builder(new HashMap<String, String>()).put("username", tokenUsername).put("token", token).build(); } }
配置
@Override protected void configure(HttpSecurity http) throws Exception { //定义 url 授权 http.authorizeRequests() //表示某个路径需要某些权限才能访问 //.antMatchers("/msg/list").hasAnyAuthority("admin", "user") //.antMatchers("/usr/list").hasAuthority("delete") //允许匿名访问 .antMatchers("/auth/**").anonymous() //表示其他路径登录后就能访问 .anyRequest() .access("@resourcePermissionService.hasPermission(request,authentication)"); //.authenticated(); }
本文作者:primaryC
本文链接:https://www.cnblogs.com/cnff/p/17535612.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下