通過上一篇文章,相信您已經學會了如何使用 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 文檔:
其中,definitions
是 WSDL 的根節點,它包括兩個重要的屬性:
- name:WS 名稱,默認爲“WS 實現類 + Service”,例如:HelloServiceImplService
- targetNamespace:WS 目標命名空間,默認爲“WS 實現類對應包名倒排後構成的地址”,例如:http://soap_spring_cxf.ws.demo/
提示:可以在 javax.jws.WebService
註解中配置以上兩個屬性值,但這個配置一定要在 WS 實現類上進行,WS 接口類只需標註一個 WebService 註解即可。
在 definitions 這個根節點下,有五種類型的子節點,它們分別是:
- types:描述了 WS 中所涉及的數據類型
- portType:定義了 WS 接口名稱(endpointInterface)及其操作名稱,以及每個操作的輸入與輸出消息
- message:對相關消息進行了定義(供 types 與 portType 使用)
- binding:提供了對 WS 的數據綁定方式
- service:WS 名稱及其端口名稱(portName),以及對應的 WSDL 地址
其中包括了兩個重要信息:
- portName:WS 的端口名稱,默認爲“WS 實現類 + Port”,例如:HelloServiceImplPort
- endpointInterface:WS 的接口名稱,默認爲“WS 實現類所實現的接口”,例如:HelloService
提示:可在 javax.jws.WebService
註解中配置 portName 與 endpointInterface,同樣必須在 WS 實現類上配置。
如果說 WSDL 是用於描述 WS 是什麼,那麼 SOAP 就用來表示 WS 裏有什麼。
其實 SOAP 就是一個信封(Envelope),在這個信封裏包括兩個部分,一是頭(Header),二是體(Body)。用於傳輸的數據都放在 Body 中了,一些特殊的屬性需要放在 Header 中(下面會看到)。
一般情況下,將需要傳輸的數據放入 Body 中,而 Header 是沒有任何內容的,看起來整個 SOAP 消息是這樣的:
可見,HTTP 請求的 Request Header 與 Request Body,這正好與 SOAP 消息的結構有着異曲同工之妙!
看到這裏,您或許會有很多疑問:
- WS 不應該讓任何人都可以調用的,這樣太不安全了,至少需要做一個身份認證吧?
- 爲了避免第三方惡意程序監控 WS 調用過程,能否對 SOAP Body 中的數據進行加密呢?
- SOAP Header 中究竟可存放什麼東西呢?
沒錯!這就是我們今天要展開討論的話題 —— 基於 SOAP 的安全控制。
在 WS 領域有一個很強悍的解決方案,名爲 WS-Security
,它僅僅是一個規範,在 Java 業界裏有一個很權威的實現,名爲 WSS4J。
下面我將一步步讓您學會,如何使用 Spring
+ CXF
+ WSS4J
實現一個安全可靠的 WS 調用框架。
其實您需要做也就是兩件事情:
- 認證 WS 請求
- 加密 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:
可見,在 SOAP Header 中提供了 UsernameToken 的相關信息,但 Username 與 Password 都是明文的,SOAP Body 也是明文的,這顯然不是最好的解決方案。
如果您將 passwordType 由 PasswordText 改爲 PasswordDigest(服務端與客戶端都需要做同樣的修改),那麼就會看到一個加密過的密碼:
除了這種基於用戶名與密碼的身份認證以外,還有一種更安全的身份認證方式,名爲“數字簽名”。
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 並觀察控制檯日誌
可見,數字簽名確實是一種更爲安全的身份認證方式,但無法對 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 消息應該是這樣的:
可見,SOAP 請求不僅簽名了,而且還加密了,這樣的通訊更加安全可靠。
但是還存在一個問題,雖然 SOAP 請求已經很安全了,但 SOAP 響應卻沒有做任何安全控制,看看下面的 SOAP 響應吧:
如何才能對 SOAP 響應進行簽名與加密呢?相信您一定有辦法做到,不妨親自動手試一試吧!
4. 總結
本文的內容有些多,確實需要稍微總結一下:
- WSDL 是用於描述 WS 的具體內容的
- SOAP 是用於封裝 WS 請求與響應的
- 可使用“用戶令牌”方式對 WS 進行身份認證(支持明文密碼與密文密碼)
- 可使用“數字簽名”方式對 WS 進行身份認證
- 可對 SOAP 消息進行加密與解密
關於“SOAP 安全控制”也就這點事兒了,但關於“WS 那點事兒”還並沒有結束,因爲 RESTful Web Services 在等着您。如何發佈 REST 服務?如何對 REST 服務進行安全控制?我們下次再見!