SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理


本文主要介绍SpringSecurity 和 SpringBoot 整合过程中关于 CSRF 的处理.

什么是 CSRF

CSRF, 全称 Cross-Site Request Forgery - 跨站请求伪造. 攻击者盗用用户的身份凭证 (譬如拿到了你的 access-token), 以你的名义向服务端发送请求, 对服务器来说这个请求是完全合法的, 但是却完成了攻击者所期望的操作.
如何防御? 通过 HTTP 请求头中的 Referer 字段或是 令牌证都可行. SpringSecurity 采用的就是后面这一种方式. 在响应头和请求头中做文章: 每次合法的请求后端都会给前端返回 csrf-token, 下一次请求, 前端携带这个令牌与后端持有的令牌比较, 如果一致就表示这是一次合法的请求.

SpringSecurity CSRF

什么样的请求会被 SpringSecurity 的 CSRF 防御策略检查?

  • 对于 GET 请求, 它只是访问服务器的资源, 没有进行修改数据的操作. 所以对于这类请求, Spring Security 的 CSRF 防御策略是允许的.
  • 对于 POST 请求, 是默认被检查的. 因为这类请求是带有更新服务器资源的危险操作. 如果第三方通过劫持令牌来更新服务器资源, 那会造成服务器数据被非法的篡改, 所以这类请求是会被 SpringSecurity CSRF 防御策略拦截的. 在默认的情况下, SpringSecurity 是启用 CSRF 拦截功能的. 前端发起的 POST 请求后端无法正常处理, 保证了安全性, 但影响了正常的使用. 如果关闭 CSRF 防护功能, 虽然可以正常处理 POST 请求, 但是无法防范非法的 POST 请求. 为了辨别合法的 POST 请求, 采用了 TOKEN 的机制.

在 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.
总的流程:

  1. 对于放行的请求: 生成与用户绑定的缓存, 缓存 csrf-token;
  2. 对于其他的请求: 需要携带第1步生成的 csrf-token, CsrfFilter 届时会用缓存中的和请求中的对比, 以判断是否是合法请求;

每次合法的身份验证之后, 都应当更换缓存中的 csrf-token, 并在相应头中置入新的 csrf-token. 这一过程受控于 CsrfAuthenticationStrategy 这个类, 它负责在执行认证请求之后, 删除旧的令牌, 生成新的. 确保每次请求之后, csrf-token 都得到更新.
在这里插入图片描述


CsrfAuthentication#onAuthentication 的执行时机:
在这里插入图片描述

主要代码片段

SpringConfiguration

放行登陆端点, 用于生成第一个 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;
    }
}

CsrfTokenRedisRepository

假定, 每一次合法的请求, 总能获取到用户名称: 登陆请求携带用户信息, 其余请求无论是携带 JWT 还是其他形式的令牌, 总能从中获取到用户名.
从前面的分析我们知道:

  • 在第一个阶段, 由 CsrfFilter 发起: loadToken 方法会优先被调用, 如果返回 null, generateTokensaveToken 会被调用;
  • 第二个阶段, 当一次认证操作发生之后, 由 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;
    }
}

HttpServletRequestWrapFilter

由于我们前端数据是放在 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();
                }
            };
        }
    }
}

AuthenticationFilter & AuthorizationFilter

接下来, 由于不是本文重点, 我们简单实现这两个过滤器, 有关它们的作用以及详细实现, 参考 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 模块.

Reference

CSRF的攻击与防御

修订日志

  • 2020-05-18 14:37:22 - 更新 “CsrfAuthentication#onAuthentication 的执行时机”

- END -

  • 7
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 13
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值