WCF BasicHttpBinding 加密傳輸與身分驗證

這篇筆記要記的是,在寫 WCF 4 用戶端程式來呼叫某個第三方 Java web service 時碰到的一些狀況與問題排除過程。

已知線索:
  • 欲呼叫的 Java web service 無公開的 WSDL 網址,但有提供一個 WSDL 檔案。
  • 須使用 HTTPS 加密協定。
  • SOAP header 裡面要指定 username 和 password。
  • 已經用 SoapUI 測試過,確定可以成功呼叫 web service。(SoapUI 是好物!)

底下是呼叫該 web service 的 getFoo 方法時,SoapUI 產生的 SOAP 訊息:

<soapenv:Envelope xmlns:wsa="http://www.w3.org/2005/08/addressing" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
                  xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/" 
                  xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ejb="http://ejb.services.qoo">
  <soapenv:Header xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
    <wsse:Security>
      <wsse:UsernameToken Id="http://xmethods.net/xspace">
        <wsse:Username>Michael</wsse:Username>
        <wsse:Password>guesswhat</wsse:Password>
      </wsse:UsernameToken>
    </wsse:Security>
    <wsa:To>https://target.service.company.com:123/qoo/QooServices</wsa:To>
    <wsa:Action>getFoo</wsa:Action>
    <wsa:MessageID>uuid:9a6DA6FF-011C-4000-E000-5E3A0A244C97</wsa:MessageID>
  </soapenv:Header>

  <soapenv:Body>
    <ejb:getFoo>
      <getFooRequest>
        <fooId>XYZ</fooId>
      </getFooRequest>
    </ejb:getFoo>
  </soapenv:Body>
</soapenv:Envelope>

一開始,我先寫個 Windows Forms 程式來做個簡單測試,使用 WSHttpBinding,結果伺服器總是傳回 HTTP 500 錯誤。在嘗試錯誤的過程中,我寫了一個方法,直接用 HttpClient 把 SOAP 內容透過 HTTP POST 的方式送給 server,並取得回應結果。程式碼如下:

private void btnPostSoap_Click(object sender, EventArgs e)
{
    string fname = Path.GetDirectoryName(Application.ExecutablePath) + @"\SoapSamples\TestSoap.xml";
    string soap = File.ReadAllText(fname);
    HttpContent content = new StringContent(soap);
    HttpClient client = new HttpClient();
    string endPoint = "https://target.service.company.com:123/qoo/QooServices";
    client.PostAsync(endPoint, content).ContinueWith(task =>
    {
        task.Result.Content.ReadAsStringAsync().ContinueWith(t =>
        {
            var aDelegate = new Action&ltlstring>(UpdateUI);
            txtResult.Invoke(aDelegate, t.Result);
        });
    });
}

private void UpdateUI(string result)
{
    txtResult.Text = result;
}

當我將 SoapUI 產生的 SOAP 內容貼到 TestSoap.xml 檔案中,然後執行上述方法時,可順利呼叫 web service 方法並取得回傳結果。但如果用 WCF 類別來呼叫 web service 就只得到 HTTP 500 錯誤,例如:WCF Security processor was unable to find a security header in the message。

於是土法煉鋼:用 Fiddler 攔截用戶端發出的 HTTPS 請求(是的,Fiddler 可以解開 HTTPS 加密封包),取得 WCF 產生的 SOAP 訊息,然後與 SoapUI 產生的 SOAP 訊息比對差異,並逐一手動修改 WCF 產生的 SOAP 訊息,餵給上述方法測試,找出問題癥結。
註:要取得 SOAP 封包內容,除了 Fiddler,也可以用 WCF 的訊息記錄(message logging)功能:Configuring Message Logging。只是開啟這功能得在組態檔裡面加一堆東西,若只是臨時想要查看封包內容,Fiddler 還是方便得多。
SOAP 1.1 vs 1.2

我的主要錯誤是沒注意到那個 Java web service 其實是 SOAP 1.1 的規格,不是 SOAP 1.2。我一開始看到 SoapUI 對 WSDL 解析的結果是 SOAP 1.2(如下圖),便不疑有他(畢竟 SoapUI 可順利呼叫 web service)。


然而經過比對 SoapUI 發出的封包與 WCF 程式發出的封包內容,發現它們的 SOAP envelope 使用的 namespace 不一樣:
  • SoapUI 封包:xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"  ==> 這是 SOAP 1.1
  • WCF 程式封包: xmlns:s="http://www.w3.org/2003/05/soap-envelope"  ==> 這是 SOAP 1.2
