Web Service 那点事儿(3)—— SOAP 及其安全控制

经过上一篇文章,相信您已经学会了如何使用 CXF 开发基于 SOAP 的 WS 了。或许您目前对于底层原理性的东西还不太理解,心中不免会有些疑问:html

什么是 WSDL?java

什么是 SOAP?spring

如何能让 SOAP 更加安全?shell

我将努力经过本文,针对以上问题,让您获得一个满意的答案。apache

还等什么呢?就从 WSDL 开始吧!浏览器

WSDL 的全称是 Web Services Description Language(Web 服务描述语言),用于描述 WS 的具体内容。安全

当您成功发布一个 WS 后,就能在浏览器中经过一个地址查看基于 WSDL 文档,它是一个基于 XML 的文档。一个典型的 WSDL 地址以下:服务器

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

注意: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 服务进行安全控制?咱们下次再见!

相关文章
相关标签/搜索