I recently had a requirement to interop with a web service (written in Java, not that it matters all that much what it was written in) with a quasi-unique set of security requirements. They were as follows:
- SOAP 1.1
- Transport security was an option. Production endpoint was using SSL; test endpoint was not. Need the flexibility to turn this on or off.
- Message Security consisted of two tokens (both WS-Security 1.0)
- Unsigned username token with plaintext password (forget the argument about plain text using a non-encrypted channel)
- X.509 token signing the SOAP message body
- This is a client cert to authenticate the client to the service
- X.509 token was not embedded within the message itself; it was an external reference to a cert using a thumbprint lookup
I started down the path of using WCF. None of the out-of-the-box bindings fit the bill for this, so I realized I needed to write some custom stuff. However, I soon realized I was mired in a bog of custom code to get this to work. I had started to create a custom binding (with a "two-token" binding element), but I was faced with a myriad of complexities and too little experience to navigate it successfully. The WCF stack is a highly complex framework, and diving into it to develop completely custom code shouldn't be taken lightly. I hope to revisit this problem with WCF to implement it the "right" way, considering this is the .NET connected systems technology going forward. When I do, I'll post the solution here.
Anyway, at this point, I shifted gears and looked to WSE3.0 to solve this problem. As with WCF, none of the turnkey assertion policies worked for me, so I had to roll up my sleeves and start working on custom code. The solution was fairly straightforward, although I had to go through several iterations to turn the various knobs "just so" to get the interop down exactly.
I first started by creating a custom assertion policy, like so:
public class TwoTokenSecurityAssertion : SecurityPolicyAssertion
{
// Fields
private TokenProvider<UsernameToken> usernameTokenProvider;
private TokenProvider<X509SecurityToken> x509TokenProvider;
// Methods
public override SoapFilter CreateClientInputFilter(FilterCreationContext context)
{
if (context == null)
{
throw new ArgumentNullException("context");
}
return new ClientInputFilter(this);
}
public override SoapFilter CreateClientOutputFilter(FilterCreationContext context)
{
if (context == null)
{
throw new ArgumentNullException("context");
}
return new ClientOutputFilter(this);
}
public override SoapFilter CreateServiceInputFilter(FilterCreationContext context)
{
if (context == null)
{
throw new ArgumentNullException("context");
}
return new ServiceInputFilter(this);
}
public override SoapFilter CreateServiceOutputFilter(FilterCreationContext context)
{
if (context == null)
{
throw new ArgumentNullException("context");
}
return new ServiceOutputFilter(this);
}
public TokenProvider<UsernameToken> UsernameTokenProvider
{
get
{
return this.usernameTokenProvider;
}
set
{
this.usernameTokenProvider = value;
}
}
public TokenProvider<X509SecurityToken> X509TokenProvider
{
get
{
return this.x509TokenProvider;
}
set
{
this.x509TokenProvider = value;
}
}
// Nested Types
// Called when a message is inbound to the client
protected class ClientInputFilter : ReceiveSecurityFilter
{
// Methods
public ClientInputFilter(TwoTokenSecurityAssertion assertion)
: base(assertion.ServiceActor, true, assertion.ClientActor)
{
}
public override void ValidateMessageSecurity(SoapEnvelope envelope, Security security)
{
// no-op
// We don't expect any security back on the response, so this is a no-op
}
}
// Called when a message is outbound from the client
protected class ClientOutputFilter : SecureConversationClientSendSecurityFilter
{
// Fields
private UsernameToken upToken;
private X509SecurityToken x509Token;
// Methods
public ClientOutputFilter(TwoTokenSecurityAssertion assertion)
: base(assertion)
{
if (assertion.X509TokenProvider != null)
{
this.x509Token = assertion.X509TokenProvider.GetToken();
}
if (assertion.UsernameTokenProvider != null)
{
this.upToken = assertion.UsernameTokenProvider.GetToken();
}
}
public override void SecureMessage(SoapEnvelope envelope, Security security, MessageProtectionRequirements
message)
{
if (envelope == null)
{
throw new ArgumentNullException("envelope");
}
if (security == null)
{
throw new ArgumentNullException("security");
}
// Grab the username token and add it as-is to the header
UsernameToken usernameToken = ClientOutputFilter.ProvideClientToken<UsernameToken>(this.upToken, this.
GetServiceActor(envelope.CurrentSoap));
security.Tokens.Add(usernameToken);
// Create the x509 token, but we won't add it to the security headers directly
X509SecurityToken x509Token = ClientOutputFilter.ProvideServiceToken<X509SecurityToken>(this.x509Token, this.
GetServiceActor(envelope.CurrentSoap));
// Create a message signature. We will sign with the x509 token
MessageSignature signature = new MessageSignature(x509Token);
// Create a new keyinfo for the x.509 token. This is so that the signature will have a reference to the
binary token.
if (signature.KeyInfo == null)
{
signature.KeyInfo = new KeyInfo();
}
// create reference so signature can use it.
SecurityTokenReference tokenRef = new SecurityTokenReference(x509Token, SecurityTokenReference.
SerializationOptions.KeyIdentifier);
// Add the keyinfo ref to the signature
signature.KeyInfo.AddClause(tokenRef);
// we only want to sign the body
signature.SignatureOptions = message.SignatureOptions;
// and add the signature to the header
security.Elements.Add(signature);
}
// Written to replace internal static method on CredentialSet
private static TSecurityToken ProvideClientToken<TSecurityToken>(TSecurityToken token, string actor) where
TSecurityToken : SecurityToken
{
if (token != null)
{
return token;
}
TSecurityToken clientToken = default(TSecurityToken);
if ((actor != null) && (SoapContext.Current != null))
{
clientToken = SoapContext.Current.Credentials[actor].GetClientToken<TSecurityToken>();
}
if (clientToken == default(TSecurityToken))
{
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Unable to determine
client token to use. Client token type requested was '{0}'. The
token must be provided either through policy by specifying the token
in the policy assertion or through code by calling
WebServicesClientProtocol.SetCredentials or using properties on the
SoapContext.Credentials.", new object[] { typeof(TSecurityToken).
ToString() }));
}
return clientToken;
}
// Written to replace internal static method on CredentialSet
private static TSecurityToken ProvideServiceToken<TSecurityToken>(TSecurityToken token, string actor) where
TSecurityToken : SecurityToken
{
if (token != null)
{
return token;
}
TSecurityToken serviceToken = default(TSecurityToken);
if ((actor != null) && (SoapContext.Current != null))
{
serviceToken = SoapContext.Current.Credentials[actor].GetServiceToken<TSecurityToken>();
}
if (serviceToken == default(TSecurityToken))
{
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Unable to determine
service token to use. Service token type requested was '{0}'. The
token must be provided either through policy by specifying the token
in the policy assertion or through code by calling
WebServicesClientProtocol.SetCredentials or using properties on the
SoapContext.Credentials.", new object[] { typeof(TSecurityToken).
ToString() }));
}
return serviceToken;
}
}
// Not used since we're only dealing with client-side.
protected class ServiceInputFilter : ReceiveSecurityFilter
{
// Fields
private X509SecurityToken x509Token;
// Methods
public ServiceInputFilter(TwoTokenSecurityAssertion assertion)
: base(assertion.ServiceActor, false)
{
if (assertion.X509TokenProvider != null)
{
this.x509Token = assertion.X509TokenProvider.GetToken();
}
}
public override void ValidateMessageSecurity(SoapEnvelope envelope, Microsoft.Web.Services3.Security.Security
security)
{
throw new NotImplementedException();
}
}
// Not used since we're only dealing with client-side.
protected class ServiceOutputFilter : SendSecurityFilter
{
// Methods
public ServiceOutputFilter(TwoTokenSecurityAssertion assertion)
: base(assertion.ServiceActor, false)
{
}
public override void SecureMessage(SoapEnvelope envelope, Microsoft.Web.Services3.Security.Security security)
{
throw new NotImplementedException();
}
}
}
Next, I created a web reference to the endpoint and set the WSE3.0 policy in code, as in:
using (MyWebReference.MyInterfaceClient proxy = new MyWebReference.MyInterfaceClient())
{
// In some ways, "client" vs. "server" is a misnomer here. What we're actually providing as a client credential is
a
// username token, whereas the "server" credential is really a client-side cert to prove to the server that we
// are who we say we are.
proxy.SetClientCredential<UsernameToken>(new UsernameToken("sanitized", "sanitized", PasswordOption.SendPlainText));
proxy.SetServiceCredential<X509SecurityToken>(X509TokenProvider.CreateToken(StoreLocation.LocalMachine, StoreName.
TrustedPeople, "<sanitized>", X509FindType.FindByThumbprint));
// Create a custom policy
Policy myPolicy = new Policy();
// Create a new policy assertion
TwoTokenSecurityAssertion myAssertion = new TwoTokenSecurityAssertion();
// define that we only want to sign the body with the
myAssertion.Protection.Request.SignatureOptions = SignatureOptions.IncludeSoapBody;
// Add the assertion to the policy
myPolicy.Assertions.Add(myAssertion);
// and finally, set the policy for this client to the custom one we just created
proxy.SetPolicy(myPolicy);
proxy.CallMethod();
}
This created a SOAP message as follows, which is exactly what I was looking for:
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-
instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:wsa="http://schemas.xmlsoap.
org/ws/2004/08/addressing" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-
wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-
wssecurity-utility-1.0.xsd">
<soap:Header>
<wsa:Action>
</wsa:Action>
<wsa:MessageID>urn:uuid:18667fd7-8a87-4729-a168-ab12379478df</wsa:MessageID>
<wsa:ReplyTo>
<wsa:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:Address>
</wsa:ReplyTo>
<wsa:To>https://webserver/destination</wsa:To>
<wsse:Security soap:mustUnderstand="1">
<wsu:Timestamp wsu:Id="Timestamp-462c82e9-9d0a-4584-b07f-e7f6e49aefcb">
<wsu:Created>2008-04-20T20:24:04Z</wsu:Created>
<wsu:Expires>2008-04-20T20:29:04Z</wsu:Expires>
</wsu:Timestamp>
<wsse:UsernameToken xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
wsu:Id="SecurityToken-ea80bb90-bdc2-4a35-9605-9b6e770b1b4f">
<wsse:Username>sanitized</wsse:Username>
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.
0#PasswordText">sanitized</wsse:Password>
<wsse:Nonce>sanizited</wsse:Nonce>
<wsu:Created>2008-04-20T20:24:04Z</wsu:Created>
</wsse:UsernameToken>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" xmlns:ds="http://www.w3.
org/2000/09/xmldsig#" />
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
<Reference URI="#Id-af786227-f58a-441a-b62e-9bb220c6bcbc">
<Transforms>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
<DigestValue>sanitized</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>sanitized</SignatureValue>
<KeyInfo>
<wsse:SecurityTokenReference>
<wsse:KeyIdentifier ValueType="http://docs.oasis-open.org/wss/oasis-wss-soap-message-security-1.
1#ThumbprintSHA1" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-
200401-wss-soap-message-security-1.0#Base64Binary">sanitized</wsse:KeyIdentifier>
</wsse:SecurityTokenReference>
</KeyInfo>
</Signature>
</wsse:Security>
</soap:Header>
<soap:Body wsu:Id="Id-af786227-f58a-441a-b62e-9bb220c6bcbc">
<TestMethod />
</soap:Body>
</soap:Envelope>
As I mentioned earlier, the "right" way to do this is with WCF, so if I get some time to research doing this with WCF, I'll post my results here.