Pages

搜尋此網誌

2013年12月31日 星期二

Spring Security Basic Authentication with Ajax request 失敗處理

Basic Auth filter authenticates OPTIONS requests and breaks CORS

當你在製作一個 rest service,提供 API 另外部的程式存取,比如說網頁中的 ajax request,且必須要有基本的安全性防護,在 java base 底下我們可以引入 Spring Security 來幫我們處理安全性的大小事。

關於安全性驗證的方式,可參考官方的文件:Spring Security doc

基於 rest service 的特性,既然是提供給第三方軟體呼叫,我們就不希望還需要 login 頁面來登入 rest service,可以的話當然希望在進行請求時就驗證存取權限,所以我們需要使用的是 Basic Authentication

簡單來說 Basic Authentication 就是將驗證資訊透過 http headers 傳遞,交由 server 驗證之後,返回通過或失敗,範例的 request 呼叫如下:

function make_base_auth(user, password) {
    var tok = user + ':' + password;
    var hash = $.base64.encode(tok);
    return "Basic "+hash;
}
$.ajax("http://localhost:8080/test/sendMsg", {
    type: "GET",
    success: function(){
        alert("OK");
    },
    xhrFields: {
        withCredentials: true
    }, 
    beforeSend: function (xhr) {
        xhr.setRequestHeader('Authorization', 
            make_base_auth("user", "password"));
    }
});

其中 Authorization 的值必須以 Basic 開頭,接著將 username:password 透過 base64 編碼,上述 make_base_auth("user", "password") 回傳的內容為: Basic dXNlcjpwYXNzd29yZA==,並且需要設定 withCredentials,表示包含驗證資訊。

前端的呼叫程式準備好了以後,我們需要對後端的 http 網路存取權限進行設定,rest server 與呼叫端屬於 cross domain 的呼叫,因此需要設置 CorsFilter,如下:

@Component
public class CorsFilter extends OncePerRequestFilter
{

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException
    {
        response.addHeader("Access-Control-Allow-Origin", "http://192.168.0.100:8080");
        response.addHeader("Access-Control-Allow-Methods", "HEAD, GET, POST, OPTIONS");
        response.addHeader("Access-Control-Allow-Headers","Authorization, Content-Type, Origin, Accept");
        response.addHeader("Access-Control-Max-Age", "1800");
        response.addHeader("Access-Control-Allow-Credentials", "true");


        filterChain.doFilter(request, response);
    }
}

rest server 使用 spring-boot 製作,傳統建立 filter 時需要在維護 web.xml,在這裡只要註記為 @Component,且 extends OncePerRequestFilter,服務啟動時 spring 會自動配置 filter,透過設置 Access-Control-* 的相關屬性,我們可以限制對 server 的存取限制。

接著,我們需要定義 Security Config:

@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/test/sendMsg").hasRole("USER")
                .anyRequest().authenticated();
        http.httpBasic();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder authManagerBuilder) throws Exception {
        authManagerBuilder.inMemoryAuthentication()
                .withUser("user").password("password").roles("USER");
    }
}

configure(HttpSecurity http) 我們定義了 /test/sendMsg 的存取者必須要有 USER 的權限,且驗證方式使用 Basic;在 configure(AuthenticationManagerBuilder authManagerBuilder) 定義使用者帳號。

如此一來,程式算是準備好,我們可以利用 curl 指令來驗證:

curl --url http://localhost:8080/test/sendMsg -H 'Authorization: Basic dXNlcjpwYXNzd29yZA=='

可以正常取得 response,但是一旦我們透過 ajax 的方式存取,總會跳出下面的畫面:

enter image description here

這是我們不想要的,且與程式邏輯不符,畢竟我們在 CorsFilter 定義了允許 Authorization 的 header 卻沒有通過驗證,並且在 curl 的模式下是可正常存取。

且既然是提供給第三方軟體呼叫,對使用者而言,一定不知道遠端 rest server 的存取密碼,為了跳過這個帳號密碼驗證步驟,查了相關處理程式 BasicAuthenticationEntryPoint 的原始碼說明:

BasicAuthenticationEntryPoint.java

Once a user agent is authenticated using BASIC authentication, logout requires that the browser be closed or an unauthorized (401) header be sent.

也就是說預設的處理方式,一律會回傳 unauthorized (401),處理的原始碼如下:


public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException, ServletException {
response.addHeader("WWW-Authenticate", "Basic realm=\"" + realmName + "\"");
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}

所以無論你怎麼調整 CorsFilter 或是 ajax request 參數,就是沒有辦法通過 http 驗證,總是跳出 dialog 需要驗證帳號與密碼,檢查 request method 雖然設置的是 GET 但總是傳出 OPTIONS

至於為什麼,可參考下列文章:

jQuery, CORS, JSON (without padding) and authentication issues

文中提到:

Basically, because I need authentication, the GET request is sending an Authorization header. However, this is not a “simple” header, and so the browser is sending a pre-flight request (the OPTIONS). This preflight request doesn’t have any authentication, though, and so the server was rejecting it. The “solution” was to set the server to let OPTIONS request not require authentication, and report an HTTP status of 200 to it.

Reference: http://www.kinvey.com/blog/item/61-kinvey-adds-cross-origin-resource-sharing-cors

