Web Service 那點事兒(3)—— SOAP 及其安全控制 原 薦

通過上一篇文章,相信您已經學會了如何使用 CXF 開發基於 SOAP 的 WS 了。或許您目前對於底層原理性的東西還不太理解,心中難免會有些疑問:

什麼是 WSDL?

什麼是 SOAP?

如何能讓 SOAP 更加安全?

我將努力通過本文,針對以上問題,讓您得到一個滿意的答案。

還等什麼呢?就從 WSDL 開始吧!

WSDL 的全稱是 Web Services Description Language(Web 服務描述語言),用於描述 WS 的具體內容。

當您成功發佈一個 WS 後,就能在瀏覽器中通過一個地址查看基於 WSDL 文檔,它是一個基於 XML 的文檔。一個典型的 WSDL 地址如下:

http://localhost:8080/ws/soap/hello?wsdl

注意:WSDL 地址必須帶有一個 wsdl 參數。

在瀏覽器中,您會看到一個標準的 XML 文檔:

wsdl

其中,definitions 是 WSDL 的根節點,它包括兩個重要的屬性:

  1. name:WS 名稱,默認爲“WS 實現類 + Service”,例如:HelloServiceImplService
  2. targetNamespace:WS 目標命名空間,默認爲“WS 實現類對應包名倒排後構成的地址”,例如:http://soap_spring_cxf.ws.demo/

提示:可以在 javax.jws.WebService 註解中配置以上兩個屬性值,但這個配置一定要在 WS 實現類上進行,WS 接口類只需標註一個 WebService 註解即可。

在 definitions 這個根節點下,有五種類型的子節點,它們分別是:

  1. types:描述了 WS 中所涉及的數據類型
  2. portType:定義了 WS 接口名稱(endpointInterface)及其操作名稱,以及每個操作的輸入與輸出消息
  3. message:對相關消息進行了定義(供 types 與 portType 使用)
  4. binding:提供了對 WS 的數據綁定方式
  5. service:WS 名稱及其端口名稱(portName),以及對應的 WSDL 地址

其中包括了兩個重要信息:

  1. portName:WS 的端口名稱,默認爲“WS 實現類 + Port”,例如:HelloServiceImplPort
  2. endpointInterface:WS 的接口名稱,默認爲“WS 實現類所實現的接口”,例如:HelloService

提示:可在 javax.jws.WebService 註解中配置 portName 與 endpointInterface,同樣必須在 WS 實現類上配置。

如果說 WSDL 是用於描述 WS 是什麼,那麼 SOAP 就用來表示 WS 裏有什麼。

其實 SOAP 就是一個信封(Envelope),在這個信封裏包括兩個部分,一是頭(Header),二是體(Body)。用於傳輸的數據都放在 Body 中了,一些特殊的屬性需要放在 Header 中(下面會看到)。

一般情況下,將需要傳輸的數據放入 Body 中,而 Header 是沒有任何內容的,看起來整個 SOAP 消息是這樣的:

inbound

可見,HTTP 請求的 Request Header 與 Request Body,這正好與 SOAP 消息的結構有着異曲同工之妙!

看到這裏,您或許會有很多疑問:

  1. WS 不應該讓任何人都可以調用的,這樣太不安全了,至少需要做一個身份認證吧?
  2. 爲了避免第三方惡意程序監控 WS 調用過程,能否對 SOAP Body 中的數據進行加密呢?
  3. SOAP Header 中究竟可存放什麼東西呢?

沒錯!這就是我們今天要展開討論的話題 —— 基於 SOAP 的安全控制。

在 WS 領域有一個很強悍的解決方案,名爲 WS-Security,它僅僅是一個規範,在 Java 業界裏有一個很權威的實現,名爲 WSS4J

下面我將一步步讓您學會,如何使用 Spring + CXF + WSS4J 實現一個安全可靠的 WS 調用框架。

其實您需要做也就是兩件事情:

  1. 認證 WS 請求
  2. 加密 SOAP 消息

怎樣對 WS 進行身份認證呢?可使用如下解決方案:

1. 基於用戶令牌的身份認證

第一步:添加 CXF 提供的 WS-Security 的 Maven 依賴

<!-- lang: xml -->
<dependency>
    <groupId>org.apache.cxf</groupId>
    <artifactId>cxf-rt-ws-security</artifactId>
    <version>${cxf.version}</version>
</dependency>

其實底層實現還是 WSS4J,CXF 只是對其做了一個封裝而已。

第二步:完成服務端 CXF 相關配置

