JSON RestTemplate 的 CSRF 拦截器

JSON CSRF Interceptor for RestTemplate

我有一个小型 Rest-Service 应用程序(Java 8,Spring 4.1.6,Spring Security 5.0.1,Jetty 9.3)我正在访问JSON 使用 Spring RestTemplate 的一些服务。之前csfr是禁用的,现在我想启用它。

据了解,csfr 有一个公共令牌(客户端随每个请求发送它,服务器将其存储在 session 中),该令牌在服务器端进行比较。如果没有可用令牌或令牌不同,访问将被拒绝。

所以我认为使用拦截器token-adding 是个好主意。我还读到,在 json 中我必须将令牌作为 header-parameter 发送...但是我做错了什么,已经登录失败了。

这里是 login-source:

MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("username", username);
form.add("password", password);
return restTemplate.postForLocation(serverUri + "login", form);

这里是拦截器的来源:

import java.io.IOException;
import java.util.UUID;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;

public class MyCsfrInterceptor implements ClientHttpRequestInterceptor{

    public static final String CSRF_TOKEN_HEADER_NAME = "X-CSRF-TOKEN";
    public static final String csrfSessionToken = UUID.randomUUID().toString();

    private static Logger LOG = LoggerFactory.getLogger(MyCsfrInterceptor.class);

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        LOG.info("My interceptor called!");
        if (request.getMethod() == HttpMethod.DELETE ||
                request.getMethod() == HttpMethod.POST ||
                request.getMethod() == HttpMethod.PATCH ||
                request.getMethod() == HttpMethod.PUT){
            LOG.info("Setting csrf token...");
            request.getHeaders().add(CSRF_TOKEN_HEADER_NAME, csrfSessionToken);
        }
        return execution.execute(request, body);
    }

}

这是客户端的日志输出:

