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!

參考資料
延伸閱讀

14 則留言:

  1. 您好 我有一些Fiddler Script語法問題
    不知道可否請教您?

    回覆刪除
  2. 這個我不熟耶,不好意思...

    回覆刪除
  3. 您好,
    拜讀大作後,深感佩服!
    小弟為此已經跟JAVA SERVER端攻防月餘,
    請問大大是否有反方向的經驗?
    也就是我們利用JAVA PROGRAMMER提供的WSDL/XSD檔案製作WS給JAVA SERVER使用的方法,謝謝。

    回覆刪除
  4. 使用 Java 端提供的 WSDL 檔案製作給對方用的 Web service 我沒有試過,最近倒是有試過對方提供一個 .NET 2.0 web service 的 WSDL,要我寫一個一模一樣的給另一個 ASP.NET 2.0 web app 使用。剛開始用 WCF service,發現對方無法順利呼叫,還得跟組態檔奮戰,太麻煩了,後來乾脆直接寫成傳統 web service,因為對方不是 .NET 3.5/WCF,所以用傳統 .NET 2.0 web service 很快就順利接上。參考看看囉!

    回覆刪除
  5. 您好,
    我現在做的方案架構類似您文章 "攔截 WCF 服務往返的 SOAP 訊息 " ,
    'http://huan-lin.blogspot.com/2013/03/intercepting-wcf-soap-messages.html'內第一張圖示,由Client App傳值給MyWebApp到DB抓資料組成XML給Third-party,Third-party會回覆結果給我,但是現在問題是Thirt-party要求我用他們給的憑證作WS-Security加密。
    當我trace用aspx傳值給svc,再由svc request給Third-party時,我一直得到500伺服器內部錯誤,trace時則是得到"Incoming message does not contain required Security header",我有按照您這篇文章檢查我的程式,但是還是一樣,請問是否我哪裡遺漏了呢?
    謝謝!

    回覆刪除
  6. Hi Captain,
    你有把 SOAP 封包攔下來查看裡面的內容嗎?SOAP 內容是否包含 third-party 要求的 security header?
    你說「Thirt-party要求我用他們給的憑證作WS-Security加密」,這個部份我不太懂:這會影響程式的寫法嗎?
    我覺得你也可以把問題張貼到 MSDN 論壇,讓多隻眼睛幫你一起看。畢竟留言板不適合貼程式碼和圖片,不容易把完整的問題情境描述清楚。

    回覆刪除
  7. 老師 您好,

    「Thirt-party要求我用他們給的憑證作WS-Security加密」的意思是,Third-party給我一個.jdk的憑證,要求我使用此憑證內的三種鑰匙進行SOAP/XML的cert及encrypt。
    我有在MSDN 提出問題,但是無法得到解答,
    網址為 "http://social.msdn.microsoft.com/Forums/vstudio/en-US/53d68de3-8f15-4cf1-ae50-5fa020701a69/unable-connect-svc-in-iis60"

    之前由於將aspx與svc放在同一專案,且使用VS ASP.NET進行瀏覽可以成功,我用FIDDLER抓到Header有將訊息傳送給Third-party,但是當我將aspx及svc放上IIS時,改了endpoint至server上的svc連結後,卻一直收到錯誤訊息,不知道是哪裡出了問題。

    若老師需要更多的資訊,是否方便給我email,我傳訊息給老師呢?
    謝謝!

    回覆刪除
  8. 老師 您好,

    更新測試的狀況,看起來是使用VS ASPNET進行DEBUG時,可以抓的到憑證,但是將svc放到IIS之後,卻無法抓到憑證,我也將該憑證的私鑰授權給IIS的匿名存取的使用者帳戶了,為什麼還是抓不到憑證呢?
    打擾之處,敬請見諒,感恩!

    謝謝!

    回覆刪除
  9. Hi Captain,
    所以你的 message body 還要利用第三方提供的 SSL 憑證來加密,是這個意思嗎?
    看起來,你碰到的狀況比我先前碰到的還複雜一些,光憑目前我看到的線索,不太好推測原因。要是我這邊有一個可以重現問題的 sample project 來測試就好了。
    不知道你有沒有看過這帖: http://stackoverflow.com/questions/14740369/wcf-soap-1-1-and-ws-security-1-0-client-certificate-transport-auth-service-cer
    其問題描述似乎與你的狀況有幾分相似。
    我的 email 是 huanlin.tsai@gmail.com。

    回覆刪除
  10. 老師 您好:

    謝謝您的資訊,現在測試上最奇怪的問題是,使用VS執行就可以順利開啟,但是一旦將svc安裝至IIS上,使用aspx呼叫就不行....

    我有寄一封email至您的信箱,裡面有較詳細的說明,打擾之處,敬請見諒,
    謝謝!

    回覆刪除
  11. 老師 您好:

    非常感謝您的提示,果然還是跟IIS的設定有關係,APP POOL的IDENTITY一改為LOCAL SYSTEM後,就可以連線成功了!
    但是我有另一個Third-party給我的WSDL,是要我開發Service端程式,使用加入服務參考後,Reference.vb卻沒有資料,我有再把WSDL寄給您了,煩請指導。
    感謝!

    回覆刪除
  12. 已回信。簡單地說,你那 WSDL 檔案因為某些語法格式的問題,SvcUtil 無法正確處理,恐怕只能使用 Add Web Reference 的方式來產生 proxy class 了。

    回覆刪除
  13. 老師 您好

    SOAP header 裡面除了在 Security 中指定 username 和 password 外,另一種是使用 憑證 的方式處理,像是要指定 BinarySecurityToken、Signature ... 等,不知老師您這邊是否曾經用過呢?這邊卡了好幾個月了 Orz

    希望您能不吝指導一下方向

    回覆刪除
  14. 這種方式我就沒用過了,能夠提供的資訊恐怕很有限。所以還是建議把問題的來龍去脈貼在公共論壇,例如 MSDN 或 stackoverflow.com,比較容易獲得解答。

    回覆刪除

技術提供:Blogger.
回頂端⬆️