也就是說,瀏覽器檢查有驗證資訊,就不是”簡單”的 http request,所以會先送出一個 pre-flight request,也就是 OPTIONS,該 OPTIONS 就不會有驗證資訊,以致於 server 就會拒絕請求,也就呼應為什麼 curl 的情況下可以正常請求,為了解決此問題,就必須要讓 server 能夠接受 OPTIONS 可包含 authentication 的 header 來進行驗證。

上述的說明在一開始看到時還不清楚處理邏輯,真要親身體驗才能了解,花了一天的時間的重覆嘗試,總算讓我找到解法,參考下列文章:

Basic Auth filter authenticates OPTIONS requests and breaks CORS

從上面關於瀏覽器對於”不簡單”的 request 的敘述,瀏覽器的處理流程會是:

  1. 先送出一個 OPTIONS 的 request 進行驗證
  2. 如果 OPTIONS 的 request 不是回傳 401 UNAUTHORIZED 會接著送出在 ajax 定義的 http mathod 的 request

會這樣處理,是因為瀏覽器預設是一旦檢查第一步驟沒有通過,就會跳出 dialog 請使用者輸入帳號密碼(開啟 withCredentials 的前提下),一旦通過,就會將驗證資訊存於 cookies,接著進入第二步驟時,會將已存在 cookies 的驗證資訊加入到第二步驟的 header,之後再重覆請求時就會一直有效,直到 cookies 過期。

有這樣的認知後,在看上面連結中程式部分的解法時就比較能夠理解。

透過繼承 BasicAuthenticationEntryPoint 覆寫 commence,判斷如果是 Preflight 依據傳入的 http method 是否為 OPTIONS 來判斷,若是,則回傳 HttpServletResponse.SC_NO_CONTENT

if(isPreflight(request)){
    response.setStatus(HttpServletResponse.SC_NO_CONTENT);

因為 OPTIONS 只是瀏覽器為了驗證進行的 request,並不是我們主要的 request 所以將其設為 SC_NO_CONTENT(204),沒有內容,但沒有失敗,一旦瀏覽器接收到沒有失敗的情形,就會進行主要的 request 請求,此時主要的 request 就會包含 Authentication 的資訊,接著看下面一個判斷式。

if (isRestRequest(request)) {
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");

再來,這邊限制了如果有 X-Requested-With ,XMLHttpRequest 這樣的 header 就直接回傳 SC_UNAUTHORIZED(401), 有沒有必要,就看服務的性質與需求,可以針對各種狀況,設定不允許存取的條件,最後其他狀況就走一般的處理模式了,完整的程式碼如下:

public class AjaxAwareLoginUrlAuthenticationEntryPoint extends
        BasicAuthenticationEntryPoint {

    private static final RequestMatcher requestMatcher = new ELRequestMatcher(
            "hasHeader('X-Requested-With','XMLHttpRequest')");

    public CustomAuthenticationEntryPoint() {
        super();
    }

    public CustomAuthenticationEntryPoint(String realmName) {
        setRealmName(realmName);
    }

    @Override
    public void commence(final HttpServletRequest request, final HttpServletResponse response, final AuthenticationException authException) throws IOException, ServletException {

        if(isPreflight(request)){
            response.setStatus(HttpServletResponse.SC_NO_CONTENT);
        } else if (isRestRequest(request)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
        } else {
            super.commence(request, response, authException);
        }
    }

    /**
     * Checks if this is a X-domain pre-flight request.
     * @param request
     * @return
     */
    private boolean isPreflight(HttpServletRequest request) {
        return "OPTIONS".equals(request.getMethod());
    }

    /**
     * Checks if it is a rest request
     * @param request
     * @return
     */
    protected boolean isRestRequest(HttpServletRequest request) {
        return requestMatcher.matches(request);
    }
}

一旦定義好 AuthenticationEntryPoint 在文章中的使用的方式是用 grails 的語法與定義檔:

Open resources.groovy and add the following lines:

beans = {

    basicAuthenticationEntryPoint(CustomAuthenticationEntryPoint) { bean ->
        realmName = 'Your Realm'
    }
}

spring boot 的情形,我們只要修改一開始建立的 SecuityConfig:

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

    AjaxAwareLoginUrlAuthenticationEntryPoint ajaxAwareLoginUrlAuthenticationEntryPoint = new AjaxAwareLoginUrlAuthenticationEntryPoint();
    ajaxAwareLoginUrlAuthenticationEntryPoint.setRealmName("testAp");
    http.httpBasic().authenticationEntryPoint(ajaxAwareLoginUrlAuthenticationEntryPoint);

}

新增客製的 ajaxAwareLoginUrlAuthenticationEntryPoint,設置 RealmName,並且加入 httpBasic().authenticationEntryPoint 即可,省去繁瑣的 xml 定義,程式更加的精簡。

實際執行,我們可以看到,一次 ajax 呼叫,有兩個 request:

enter image description here

且第一次呼叫的 http method 為 OPTIONS,http code 為 204,第二次才是主要的 ajax request,在看下圖,為 header 資訊:

enter image description here

可以看到 Authorization 已有存在 header,且 request 正常,已有正確執行 rest server 的服務。

這問題著實困擾了我好久,對於不是 IT 背景的我,理解花了好多時間,不過也總算對網路安全,還有驗證的方式,以及瀏覽器對於安全性驗證的處理邏輯有一定的認知,不過畢竟是新鮮的理解,如果有說錯還請不吝指教。

張貼留言