用了錯誤的 envelope namespace 是我的測試程式一直卡住的主要原因。反覆測試發現,只要這個 namespace 指向 http://schemas.xmlsoap.org/soap/envelope/,便可呼叫成功。若使用了不正確的 namespace,無論 SOAP 封包內的其餘內容正確與否,伺服器都會傳回錯誤。

BasicHttpBinding, WSHttpBinding, CustomBinding

WCF 的 WSHttpBinding 內定採用 SOAP 1.2,所以產生出來的 SOAP 訊息會固定使用 SOAP 1.2 的 envelope namespace。BasicHttpBinding 則是使用 SOAP 1.1,所以改用 BasicHttpBinding 應該就行了。

底下範例是不使用組態檔,完全以程式碼的方式來呼叫 web service:

void CallServicesByCode()
{
    string url = "https://target.service.company.com:123/qoo/QooServices";
    var endPoint = new EndpointAddress(url);

    var binding = new BasicHttpBinding(BasicHttpSecurityMode.TransportWithMessageCredential);
    binding.Security.Message.ClientCredentialType = BasicHttpMessageCredentialType.UserName;
    binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.None;

    var client = new Qoo.QooServicesClient(binding, endPoint);
    client.ClientCredentials.UserName.UserName = "Michael";
    client.ClientCredentials.UserName.Password = "guesswhat";

    var req = new Qoo.GetFooRequest();
    req.fooId = "1234";
    var resp = client.getFoo(req);
    txtResult = resp.fooName;
}
註:由於對方的 web service 要求使用 HTTPS 加密傳輸,而且每一個 SOAP 訊息封包都必須包含 username 和 password,所以程式在設定 WCF 傳輸安全性時,是使用 TransportWithMessageCredential 模式。

如此一來,SOAP envelope 的 namespace 就正確了。但又有個怪現象,即 Fiddler 顯示伺服器端傳回 HTTP 200 OK,而且 response body 的確有傳回正確的結果,但應用程式卻拋出 MessageSecurityException:

System.ServiceModel.Security.MessageSecurityException: Security processor was unable to find a security header in the message. This might be because the message is an unsecured fault or because there is a binding mismatch between the communicating parties.   This can occur if the service is configured for security and the client is not using security.

也就是說,伺服器端沒問題,反而是 WCF 對 response 的內容有意見了。

Timestamp

Rick Strahl 指出,原因在於 WCF 預期回傳的 response header 裡面也要有時間戳記,若沒有發現時間戳記,就會拋出例外。可是對方是 Java web service,又不是 WCF service,header 裡面自然不一定有時間戳記。
補充說明 1:呼叫此 web service 時,WCF 產生的 request header 裡面有時間戳記,試過有無皆可--對此 web service 而言。
補充說明 2:當 WCF binding 使用 message-layer security 時,時間戳記會被自動加入 SOAP envelope,以確保訊息傳遞的時效,避免訊息重發(message replay)攻擊。

解決方法是改用 CustomBinding,並將 SecurityBindingElement 的 IncludeTimestamp 屬性設為 false 便大功告成。

修改後的程式碼如下(同樣不使用組態檔):

void CallServicesByCode()
{
    string url = "https://target.service.company.com:123/qoo/QooServices";
    var endPoint = new EndpointAddress(url);

    var binding = new BasicHttpBinding(BasicHttpSecurityMode.TransportWithMessageCredential);
    binding.Security.Message.ClientCredentialType = BasicHttpMessageCredentialType.UserName;
    binding.Security.Transport.ClientCredentialType = HttpClientCredentialType.None;

    // 利用既有的 BasicHttpBinding 物件來建立 binding 元素集合,然後修改集合中的元素
    var bindingElements = binding.CreateBindingElements();
    var secbe = bindingElements.Find<System.ServiceModel.Channels.SecurityBindingElement>();
    secbe.IncludeTimestamp = false;
    secbe.LocalServiceSettings.DetectReplays = false;
    secbe.LocalClientSettings.DetectReplays = false;

    // 建立 CustomBinding 物件,使用剛才的 binding 元素集合.
    var customBinding = new System.ServiceModel.Channels.CustomBinding(bindingElements);

    var client = new Qoo.QooServicesClient(customBinding, endPoint); // 這裡改用 customBinding 物件.
    client.ClientCredentials.UserName.UserName = "Michael";
    client.ClientCredentials.UserName.Password = "guesswhat";

    var req = new Qoo.GetFooRequest();
    req.fooId = "1234";
    var resp = client.getFoo(req);
    txtResult = resp.fooName;
}

Happy coding!

參考資料
延伸閱讀

Post Comments

技術提供:Blogger.