<!-- lang: xml -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:cxf="http://cxf.apache.org/core"
       xmlns:jaxws="http://cxf.apache.org/jaxws"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
       http://cxf.apache.org/core
       http://cxf.apache.org/schemas/core.xsd
       http://cxf.apache.org/jaxws
       http://cxf.apache.org/schemas/jaxws.xsd">

    <bean id="wss4jInInterceptor" class="org.apache.cxf.ws.security.wss4j.WSS4JInInterceptor">
        <constructor-arg>
            <map>
                <!-- 用戶認證(明文密碼) -->
                <entry key="action" value="UsernameToken"/>
                <entry key="passwordType" value="PasswordText"/>
                <entry key="passwordCallbackRef" value-ref="serverPasswordCallback"/>
            </map>
        </constructor-arg>
    </bean>

    <jaxws:endpoint id="helloService" implementor="#helloServiceImpl" address="/soap/hello">
        <jaxws:inInterceptors>
            <ref bean="wss4jInInterceptor"/>
        </jaxws:inInterceptors>
    </jaxws:endpoint>

    <cxf:bus>
        <cxf:features>
            <cxf:logging/>
        </cxf:features>
    </cxf:bus>

</beans>

首先定義了一個基於 WSS4J 的攔截器(WSS4JInInterceptor),然後通過 jaxws:inInterceptors 將其配置到 helloService 上,最後使用了 CXF 提供的 Bus 特性,只需要在 Bus 上配置一個 logging feature,就可以監控每次 WS 請求與響應的日誌了。

注意:這個 WSS4JInInterceptor 是一個 InInterceptor,表示對輸入的消息進行攔截,同樣還有 OutInterceptor,表示對輸出的消息進行攔截。由於以上是服務器端的配置,因此我們只需要配置 InInterceptor 即可,對於客戶端而言,我們可以配置 OutInterceptor(下面會看到)。

有必要對以上配置中,關於 WSS4JInInterceptor 的構造器參數做一個說明。

  • action = UsernameToken:表示使用基於“用戶名令牌”的方式進行身份認證
  • passwordType = PasswordText:表示密碼以明文方式出現
  • passwordCallbackRef = serverPasswordCallback:需要提供一個用於密碼驗證的回調處理器(CallbackHandler)

以下便是 ServerPasswordCallback 的具體實現:

<!-- lang: java -->
package demo.ws.soap_spring_cxf_wss4j;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.UnsupportedCallbackException;
import org.apache.wss4j.common.ext.WSPasswordCallback;
import org.springframework.stereotype.Component;

@Component
public class ServerPasswordCallback implements CallbackHandler {

    private static final Map<String, String> userMap = new HashMap<String, String>();

    static {
        userMap.put("client", "clientpass");
        userMap.put("server", "serverpass");
    }

    @Override
    public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
        WSPasswordCallback callback = (WSPasswordCallback) callbacks[0];

        String clientUsername = callback.getIdentifier();
        String serverPassword = userMap.get(clientUsername);

        if (serverPassword != null) {
            callback.setPassword(serverPassword);
        }
    }
}

可見,它實現了 javax.security.auth.callback.CallbackHandler 接口,這是 JDK 提供的用於安全認證的回調處理器接口。在代碼中提供了兩個用戶,分別是 client 與 server,用戶名與密碼存放在 userMap 中。這裏需要將 JDK 提供的 javax.security.auth.callback.Callback 轉型爲 WSS4J 提供的 org.apache.wss4j.common.ext.WSPasswordCallback,在 handle 方法中實現對客戶端密碼的驗證,最終需要將密碼放入 callback 對象中。

第三步:完成客戶端 CXF 相關配置

<!-- lang: xml -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:jaxws="http://cxf.apache.org/jaxws"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
       http://www.springframework.org/schema/context
       http://www.springframework.org/schema/context/spring-context-4.0.xsd
       http://cxf.apache.org/jaxws
       http://cxf.apache.org/schemas/jaxws.xsd">

    <context:component-scan base-package="demo.ws"/>

    <bean id="wss4jOutInterceptor" class="org.apache.cxf.ws.security.wss4j.WSS4JOutInterceptor">
        <constructor-arg>
            <map>
                <!-- 用戶認證(明文密碼) -->
                <entry key="action" value="UsernameToken"/>
                <entry key="user" value="client"/>
                <entry key="passwordType" value="PasswordText"/>
                <entry key="passwordCallbackRef" value-ref="clientPasswordCallback"/>
            </map>
        </constructor-arg>
    </bean>

    <jaxws:client id="helloService"
                  serviceClass="demo.ws.soap_spring_cxf_wss4j.HelloService"
                  address="http://localhost:8080/ws/soap/hello">
        <jaxws:outInterceptors>
            <ref bean="wss4jOutInterceptor"/>
        </jaxws:outInterceptors>
    </jaxws:client>

</beans>

注意:這裏使用的是 WSS4JOutInterceptor,它是一個 OutInterceptor,使客戶端對輸出的消息進行攔截。