23:24:40.605 [main] DEBUG c.m.l.w.client.StatefulRestTemplate - Created POST request for "http://localhost:8080/login"
23:24:40.610 [main] DEBUG c.m.l.w.client.StatefulRestTemplate - Writing [{username=[user], password=[user]}] using [org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter@618b19ad]
23:24:40.615 [main] INFO  c.m.l.w.client.MyCsfrInterceptor - My interceptor called!
23:24:40.615 [main] INFO  c.m.l.w.client.MyCsfrInterceptor - Setting csrf token...
23:24:40.650 [main] DEBUG o.a.h.c.protocol.RequestAddCookies - CookieSpec selected: best-match
23:24:40.670 [main] DEBUG o.a.h.c.protocol.RequestAuthCache - Auth cache not set in the context
23:24:40.675 [main] DEBUG o.a.h.i.c.PoolingHttpClientConnectionManager - Connection request: [route: {}->http://localhost:8080][total kept alive: 0; route allocated: 0 of 2; total allocated: 0 of 20]
23:24:40.705 [main] DEBUG o.a.h.i.c.PoolingHttpClientConnectionManager - Connection leased: [id: 0][route: {}->http://localhost:8080][total kept alive: 0; route allocated: 1 of 2; total allocated: 1 of 20]
23:24:40.710 [main] DEBUG o.a.h.impl.execchain.MainClientExec - Opening connection {}->http://localhost:8080
23:24:40.715 [main] DEBUG o.a.h.i.c.HttpClientConnectionOperator - Connecting to localhost/127.0.0.1:8080
23:24:40.715 [main] DEBUG o.a.h.i.c.HttpClientConnectionOperator - Connection established 127.0.0.1:54712<->127.0.0.1:8080
23:24:40.715 [main] DEBUG o.a.h.impl.execchain.MainClientExec - Executing request POST /login HTTP/1.1
23:24:40.715 [main] DEBUG o.a.h.impl.execchain.MainClientExec - Target auth state: UNCHALLENGED
23:24:40.720 [main] DEBUG o.a.h.impl.execchain.MainClientExec - Proxy auth state: UNCHALLENGED
23:24:40.720 [main] DEBUG org.apache.http.headers - http-outgoing-0 >> POST /login HTTP/1.1
23:24:40.720 [main] DEBUG org.apache.http.headers - http-outgoing-0 >> Content-Type: application/x-www-form-urlencoded
23:24:40.720 [main] DEBUG org.apache.http.headers - http-outgoing-0 >> X-CSRF-TOKEN: 99ca8171-b8e0-4b95-8d06-3759a874c64b
23:24:40.720 [main] DEBUG org.apache.http.headers - http-outgoing-0 >> Content-Length: 27
23:24:40.720 [main] DEBUG org.apache.http.headers - http-outgoing-0 >> Host: localhost:8080
23:24:40.720 [main] DEBUG org.apache.http.headers - http-outgoing-0 >> Connection: Keep-Alive
23:24:40.720 [main] DEBUG org.apache.http.headers - http-outgoing-0 >> User-Agent: Apache-HttpClient/4.3.4 (java 1.5)
23:24:40.720 [main] DEBUG org.apache.http.headers - http-outgoing-0 >> Accept-Encoding: gzip,deflate
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "POST /login HTTP/1.1[\r][\n]"
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "Content-Type: application/x-www-form-urlencoded[\r][\n]"
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "X-CSRF-TOKEN: 99ca8171-b8e0-4b95-8d06-3759a874c64b[\r][\n]"
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "Content-Length: 27[\r][\n]"
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "Host: localhost:8080[\r][\n]"
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "Connection: Keep-Alive[\r][\n]"
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "User-Agent: Apache-HttpClient/4.3.4 (java 1.5)[\r][\n]"
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "Accept-Encoding: gzip,deflate[\r][\n]"
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "[\r][\n]"
23:24:40.720 [main] DEBUG org.apache.http.wire - http-outgoing-0 >> "username=user&password=user"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "HTTP/1.1 403 Expected CSRF token not found. Has your session expired?[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "Date: Sat, 15 Aug 2015 21:24:40 GMT[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "Pragma: no-cache[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "X-XSS-Protection: 1; mode=block[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "X-Frame-Options: DENY[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "X-Content-Type-Options: nosniff[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "Set-Cookie: JSESSIONID=1bvmexep1lv9h1qja44hflx0wg;Path=/[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "Content-Type: text/html;charset=iso-8859-1[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "Cache-Control: must-revalidate,no-cache,no-store[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "Content-Length: 409[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "Server: Jetty(9.3.0.RC1)[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "[\r][\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "<html>[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "<head>[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "<title>Error 403 Expected CSRF token not found. Has your session expired?</title>[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "</head>[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "<body><h2>HTTP ERROR 403</h2>[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "<p>Problem accessing /login. Reason:[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "<pre>    Expected CSRF token not found. Has your session expired?</pre></p><hr><a href="http://eclipse.org/jetty">Powered by Jetty:// 9.3.0.RC1</a><hr/>[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "</body>[\n]"
23:24:40.831 [main] DEBUG org.apache.http.wire - http-outgoing-0 << "</html>[\n]"
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << HTTP/1.1 403 Expected CSRF token not found. Has your session expired?
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << Date: Sat, 15 Aug 2015 21:24:40 GMT
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << Pragma: no-cache
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << X-XSS-Protection: 1; mode=block
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << X-Frame-Options: DENY
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << X-Content-Type-Options: nosniff
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << Set-Cookie: JSESSIONID=1bvmexep1lv9h1qja44hflx0wg;Path=/
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << Content-Type: text/html;charset=iso-8859-1
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << Cache-Control: must-revalidate,no-cache,no-store
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << Content-Length: 409
23:24:40.836 [main] DEBUG org.apache.http.headers - http-outgoing-0 << Server: Jetty(9.3.0.RC1)
23:24:40.846 [main] DEBUG o.a.h.impl.execchain.MainClientExec - Connection can be kept alive indefinitely
23:24:40.861 [main] DEBUG o.a.h.c.p.ResponseProcessCookies - Cookie accepted [JSESSIONID="1bvmexep1lv9h1qja44hflx0wg", version:0, domain:localhost, path:/, expiry:null]
23:24:40.861 [main] DEBUG c.m.l.w.client.StatefulRestTemplate - POST request for "http://localhost:8080/login" resulted in 403 (Expected CSRF token not found. Has your session expired?); invoking error handler
23:24:40.866 [main] DEBUG o.a.h.i.c.PoolingHttpClientConnectionManager - Connection [id: 0][route: {}->http://localhost:8080] can be kept alive indefinitely

在 spring 安全配置中我没有做任何 csrf 配置。

怎么了?我把 header 设置错了吗?缺少任何配置?

谨致问候,感谢您抽出宝贵时间。

看你的代码,好像是你自己生成CSRF token。但是,据我了解,Spring 安全 CSRF 处理将以这种方式工作:

  1. Spring 安全将生成 CSRF 令牌。
  2. 每当请求到来时(例如 GET 请求)Spring 安全性会将令牌附加为请求参数。这有助于呈现 JSP 表单并将标记作为隐藏字段,如下所示:

    <form action="/foo/5/update" method="post">
        <input type="text" ... />
        <input type="submit" value="Update" />
        <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
    </form>
    
  3. 每当收到类似 POST 的请求(例如提交表单)时,Spring 安全性会将其令牌与作为参数提交的令牌或 header.

当我们使用 JSP 等时,上面的

(2) 运行良好。但是,当我们调用 API 时,我们首先需要获取令牌。一个常见的做法是在服务器端有一个过滤器,将令牌附加为 cookie。这是我在一个项目中的过滤代码:

public class CsrfCookieFilter extends OncePerRequestFilter {

    public static final String XSRF_TOKEN_COOKIE_NAME = "XSRF-TOKEN";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
                    throws ServletException, IOException {      

        log.debug("Inside CsrfCookieFilter ...");

        CsrfToken csrf = (CsrfToken)
            request.getAttribute(CsrfToken.class.getName()); // Or "_csrf" (See CSRFFilter.doFilterInternal).

        if (csrf != null) {
            Cookie cookie = WebUtils.getCookie(
                request, XSRF_TOKEN_COOKIE_NAME);
            String token = csrf.getToken();
            if (cookie==null ||
                token!=null && !token.equals(cookie.getValue())) {
                cookie = new Cookie(XSRF_TOKEN_COOKIE_NAME, token);
                cookie.setPath("/");
                response.addCookie(cookie);
            }
        }       
        filterChain.doFilter(request, response);
    }
}

因此,在 POST 请求之前,首先应该发出 GET 请求并将令牌作为 cookie 获取。然后应在后续请求中将令牌作为 header 发回。

我的连接此过滤器和更改 header 名称的代码在我的安全配置中看起来像这样 class:

@Override
protected void configure(HttpSecurity http) throws Exception {

    http
        ...
        .addFilterAfter(csrfCookieFilter(), CsrfFilter.class)
        ...
}

protected Filter csrfCookieFilter() {
    return new CsrfCookieFilter();
}

另外,请注意 Spting Security changes the token 在某些事件后,如 loginlogout。因此,我们需要再次使用 GET 请求获取令牌。

官方Spring Angular指南已经详细阐述了。