Apache WSS4J 3.0.6 WS-Security 场景进阶实战详解
实战详解 Apache WSS4J 3.0.6 的 WS-Security 场景:UsernameToken 明文与摘要两种形态、Timestamp 有效窗口与重放缓存、先签名后加密的组合报文构建与验证侧 RequestData 配置,以及篡改/过期/口令错误三类实测失败场景的 code 语义,全部命令与回显实测通过。
Apache WSS4J 3.0.6 WS-Security 场景进阶实战详解
目标:掌握 WSS4J 3.0.6 在真实 WS-Security 场景中的落地用法——UsernameToken 明文/摘要两种形态的实现与验证、Timestamp 有效窗口与重放缓存、先签名后加密的组合报文流水线、验证侧 RequestData 完整配置,以及三类失败场景的报错 code 语义与工程化处置。全部代码、命令与回显均已在 Ubuntu 22.04、OpenJDK 17.0.20.1 实测通过。
一、环境信息
- 操作系统:Ubuntu 22.04.5 LTS(x86_64)
- JDK:OpenJDK 17.0.20.1;Apache Maven 3.6.3
- 组件版本:Apache WSS4J 3.0.6(wss4j-ws-security-dom + wss4j-ws-security-common)、Apache Santuario xmlsec 3.0.6、BouncyCastle bcprov-jdk18on 1.82
- 密钥库(口令统一
q1w2e3r4):client.jks(JKS,client 签名私钥)、server.bcfks(BCFKS,server 解密私钥)、trust.jks(JKS,两张可信证书) - 验证工程:
/root/wss4j-demo(demo.WssDemo,按info/ut/ts/sign/encrypt/full/fail七组演示分组运行)
组件版本实测(运行 info 组的回显,可当环境自检脚本用):
== 环境与密钥库信息 ==
wss4j-ws-security-dom 版本 = 3.0.6
BouncyCastle 版本 = BouncyCastle Security Provider v1.82
client.jks(JKS) 别名: client
server.bcfks(BCFKS) 别名: server
trust.jks 可信证书: client server
server 证书主体 = CN=server.demows.example,O=Demo,L=Beijing,C=CN | 签名算法 = SHA256withRSA
前置篇《Ubuntu 22.04 安装 Apache WSS4J 指南》完成环境与密钥库准备,《Apache WSS4J 3.0.6 签名与加密 API 详解》覆盖签名/加密 API 细节;本文聚焦场景组合与验证侧的完整工程形态。所有代码共用以下基础设施(实测 WssDemo 原样摘录):
static final String SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/";
static final String UT_PASSWORD = "q1w2e3r4";
static final String STORE_PASSWORD = "q1w2e3r4";
static Crypto clientCrypto() { // 签名方:含私钥
return crypto("/wss4j/keystores/client.jks", "JKS", null);
}
static Crypto serverCrypto() { // 接收方:含解密私钥(BCFKS 需指定 BC 提供者)
return crypto("/wss4j/keystores/server.bcfks", "BCFKS", "BC");
}
static Crypto trustCrypto() { // 验签方:只含可信证书
return crypto("/wss4j/keystores/trust.jks", "JKS", null);
}
二、UsernameToken:明文与摘要两种形态
2.1 明文形态(PasswordText)
构建侧实测代码(WSSecUsernameToken 挂到已有 wsse:Security 头):
Document doc = newSoapDoc();
WSSecHeader h = new WSSecHeader(doc);
h.insertSecurityHeader();
WSSecUsernameToken ut = new WSSecUsernameToken(h);
ut.setUserInfo("alice", UT_PASSWORD);
ut.setPasswordType(WSConstants.PASSWORD_TEXT); // 明文
ut.build();
报文头实测片段(明文形态,口令以文本直出):
<wsse:Security soap:mustUnderstand="1" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
<wsse:UsernameToken wsu:Id="UsernameToken-...">
<wsse:Username>alice</wsse:Username>
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">q1w2e3r4</wsse:Password>
</wsse:UsernameToken>
</wsse:Security>
验证侧实测结果:
验证通过,处理到的安全动作 = [UT]
2.2 摘要形态(PasswordDigest + Nonce + Created)
摘要形态多两件事:addNonce() 生成一次性随机数、addCreated() 生成时间戳,三者共同参与摘要计算:
WSSecUsernameToken ut = new WSSecUsernameToken(secHeader);
ut.setUserInfo("alice", UT_PASSWORD);
ut.setPasswordType(WSConstants.PASSWORD_DIGEST); // 摘要
ut.addNonce();
ut.addCreated();
ut.build();
报文头实测片段(节选,逐行过滤输出):
<wsse:UsernameToken wsu:Id="UsernameToken-...">
<wsse:Username>alice</wsse:Username>
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">W5FdSH/s5XDb6knOHm5Kp3e1G10=</wsse:Password>
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">IS1MNPpQpgN/B/bXGx3nBg==</wsse:Nonce>
<wsu:Created>2026-10-15T04:02:11.562Z</wsu:Created>
</wsse:UsernameToken>
摘要的计算公式(WSS4J 按此校验):Base64(SHA-1(Nonce + Created + Password))。报文里只出现摘要值,口令本身不再过网。验证侧回调按 USERNAME_TOKEN usage(实测常量值 2)返回原口令,由引擎算摘要比对:
reqData.setCallbackHandler(callbacks -> {
for (Callback cb : callbacks) {
WSPasswordCallback pc = (WSPasswordCallback) cb;
if (pc.getUsage() == WSPasswordCallback.USERNAME_TOKEN) {
pc.setPassword("q1w2e3r4");
} else if (pc.getUsage() == WSPasswordCallback.DECRYPT) { // 实测常量值 1
pc.setPassword("q1w2e3r4");
} else {
throw new UnsupportedCallbackException(cb);
}
}
});
实测验证结果:
验证通过,处理到的安全动作 = [UT]
💡 技术拓展:明文与摘要的选型对比——
方案 报文内容 抗嗅探 服务端存储 适用场景 PasswordText 口令明文 依赖传输层加密 需可逆存口令或加密存储 对端只认明文的存量系统 PasswordDigest 摘要+Nonce+Created 抓包拿不到口令 存原口令(或等价可逆)用于复算 新系统默认选择 本文采用 摘要形态(PasswordDigest + Nonce + Created),理由:摘要形态让口令不过网,配合 Nonce 重放缓存还能挡重放;明文形态仅用于对接无法改造的老对端,且必须叠加 TLS。另注意:摘要是 SHA-1 单轮、无盐,口令强度仍决定安全下限,
q1w2e3r4仅为演示。
2.3 两种形态的验证侧差异
引擎处理逻辑实测一致(一个回调返回原口令即可),差异在引擎内部:明文形态直接字符串比较;摘要形态取回调口令按公式复算再比对。因此服务端存储策略由形态决定:接摘要形态的服务端必须能拿到原口令;若只能存盐化哈希(BCrypt 等),摘要形态无法复算,需要改用明文形态 + 传输层加密,或换 SAML/JWT 令牌体系。
三、Timestamp:有效窗口与重放防护
3.1 构建与验证
WSSecTimestamp 一行构建(TTL 单位秒,Created/Expires 自动按当前时间推算):
WSSecTimestamp ts = new WSSecTimestamp(secHeader);
ts.setTimeToLive(300);
ts.build();
报文头实测片段:
<wsu:Timestamp wsu:Id="Timestamp-...">
<wsu:Created>2026-10-15T04:03:02.163Z</wsu:Created>
<wsu:Expires>2026-10-15T04:08:02.163Z</wsu:Expires>
</wsu:Timestamp>
验证侧配置(实测):
reqData.setTimeStampTTL(300); // 超过当前时间 300 秒即拒
实测结果:
验证通过,处理到的安全动作 = [TS]
3.2 重放缓存:MemoryReplayCache
时间戳在有效窗口内可被原样重放,摘要 UT 的 Nonce 同理。WSS4J 用重放缓存(ReplayCache)记录已见过的 Nonce/时间戳标识,二次提交直接拒绝。实测接入内存实现:
reqData.setNonceReplayCache(new MemoryReplayCache()); // UT Nonce 重放检测
reqData.setTimestampReplayCache(new MemoryReplayCache()); // 时间戳重放检测
逐项说明:
MemoryReplayCache:wss4j-ws-security-common 内置的内存实现,进程内有效;多副本部署需换共享实现(如基于缓存中间件的定制ReplayCache),否则每个副本各存一份挡不住跨副本重放。- 缓存与
setUserInfo的 Nonce 绑定:实测同一 UT 报文用同一个缓存实例验证第二次时会被重放检测拦截;每次验证新建缓存实例(本文演示组的行为)则不触发——线上务必让缓存跨请求存活。 - 时间戳重放缓存记录的是
wsu:Id,配合 TTL 窗口共同把重放窗口压到最短。
💡 技术拓展:TTL 取值建议——窗口越长,重放面越大;越短,对时钟同步要求越高。实测常见落点:内网高可信 60~120 秒,跨机构 300 秒(本文取 300,WSS4J 默认亦为 300),并配套 NTP 强制校时。
RequestData还有setTimeStampStrict(true)开关(默认开启),用于拒绝缺Expires的时间戳。
四、组合场景:先签名后加密
4.1 构建顺序与代码
生产报文通常同时要「防篡改(签名)」「防窃听(加密)」「防重放(时间戳+Nonce)」。实测可行的顺序是:时间戳 → UsernameToken → 签名 → 加密,全部复用同一个 WSSecHeader:
Document doc = newSoapDoc();
WSSecHeader h = new WSSecHeader(doc);
h.insertSecurityHeader();
// 1) 时间戳(防重放/防陈旧)
WSSecTimestamp ts = new WSSecTimestamp(h);
ts.setTimeToLive(300);
ts.build();
// 2) 摘要 UsernameToken(身份)
WSSecUsernameToken ut = new WSSecUsernameToken(h);
ut.setUserInfo("alice", UT_PASSWORD);
ut.setPasswordType(WSConstants.PASSWORD_DIGEST);
ut.addNonce();
ut.addCreated();
ut.build();
// 3) 签名:同时覆盖 Timestamp(Element) 与 Body(Content)(防篡改)
WSSecSignature sign = new WSSecSignature(h);
sign.setUserInfo("client", STORE_PASSWORD);
sign.setKeyIdentifierType(WSConstants.BST_DIRECT_REFERENCE);
sign.setSignatureAlgorithm(WSConstants.RSA_SHA256);
sign.setDigestAlgo(MessageDigestAlgorithm.ALGO_ID_DIGEST_SHA256);
sign.setSigCanonicalization(WSConstants.C14N_EXCL_OMIT_COMMENTS);
sign.prepare(clientCrypto());
List<WSEncryptionPart> parts = new ArrayList<>();
parts.add(new WSEncryptionPart("Timestamp", WSConstants.WSU_NS, "Element"));
parts.add(new WSEncryptionPart("Body", SOAP_NS, "Content"));
sign.computeSignature(sign.addReferencesToSign(parts));
sign.prependBSTElementToHeader();
// 4) 加密 Body(防窃听)
KeyGenerator kg = KeyGenerator.getInstance("AES");
kg.init(128);
SecretKey sessionKey = kg.generateKey();
WSSecEncrypt enc = new WSSecEncrypt(h);
enc.setUserInfo("server");
enc.setKeyIdentifierType(WSConstants.ISSUER_SERIAL);
enc.setSymmetricEncAlgorithm(WSConstants.AES_128_GCM);
Document secDoc = enc.build(serverCrypto(), sessionKey);
为什么必须「先签名后加密」:签名针对的是明文的规范化字节流;若先加密,签名引用的 Body 已是 EncryptedData 密文,验证侧要先解密再验签且规范化工序更脆。先签后密是 WS-Security Security Header Layout 的推荐形态,验证侧引擎按「解密 → 恢复明文 → 验签」自动处理。
4.2 验证侧配置与实测结果
验证侧把四类配置一次配齐(信任库、解密私钥、回调、重放缓存):
WSSecurityEngine engine = new WSSecurityEngine();
RequestData reqData = new RequestData();
reqData.setSigVerCrypto(trustCrypto());
reqData.setDecCrypto(serverCrypto());
reqData.setCallbackHandler(utCallback(UT_PASSWORD));
reqData.setNonceReplayCache(new MemoryReplayCache());
reqData.setTimestampReplayCache(new MemoryReplayCache());
reqData.setTimeStampTTL(300);
WSHandlerResult result = engine.processSecurityHeader(secDoc, reqData);
实测运行结果(一次提交,引擎自动完成解密、恢复、验签、UT 校验、时间戳校验):
组合报文字节数(序列化后) = 7563
密文侧 Body 明文可见性 = 不可见(正常)
验证通过,处理到的安全动作 = [TS, UT, SIGN, BST, ENCR]
解密后 Body 内容 = order-1001|item=SSD-2T|qty=3|amount=1299.00
解密后与原文一致 = true
结果解读:五个动作码覆盖了全部安全语义——时间戳有效、UT 摘要比对通过、签名验证通过(覆盖 Timestamp 与解密后的 Body)、直附证书已受理、Body 已解密且明文与原文一致。组合报文 7563 字节 vs 明文约 400 字节,开销主要来自证书直附(BST_DIRECT_REFERENCE,约 1 KB)与双层密文 Base64 膨胀,生产可按需换 ISSUER_SERIAL 寻址瘦身。
五、失败场景实测与报错语义
三个失败场景全部实测复现(fail 组)。输出均为真实运行回显,code 取自 WSSecurityException,可作为线上告警分级依据。
场景一:签名后篡改 Body(改写订单号与金额)再验证,实测拒绝:
[场景1] 拒绝成功: code=FAILED_CHECK message=The signature or decryption was invalid
场景二:时间戳过期(把 Created/Expires 重写为 30 分钟前)再验证,实测拒绝:
[场景2] 拒绝成功: code=MESSAGE_EXPIRED message=Invalid timestamp: The message timestamp has expired
场景三:摘要 UT 的验证回调返回错误口令(wrong-pass 复算摘要不匹配),实测拒绝:
[场景3] 拒绝成功: code=FAILED_AUTHENTICATION message=The security token could not be authenticated or authorized
三类 code 的处置建议(按实测语义归纳):
| code | 触发实测场景 | 含义 | 建议处置 |
|---|---|---|---|
FAILED_CHECK | 篡改已签名 Body | 签名/解密无效(摘要不匹配) | 直接断连并告警,排查中间环节是否被介入 |
MESSAGE_EXPIRED | 重写过期时间戳 | 时间窗失效 | 先核对两端 NTP;频繁出现说明时钟漂移 |
FAILED_AUTHENTICATION | UT 回调口令错误 | 口令比对失败 | 限速防爆破;确认部署环境口令配置 |
💡 技术拓展:失败处理与「验后即弃」原则——三类失败都在验证阶段被引擎抛出,业务代码拿到的 Document 已通过全部校验,不必自行复核签名。工程要点是:验证失败时不要把未验证的原始报文继续传递;按 code 分级打点后,用固定错误响应(不回显细节)应答,避免给探测者提供报错侧信道。
六、参考链接
- Apache WSS4J 官方主页:https://ws.apache.org/wss4j/
- WSS4J 3.0.6 javadoc(WSSecUsernameToken/WSSecTimestamp/RequestData):https://ws.apache.org/wss4j/apidocs/index.html
- OASIS UsernameToken Profile 1.1(明文/摘要规范):https://docs.oasis-open.org/wss/v1.1/wss-v1.1-spec-os-UsernameTokenProfile-01.htm
- OASIS SOAP Message Security 1.1(Security Header Layout):https://docs.oasis-open.org/wss/v1.1/wss-v1.1-spec-os-SOAPMessageSecurity-01.htm
- 本专栏前置篇:《Apache WSS4J 3.0.6 签名与加密 API 详解》(签名/加密 API 与算法选型)、《Ubuntu 22.04 安装 Apache WSS4J 指南》(环境与密钥库)