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

原创
2014/07/06 23:30
阅读数 2W

通过上一篇文章,相信您已经学会了如何使用 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 服务进行安全控制?我们下次再见!

展开阅读全文
打赏
19
124 收藏
分享
加载中

引用来自“John”的评论

您好,请问能不能共享整个项目的源代码?因为按照您的代码应用到自己程序上出现了一些bug。麻烦了。

引用来自“黄勇”的评论

http://git.oschina.net/huangyong/ws_demo
万分感谢!还有一个问题,如果不用公私钥,那应该怎么对soap消息以及soap响应加密呢?
2015/09/02 13:03
回复
举报

引用来自“John”的评论

您好,请问能不能共享整个项目的源代码?因为按照您的代码应用到自己程序上出现了一些bug。麻烦了。

引用来自“黄勇”的评论

http://git.oschina.net/huangyong/ws_demo
成功收藏~
2015/08/29 20:48
回复
举报
黄勇博主

引用来自“John”的评论

您好,请问能不能共享整个项目的源代码?因为按照您的代码应用到自己程序上出现了一些bug。麻烦了。
http://git.oschina.net/huangyong/ws_demo
2015/08/29 16:27
回复
举报
黄勇博主

引用来自“都哑”的评论

非常感谢!如果是js访问ws,是不是就不能使用数字签名了?
是的
2015/08/29 16:27
回复
举报
黄勇博主

引用来自“程序员甲”的评论

能不能不用signaturePropFile指定一个文件?直接写在XML里面?
貌似不太行哦
2015/08/29 16:26
回复
举报
您好,请问能不能共享整个项目的源代码?因为按照您的代码应用到自己程序上出现了一些bug。麻烦了。
2015/08/28 18:25
回复
举报
非常感谢!如果是js访问ws,是不是就不能使用数字签名了?
2015/04/16 10:22
回复
举报
能不能不用signaturePropFile指定一个文件?直接写在XML里面?
2014/12/24 16:16
回复
举报
黄勇博主

引用来自“wxw1234”的评论

如果证书过期了怎么办,需要将文件替换一下吗?
证书过期需要更换新的证书
2014/12/04 21:35
回复
举报
如果证书过期了怎么办,需要将文件替换一下吗?
2014/12/03 15:58
回复
举报
更多评论
打赏
18 评论
124 收藏
19
分享
返回顶部
顶部