赞
踩
CSRF, 全称 Cross-Site Request Forgery - 跨站请求伪造. 攻击者盗用用户的身份凭证 (譬如拿到了你的 access-token), 以你的名义向服务端发送请求, 对服务器来说这个请求是完全合法的, 但是却完成了攻击者所期望的操作.
如何防御? 通过 HTTP 请求头中的 Referer 字段或是 令牌证都可行. SpringSecurity 采用的就是后面这一种方式. 在响应头和请求头中做文章: 每次合法的请求后端都会给前端返回 csrf-token, 下一次请求, 前端携带这个令牌与后端持有的令牌比较, 如果一致就表示这是一次合法的请求.
什么样的请求会被 SpringSecurity 的 CSRF 防御策略检查?
在 SpringBoot Web + SpringSecurity 中, 是通过 CsrfFilter
这一过滤器拦截目标请求的. 这个过滤器继承了 OncePerRequestFilter
, 意味着所有请求都会被它过滤, 那它是依据什么条件来判断一个请求是否应当被检查的呢? 答案是通过 CsrfConfigurer
中的 requireCsrfProtectionMatcher
来指定的, 默认是 CsrfFilter.DEFAULT_CSRF_MATCHER
:
可以在配置类中通过 requireCsrfProtectionMatcher(org.springframework.security.web.util.matcher.RequestMatcher requireCsrfProtectionMatcher)
指定.
介绍完了规则, 下面再来说说 csrf-token, 如何生成? 什么时候生成? 来看 CsrfFilter
的 doInternalFilter:
可以看到, CsrfFilter
是依赖 tokenRepository
(private final CsrfTokenRepository tokenRepository;
) 来操作 csrf-token 的. 整个逻辑很简单, 从 tokenRepository 中获取服务端的 csrf-token, 和请求中的做比较. 接下来看代码实现.
跟上一篇 (SpringSecurity (3) SpringBoot + JWT 实现身份认证和权限验证) 一样, 我们仍然会模拟前后端分离, 启用 JWT 的场景, 在服务端禁用 Session.
首先来看看 CsrfTokenRepository
的默认实现:
为此, 我们需要实现自己的 CsrfTokenRepository
.
总的流程:
CsrfFilter
届时会用缓存中的和请求中的对比, 以判断是否是合法请求;每次合法的身份验证之后, 都应当更换缓存中的 csrf-token, 并在相应头中置入新的 csrf-token. 这一过程受控于 CsrfAuthenticationStrategy
这个类, 它负责在执行认证请求之后, 删除旧的令牌, 生成新的. 确保每次请求之后, csrf-token 都得到更新.
CsrfAuthentication#onAuthentication
的执行时机:
放行登陆端点, 用于生成第一个 csrf-token. 指定令牌仓库为我们自己的实现.
@Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter { private RedisService redisService; private PasswordEncoder passwordEncoder; private CsrfTokenRedisRepository csrfTokenRedisRepository; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("caplike").password(passwordEncoder.encode("caplike")).authorities("ADMIN") ; } @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().ignoringAntMatchers("/login").csrfTokenRepository(csrfTokenRedisRepository) .and() .authorizeRequests() .anyRequest().hasAuthority("ADMIN") .and() .formLogin().disable() .addFilterBefore(new HttpServletRequestWrapFilter(), CsrfFilter.class) .addFilterAt(new SimpleAuthenticationFilter(authenticationManager(), redisService), UsernamePasswordAuthenticationFilter.class) .addFilterAfter(new SimpleAuthorizationFilter(), SimpleAuthenticationFilter.class) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) ; } // ~ Autowired // ----------------------------------------------------------------------------------------------------------------- @Autowired public void setPasswordEncoder(PasswordEncoder passwordEncoder) { this.passwordEncoder = passwordEncoder; } @Autowired public void setCsrfTokenRedisRepository(CsrfTokenRedisRepository csrfTokenRedisRepository) { this.csrfTokenRedisRepository = csrfTokenRedisRepository; } @Autowired public void setRedisService(RedisService redisService) { this.redisService = redisService; } }
假定, 每一次合法的请求, 总能获取到用户名称: 登陆请求携带用户信息, 其余请求无论是携带 JWT 还是其他形式的令牌, 总能从中获取到用户名.
从前面的分析我们知道:
CsrfFilter
发起: loadToken
方法会优先被调用, 如果返回 null, generateToken
和 saveToken
会被调用;CsrfAuthenticationStrategy
发起, 再一次调用 loadToken
方法判断服务端有没有 csrf-token, 如果有, 执行清除操作 (saveToken
第一个参数传入 null), 再生成新的 (generateToken
), 接着再保存 saveToken
;RedisService
的实现请看 SpringBoot 自动配置 (2) - 自己写个 Starter 二次封装 spring-boot-starter-data-redis)@Component public class CsrfTokenRedisRepository implements CsrfTokenRepository { /** * parameterName */ private static final String CSRF_PARAMETER_NAME = "_csrf"; /** * headerName */ private static final String CSRF_HEADER_NAME = "X-CSRF-TOKEN"; private RedisService redisService; private LoginUser loginUser; @SneakyThrows @Override public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) { if (Objects.isNull(token)) { log.debug("csrf filter: do nothing while token is null. The token's lifecycle will be handled by Redis."); return; } redisService.setValue( RedisKey.builder() .prefix("user") .suffix(Optional.ofNullable(loginUser).orElseThrow(() -> new AuthenticationException("LoginUser info is null!") { }).getUsername()) .build(), token.getToken() ); response.setHeader("csrf-token", token.getToken()); } @SneakyThrows @Override public CsrfToken loadToken(HttpServletRequest request) { final LoginUser loginUser = setLoginUser(request); log.debug("csrf filter: redis csrf token repository: load token by LoginUser info ({}).", loginUser.toString()); try { final String csrfToken = getCachedToken(); return StringUtils.isBlank(csrfToken) ? null : new DefaultCsrfToken( CSRF_HEADER_NAME, CSRF_PARAMETER_NAME, csrfToken ); } catch (RuntimeException ignored) { return null; } } private String getCachedToken() { final String csrfToken = redisService.getValue( // demo-spring-security-csrf.user.{username} RedisKey.builder().prefix("user").suffix(loginUser.getUsername()).build(), String.class ); if (StringUtils.isNoneBlank(csrfToken)) { return csrfToken; } return StringUtils.EMPTY; } private LoginUser setLoginUser(HttpServletRequest request) throws java.io.IOException { final LoginUser loginUser = (LoginUser) Optional.ofNullable(JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8, LoginUser.class)) .orElseThrow(() -> new AuthenticationException("LoginUser info is null!") { }); this.loginUser = loginUser; return loginUser; } @Override public CsrfToken generateToken(HttpServletRequest request) { final String csrfToken = StringUtils.replace(UUID.randomUUID().toString(), "-", StringUtils.EMPTY); log.debug("csrf filter: redis csrf token repository: generate token: {}", csrfToken); return new DefaultCsrfToken(CSRF_HEADER_NAME, CSRF_PARAMETER_NAME, csrfToken); } // ~ Autowired // ----------------------------------------------------------------------------------------------------------------- @Autowired public void setRedisService(RedisService redisService) { this.redisService = redisService; } }
由于我们前端数据是放在 requestBody 中, 并且在 CsrfFilter
里已经使用了 request.getInputStream
, 这时如果我们直接在后续 Filter 中再一次调用该方法, 会报 “getInputStream() has already been called for this request” 的异常. 而在 Spring MVC 中, requestBody 中的数据也是通过 request.getInputStream()
获取的, 所以为了确保后续程序能够多次获取输入流中的信息, 有必要实现一个包装 HttpServletRequest 的过滤器, 并先于 CsrfFilter (CsrfFilter 默认就先于 UsernamePasswordAuthenticationFilter)
调用, 使得 HttpServletRequest 的 inputStream 可以重复使用, 代码如下:
public class HttpServletRequestWrapFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { log.info("request body servlet request wrap filter ..."); final RequestBodyServletRequestWrapper requestWrapper = new RequestBodyServletRequestWrapper(request); filterChain.doFilter(requestWrapper, response); } /** * ServletRequest 的包装器 (让后续方法可以重复调用 request.getInputStream())<br> * 处理流只能读取一次的问题, 用包装器继续将流写出. @RequestBody 会调用 getInputStream 方法, 所以本质上是解决 getInputStream 多次调用的问题: <br> * ServletRequest 的 getReader() 和 getInputStream() 两个方法只能被调用一次,而且不能两个都调用。那么如果 Filter 中调用了一次,在 Controller 里面就不能再调用了, * 会抛出异常:getReader() has already been called for this request 异常 * * @author LiKe * @date 2019-11-21 09:24 */ private static class RequestBodyServletRequestWrapper extends HttpServletRequestWrapper { /** * 请求体数据 */ private final byte[] requestBody; /** * 重写的参数 Map */ private final Map<String, String[]> paramMap; public RequestBodyServletRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 重写 requestBody requestBody = IOUtils.toByteArray(request.getReader(), StandardCharsets.UTF_8); // 重写参数 Map paramMap = new HashMap<>(); if (requestBody.length == 0) { return; } JSON.parseObject(getRequestBody()).forEach((key, value) -> paramMap.put(key, new String[]{String.valueOf(value)})); } public String getRequestBody() { return StringUtils.toEncodedString(requestBody, StandardCharsets.UTF_8); } // ~ get // ----------------------------------------------------------------------------------------------------------------- @Override public Map<String, String[]> getParameterMap() { return paramMap; } @Override public String getParameter(String key) { String[] valueArr = paramMap.get(key); if (valueArr == null || valueArr.length == 0) { return null; } return valueArr[0]; } @Override public String[] getParameterValues(String key) { return paramMap.get(key); } @Override public Enumeration<String> getParameterNames() { return Collections.enumeration(paramMap.keySet()); } // ~ read // ----------------------------------------------------------------------------------------------------------------- @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream())); } @Override public ServletInputStream getInputStream() { final ByteArrayInputStream inputStream = new ByteArrayInputStream(requestBody); return new ServletInputStream() { @Override public boolean isFinished() { return false; } @Override public boolean isReady() { return false; } @Override public void setReadListener(ReadListener listener) { } @Override public int read() { return inputStream.read(); } }; } } }
接下来, 由于不是本文重点, 我们简单实现这两个过滤器, 有关它们的作用以及详细实现, 参考 SpringSecurity (3) SpringBoot + JWT 实现身份认证和权限验证
public class SimpleAuthenticationFilter extends UsernamePasswordAuthenticationFilter { private final RedisService redisService; private final AuthenticationManager authenticationManager; public SimpleAuthenticationFilter(AuthenticationManager authenticationManager, RedisService redisService) { this.authenticationManager = authenticationManager; this.redisService = redisService; } @SneakyThrows @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { log.info("simple authentication filter: attemptAuthentication ..."); Map<String, String> map = JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8, Map.class); return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken( MapUtils.getString(map, "username"), MapUtils.getString(map, "password") )); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) { SecurityContextHolder.getContext().setAuthentication(authResult); UserDetails userDetails = (UserDetails) authResult.getPrincipal(); System.out.println(JSON.toJSONString(userDetails)); // 登陆成功把 csrf-token 返回给前端 response.setHeader("csrf-token", redisService.getValue(RedisKey.builder().prefix("user").suffix(userDetails.getUsername()).build(), String.class)); } }
public class SimpleAuthorizationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { Map<String, String> map = JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8, Map.class); // 认证 SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( MapUtils.getString(map, "username"), MapUtils.getString(map, "password"), Collections.singletonList((GrantedAuthority) () -> "ADMIN") )); filterChain.doFilter(request, response); } }
核心代码已经张贴出来, 完整示例请查看 代码仓库
对于登陆请求, 成功后会在响应头中返回 csrf-token
对于其他请求, 每一次请求, 都会生成新的 csrf-token:
第一次:
第二次:
本文介绍了 SpringSecurity CSRF 的相关内容.
下一篇, 我们将结合前面的内容, 比较完整的实现一个 SpringBoot + SpringSecurity + JWT + CSRF + Redis 的 Auth 模块.
- END -
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。