WSS4JOutInterceptor 的配置基本上與 WSS4JInInterceptor 大同小異,這裏需要提供客戶端的用戶名(user = client),還需要提供一個客戶端密碼回調處理器(passwordCallbackRef = clientPasswordCallback),代碼如下:

<!-- lang: java -->
package demo.ws.soap_spring_cxf_wss4j;

import java.io.IOException;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.UnsupportedCallbackException;
import org.apache.wss4j.common.ext.WSPasswordCallback;
import org.springframework.stereotype.Component;

@Component
public class ClientPasswordCallback implements CallbackHandler {

    @Override
    public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
        WSPasswordCallback callback = (WSPasswordCallback) callbacks[0];
        callback.setPassword("clientpass");
    }
}

在 ClientPasswordCallback 無非設置客戶端用戶的密碼,其它的什麼也不用做了。客戶端密碼只能通過回調處理器的方式來提供,而不能在 Spring 中配置。

第四步:調用 WS 並觀察控制檯日誌

部署應用並啓動 Tomcat,再次調用 WS,此時會在 Tomcat 控制檯裏的 Inbound Message 中看到如下 Payload:

inbound-1

可見,在 SOAP Header 中提供了 UsernameToken 的相關信息,但 Username 與 Password 都是明文的,SOAP Body 也是明文的,這顯然不是最好的解決方案。

如果您將 passwordType 由 PasswordText 改爲 PasswordDigest(服務端與客戶端都需要做同樣的修改),那麼就會看到一個加密過的密碼:

inbound-2

除了這種基於用戶名與密碼的身份認證以外,還有一種更安全的身份認證方式,名爲“數字簽名”。

2. 基於數字簽名的身份認證

數字簽名從字面上理解就是一種基於數字的簽名方式。也就是說,當客戶端發送 SOAP 消息時,需要對其進行“簽名”,來證實自己的身份,當服務端接收 SOAP 消息時,需要對其簽名進行驗證(簡稱“驗籤”)。

在客戶端與服務端上都有各自的“密鑰庫”,這個密鑰庫裏存放了“密鑰對”,而密鑰對實際上是由“公鑰”與“私鑰”組成的。當客戶端發送 SOAP 消息時,需要使用自己的私鑰進行簽名,當客戶端接收 SOAP 消息時,需要使用客戶端提供的公鑰進行驗籤。

因爲有請求就有相應,所以客戶端與服務端的消息調用實際上是雙向的,也就是說,客戶端與服務端的密鑰庫裏所存放的信息是這樣的:

  • 客戶端密鑰庫:客戶端的私鑰(用於簽名)、服務端的公鑰(用於驗籤)
  • 服務端密鑰庫:服務端的私鑰(用於簽名)、客戶端的公鑰(用於驗籤)

記住一句話:使用自己的私鑰進行簽名,使用對方的公鑰進行驗籤。

可見生成密鑰庫是我們要做的第一件事情。

第一步:生成密鑰庫

現在您需要創建一個名爲 keystore.bat 的批處理文件,其內容如下:

<!-- lang: shell -->
@echo off

keytool -genkeypair -alias server -keyalg RSA -dname "cn=server" -keypass serverpass -keystore server_store.jks -storepass storepass
keytool -exportcert -alias server -file server_key.rsa -keystore server_store.jks -storepass storepass
keytool -importcert -alias server -file server_key.rsa -keystore client_store.jks -storepass storepass -noprompt
del server_key.rsa

keytool -genkeypair -alias client -dname "cn=client" -keyalg RSA -keypass clientpass -keystore client_store.jks -storepass storepass
keytool -exportcert -alias client -file client_key.rsa -keystore client_store.jks -storepass storepass
keytool -importcert -alias client -file client_key.rsa -keystore server_store.jks -storepass storepass -noprompt
del client_key.rsa

在以上這些命令中,使用了 JDK 提供的 keytool 命令行工具,關於該命令的使用方法,可點擊以下鏈接:

http://docs.oracle.com/javase/6/docs/technotes/tools/solaris/keytool.html

運行該批處理程序,將生成兩個文件:server_store.jks 與 client_store.jks,隨後將 server_store.jks 放入服務端的 classpath 下,將 client_store.jks 放入客戶端的 classpath 下。如果您在本機運行,那麼本機既是客戶端又是服務端。

第二步:完成服務端 CXF 相關配置

<!-- lang: xml -->
...
<bean id="wss4jInInterceptor" class="org.apache.cxf.ws.security.wss4j.WSS4JInInterceptor">
    <constructor-arg>
        <map>
            <!-- 驗籤(使用對方的公鑰) -->
            <entry key="action" value="Signature"/>
            <entry key="signaturePropFile" value="server.properties"/>
        </map>
    </constructor-arg>
</bean>
...

其中 action 爲 Signature,server.properties 內容如下:

<!-- lang: java -->
org.apache.ws.security.crypto.provider=org.apache.wss4j.common.crypto.Merlin
org.apache.ws.security.crypto.merlin.file=server_store.jks
org.apache.ws.security.crypto.merlin.keystore.type=jks
org.apache.ws.security.crypto.merlin.keystore.password=storepass

第三步:完成客戶端 CXF 相關配置

<!-- lang: xml -->
...
<bean id="wss4jOutInterceptor" class="org.apache.cxf.ws.security.wss4j.WSS4JOutInterceptor">
    <constructor-arg>
        <map>
            <!-- 簽名(使用自己的私鑰) -->
            <entry key="action" value="Signature"/>
            <entry key="signaturePropFile" value="client.properties"/>
            <entry key="signatureUser" value="client"/>
            <entry key="passwordCallbackRef" value-ref="clientPasswordCallback"/>
        </map>
    </constructor-arg>
</bean>
...

其中 action 爲 Signature,client.properties 內容如下:

<!-- lang: java -->
org.apache.ws.security.crypto.provider=org.apache.wss4j.common.crypto.Merlin
org.apache.ws.security.crypto.merlin.file=client_store.jks
org.apache.ws.security.crypto.merlin.keystore.type=jks
org.apache.ws.security.crypto.merlin.keystore.password=storepass

此外,客戶端同樣需要提供簽名用戶(signatureUser)與密碼回調處理器(passwordCallbackRef)。

第四步:調用 WS 並觀察控制檯日誌

inbound-3

可見,數字簽名確實是一種更爲安全的身份認證方式,但無法對 SOAP Body 中的數據進行加密,仍然是“world”。

究竟怎樣才能加密並解密 SOAP 消息中的數據呢?

3. SOAP 消息的加密與解密

WSS4J 除了提供簽名與驗籤(Signature)這個特性以外,還提供了加密與解密(Encrypt)功能,您只需要在服務端與客戶端的配置中稍作修改即可。

服務端:

<!-- lang: xml -->
...
<bean id="wss4jInInterceptor" class="org.apache.cxf.ws.security.wss4j.WSS4JInInterceptor">
    <constructor-arg>
        <map>
            <!-- 驗籤 與 解密 -->
            <entry key="action" value="Signature Encrypt"/>
            <!-- 驗籤(使用對方的公鑰) -->
            <entry key="signaturePropFile" value="server.properties"/>
            <!-- 解密(使用自己的私鑰) -->
            <entry key="decryptionPropFile" value="server.properties"/>
            <entry key="passwordCallbackRef" value-ref="serverPasswordCallback"/>
        </map>
    </constructor-arg>
</bean>
...

客戶端:

<!-- lang: xml -->
...
<bean id="wss4jOutInterceptor" class="org.apache.cxf.ws.security.wss4j.WSS4JOutInterceptor">
    <constructor-arg>
        <map>
            <!-- 簽名 與 加密 -->
            <entry key="action" value="Signature Encrypt"/>
            <!-- 簽名(使用自己的私鑰) -->
            <entry key="signaturePropFile" value="client.properties"/>
            <entry key="signatureUser" value="client"/>
            <entry key="passwordCallbackRef" value-ref="clientPasswordCallback"/>
            <!-- 加密(使用對方的公鑰) -->
            <entry key="encryptionPropFile" value="client.properties"/>
            <entry key="encryptionUser" value="server"/>
        </map>
    </constructor-arg>
</bean>
...

可見,客戶端發送 SOAP 消息時進行簽名(使用自己的私鑰)與加密(使用對方的公鑰),服務端接收 SOAP 消息時進行驗籤(使用對方的公鑰)與解密(使用自己的私鑰)。

現在您看到的 SOAP 消息應該是這樣的:

inbound-4

可見,SOAP 請求不僅簽名了,而且還加密了,這樣的通訊更加安全可靠。

但是還存在一個問題,雖然 SOAP 請求已經很安全了,但 SOAP 響應卻沒有做任何安全控制,看看下面的 SOAP 響應吧:

outbound

如何才能對 SOAP 響應進行簽名與加密呢?相信您一定有辦法做到,不妨親自動手試一試吧!

4. 總結

本文的內容有些多,確實需要稍微總結一下:

  1. WSDL 是用於描述 WS 的具體內容的
  2. SOAP 是用於封裝 WS 請求與響應的
  3. 可使用“用戶令牌”方式對 WS 進行身份認證(支持明文密碼與密文密碼)
  4. 可使用“數字簽名”方式對 WS 進行身份認證
  5. 可對 SOAP 消息進行加密與解密

關於“SOAP 安全控制”也就這點事兒了,但關於“WS 那點事兒”還並沒有結束,因爲 RESTful Web Services 在等着您。如何發佈 REST 服務?如何對 REST 服務進行安全控制?我們下次再見!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章