原文:http://weblog.west-wind.com/posts/2012/Nov/24/WCF-WSSecurity-and-WSE-Nonce-Authentication?utm_source=tuicool&utm_medium=referral
WCF makes it fairly easy to access WS-* Web Services, except when you run into a service format that it doesn't support. Even then WCF provides a huge amount of flexibility to make the service clients work, however finding the proper interfaces to make that happen is not easy to discover and for the most part undocumented unless you're lucky enough to run into a blog, forum or StackOverflow post on the matter.
This is definitely true for the Password Nonce as part of the WS-Security/WSE protocol, which is not natively supported in WCF. Specifically I had a need to create a WCF message on the client that includes a WS-Security header that looks like this from their spec document:
1 <soapenv:Header> 2 <wsse:Security soapenv:mustUnderstand="1" 3 xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"> 4 <wsse:UsernameToken wsu:Id="UsernameToken-8" 5 xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"> 6 <wsse:Username>TeStUsErNaMe1</wsse:Username> 7 <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText" 8 >TeStPaSsWoRd1</wsse:Password> 9 <wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" 10 >f8nUe3YupTU5ISdCy3X9Gg==</wsse:Nonce> 11 <wsu:Created>2011-05-04T19:01:40.981Z</wsu:Created> 12 </wsse:UsernameToken> 13 </wsse:Security> 14 </soapenv:Header>
Specifically, the Nonce and Created keys are what WCF doesn't create or have a built in formatting for.
Why is there a nonce? My first thought here was WTF? The username and password are there in clear text, what does the Nonce accomplish? The Nonce and created keys are are part of WSE Security specification and are meant to allow the server to detect and prevent replay attacks. The hashed nonce should be unique per request which the server can store and check for before running another request thus ensuring that a request is not replayed with exactly the same values.
Basic ServiceUtl Import - not much Luck
The first thing I did when I imported this service with a service reference was to simply import it as a Service Reference. The Add Service Reference import automatically detects that WS-Security is required and appropariately adds the WS-Security to the basicHttpBinding in the config file:
1 <?xml version="1.0" encoding="utf-8" ?> 2 <configuration> 3 <system.serviceModel> 4 <bindings> 5 <basicHttpBinding> 6 <binding name="RealTimeOnlineSoapBinding"> 7 <security mode="Transport" /> 8 </binding> 9 <binding name="RealTimeOnlineSoapBinding1" /> 10 </basicHttpBinding> 11 </bindings> 12 <client> 13 <endpoint address="https://notarealurl.com:443/services/RealTimeOnline" 14 binding="basicHttpBinding" bindingConfiguration="RealTimeOnlineSoapBinding" 15 contract="RealTimeOnline.RealTimeOnline" name="RealTimeOnline" /> 16 </client> 17 </system.serviceModel> 18 </configuration>
If if I run this as is using code like this:
1 var client = new RealTimeOnlineClient(); 2 3 client.ClientCredentials.UserName.UserName = "TheUsername"; 4 client.ClientCredentials.UserName.Password = "ThePassword"; 5 …
I get nothing in terms of WS-Security headers. The request is sent, but the the binding expects transport level security to be applied, rather than message level security. To fix this so that a WS-Security message header is sent the security mode can be changed to:
<security mode="TransportWithMessageCredential" />
Now if I re-run I at least get a WS-Security header which looks like this:
1 <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" 2 xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"> 3 <s:Header> 4 <o:Security s:mustUnderstand="1" 5 xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"> 6 <u:Timestamp u:Id="_0"> 7 <u:Created>2012-11-24T02:55:18.011Z</u:Created> 8 <u:Expires>2012-11-24T03:00:18.011Z</u:Expires> 9 </u:Timestamp> 10 <o:UsernameToken u:Id="uuid-18c215d4-1106-40a5-8dd1-c81fdddf19d3-1"> 11 <o:Username>TheUserName</o:Username> 12 <o:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText" 13 >ThePassword</o:Password> 14 </o:UsernameToken> 15 </o:Security> 16 </s:Header>
Closer! Now the WS-Security header is there along with a timestamp field (which might not be accepted by some WS-Security expecting services), but there's no Nonce or created timestamp as required by my original service.
Using a CustomBinding instead
My next try was to go with a CustomBinding instead of basicHttpBinding as it allows a bit more control over the protocol and transport configurations for the binding. Specifically I can explicitly specify the message protocol(s) used. Using configuration file settings here's what the config file looks like:
1 <?xml version="1.0"?> 2 <configuration> 3 <system.serviceModel> 4 <bindings> 5 <customBinding> 6 <binding name="CustomSoapBinding"> 7 <security includeTimestamp="false" 8 authenticationMode="UserNameOverTransport" 9 defaultAlgorithmSuite="Basic256" 10 requireDerivedKeys="false" 11 messageSecurityVersion="WSSecurity10WSTrustFebruary2005WSSecureConversationFebruary2005WSSecurityPolicy11BasicSecurityProfile10"> 12 </security> 13 <textMessageEncoding messageVersion="Soap11"></textMessageEncoding> 14 <httpsTransport maxReceivedMessageSize="2000000000"/> 15 </binding> 16 </customBinding> 17 </bindings> 18 <client> 19 <endpoint address="https://notrealurl.com:443/services/RealTimeOnline" 20 binding="customBinding" 21 bindingConfiguration="CustomSoapBinding" 22 contract="RealTimeOnline.RealTimeOnline" 23 name="RealTimeOnline" /> 24 </client> 25 </system.serviceModel> 26 <startup> 27 <supportedRuntime version="v4.0" 28 sku=".NETFramework,Version=v4.0"/> 29 </startup> 30 </configuration>
This ends up creating a cleaner header that's missing the timestamp field which can cause some services problems. The WS-Security header output generated with the above looks like this:
1 <s:Header> 2 <o:Security s:mustUnderstand="1" 3 xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"> 4 <o:UsernameToken u:Id="uuid-291622ca-4c11-460f-9886-ac1c78813b24-1"> 5 <o:Username>TheUsername</o:Username> 6 <o:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText" 7 >ThePassword</o:Password> 8 </o:UsernameToken> 9 </o:Security> 10 </s:Header>
This is closer as it includes only the username and password.
The key here is the protocol for WS-Security:
messageSecurityVersion="WSSecurity10WSTrustFebruary2005WSSecureConversationFebruary2005WSSecurityPolicy11BasicSecurityProfile10"
which explicitly specifies the protocol version. There are several variants of this specification but none of them seem to support the nonce unfortunately. This protocol does allow for optional omission of the Nonce and created timestamp provided (which effectively makes those keys optional). With some services I tried that requested a Nonce just using this protocol actually worked where the default basicHttpBinding failed to connect, so this is a possible solution for access to some services.
Unfortunately for my target service that was not an option. The nonce has to be there.
Creating Custom ClientCredentials
As it turns out WCF doesn't have support for the Digest Nonce as part of WS-Security, and so as far as I can tell there's no way to do it just with configuration settings. I did a bunch of research on this trying to find workarounds for this, and I did find a couple of entries on StackOverflow as well as on the MSDN forums. However, none of these are particularily clear and I ended up using bits and pieces of several of them to arrive at a working solution in the end.
- http://stackoverflow.com/questions/896901/wcf-adding-nonce-to-usernametoken
- http://social.msdn.microsoft.com/Forums/en-US/wcf/thread/4df3354f-0627-42d9-b5fb-6e880b60f8ee
The latter forum message is the more useful of the two (the last message on the thread in particular) and it has most of the information required to make this work. But it took some experimentation for me to get this right so I'll recount the process here maybe a bit more comprehensively.
In order for this to work a number of classes have to be overridden:
- ClientCredentials
- ClientCredentialsSecurityTokenManager
- WSSecurityTokenizer
The idea is that we need to create a custom ClientCredential class to hold the custom properties so they can be set from the UI or via configuration settings. The TokenManager and Tokenizer are mainly required to allow the custom credentials class to flow through the WCF pipeline and eventually provide custom serialization.
Here are the three classes required and their full implementations:
1 public class CustomCredentials : ClientCredentials 2 { 3 public CustomCredentials() 4 { } 5 6 protected CustomCredentials(CustomCredentials cc) 7 : base(cc) 8 { } 9 10 public override System.IdentityModel.Selectors.SecurityTokenManager CreateSecurityTokenManager() 11 { 12 return new CustomSecurityTokenManager(this); 13 } 14 15 protected override ClientCredentials CloneCore() 16 { 17 return new CustomCredentials(this); 18 } 19 } 20 public class CustomSecurityTokenManager : ClientCredentialsSecurityTokenManager 21 { 22 public CustomSecurityTokenManager(CustomCredentials cred) 23 : base(cred) 24 { } 25 26 public override System.IdentityModel.Selectors.SecurityTokenSerializer CreateSecurityTokenSerializer(System.IdentityModel.Selectors.SecurityTokenVersion version) 27 { 28 return new CustomTokenSerializer(System.ServiceModel.Security.SecurityVersion.WSSecurity11); 29 } 30 } 31 public class CustomTokenSerializer : WSSecurityTokenSerializer 32 { 33 public CustomTokenSerializer(SecurityVersion sv) 34 : base(sv) 35 { } 36 37 protected override void WriteTokenCore(System.Xml.XmlWriter writer, 38 System.IdentityModel.Tokens.SecurityToken token) 39 { 40 UserNameSecurityToken userToken = token as UserNameSecurityToken; 41 42 string tokennamespace = "o"; 43 44 DateTime created = DateTime.Now; 45 string createdStr = created.ToString("yyyy-MM-ddThh:mm:ss.fffZ"); 46 47 // unique Nonce value - encode with SHA-1 for 'randomness' 48 // in theory the nonce could just be the GUID by itself 49 string phrase = Guid.NewGuid().ToString(); 50 var nonce = GetSHA1String(phrase); 51 52 // in this case password is plain text 53 // for digest mode password needs to be encoded as: 54 // PasswordAsDigest = Base64(SHA-1(Nonce + Created + Password)) 55 // and profile needs to change to 56 //string password = GetSHA1String(nonce + createdStr + userToken.Password); 57 58 string password = userToken.Password; 59 60 writer.WriteRaw(string.Format( 61 "<{0}:UsernameToken u:Id=\"" + token.Id + 62 "\" xmlns:u=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd\">" + 63 "<{0}:Username>" + userToken.UserName + "</{0}:Username>" + 64 "<{0}:Password Type=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText\">" + 65 password + "</{0}:Password>" + 66 "<{0}:Nonce EncodingType=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary\">" + 67 nonce + "</{0}:Nonce>" + 68 "<u:Created>" + createdStr + "</u:Created></{0}:UsernameToken>", tokennamespace)); 69 } 70 71 protected string GetSHA1String(string phrase) 72 { 73 SHA1CryptoServiceProvider sha1Hasher = new SHA1CryptoServiceProvider(); 74 byte[] hashedDataBytes = sha1Hasher.ComputeHash(Encoding.UTF8.GetBytes(phrase)); 75 return Convert.ToBase64String(hashedDataBytes); 76 } 77 78 }
Realistically only the CustomTokenSerializer has any significant code in. The code there deals with actually serializing the custom credentials using low level XML semantics by writing output into an XML writer.
I can't take credit for this code - most of the code comes from the MSDN forum post mentioned earlier - I made a few adjustments to simplify the nonce generation and also added some notes to allow for PasswordDigest generation.
Per spec the nonce is nothing more than a unique value that's supposed to be 'random'. I'm thinking that this value can be any string that's unique and a GUID on its own probably would have sufficed. Comments on other posts that GUIDs can be potentially guessed are highly exaggerated to say the least IMHO. To satisfy even that aspect though I added the SHA1 encryption and binary decoding to give a more random value that would be impossible to 'guess'. The original example from the forum post used another level of encoding and decoding to string in between - but that really didn't accomplish anything but extra overhead.
The header output generated from this looks like this:
1 <s:Header> 2 <o:Security s:mustUnderstand="1" 3 xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"> 4 <o:UsernameToken u:Id="uuid-f43d8b0d-0ebb-482e-998d-f544401a3c91-1" 5 xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"> 6 <o:Username>TheUsername</o:Username> 7 <o:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">ThePassword</o:Password> 8 <o:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" 9 >PjVE24TC6HtdAnsf3U9c5WMsECY=</o:Nonce> 10 <u:Created>2012-11-23T07:10:04.670Z</u:Created> 11 </o:UsernameToken> 12 </o:Security> 13 </s:Header>
which is exactly as it should be.
Password Digest?
In my case the password is passed in plain text over an SSL connection, so there's no digest required so I was done with the code above.
Since I don't have a service handy that requires a password digest, I had no way of testing the code for the digest implementation, but here is how this is likely to work. If you need to pass a digest encoded password things are a little bit trickier. The password type namespace needs to change to:
http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#Digest
and then the password value needs to be encoded. The format for password digest encoding is this:
Base64(SHA-1(Nonce + Created + Password))
and it can be handled in the code above with this code (that's commented in the snippet above):
string password = GetSHA1String(nonce + createdStr + userToken.Password);
The entire WriteTokenCore method for digest code looks like this:
1 protected override void WriteTokenCore(System.Xml.XmlWriter writer, 2 System.IdentityModel.Tokens.SecurityToken token) 3 { 4 UserNameSecurityToken userToken = token as UserNameSecurityToken; 5 6 string tokennamespace = "o"; 7 8 DateTime created = DateTime.Now; 9 string createdStr = created.ToString("yyyy-MM-ddThh:mm:ss.fffZ"); 10 11 // unique Nonce value - encode with SHA-1 for 'randomness' 12 // in theory the nonce could just be the GUID by itself 13 string phrase = Guid.NewGuid().ToString(); 14 var nonce = GetSHA1String(phrase); 15 16 string password = GetSHA1String(nonce + createdStr + userToken.Password); 17 18 writer.WriteRaw(string.Format( 19 "<{0}:UsernameToken u:Id=\"" + token.Id + 20 "\" xmlns:u=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd\">" + 21 "<{0}:Username>" + userToken.UserName + "</{0}:Username>" + 22 "<{0}:Password Type=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#Digest\">" + 23 password + "</{0}:Password>" + 24 "<{0}:Nonce EncodingType=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary\">" + 25 nonce + "</{0}:Nonce>" + 26 "<u:Created>" + createdStr + "</u:Created></{0}:UsernameToken>", tokennamespace)); 27 }
I had no service to connect to to try out Digest auth - if you end up needing it and get it to work please drop a comment…
How to use the custom Credentials
The easiest way to use the custom credentials is to create the client in code.
Here's a factory method I use to create an instance of my service client:
1 public static RealTimeOnlineClient CreateRealTimeOnlineProxy(string url, 2 string username, 3 string password) 4 { 5 if (string.IsNullOrEmpty(url)) 6 url = "https://notrealurl.com:443/cows/services/RealTimeOnline"; 7 8 CustomBinding binding = new CustomBinding(); 9 var security = TransportSecurityBindingElement.CreateUserNameOverTransportBindingElement(); 10 security.IncludeTimestamp = false; 11 security.DefaultAlgorithmSuite = SecurityAlgorithmSuite.Basic256; 12 security.MessageSecurityVersion = 13 MessageSecurityVersion.WSSecurity10WSTrustFebruary2005WSSecureConversationFebruary2005WSSecurityPolicy11BasicSecurityProfile10; 14 15 var encoding = new TextMessageEncodingBindingElement(); 16 encoding.MessageVersion = MessageVersion.Soap11; 17 18 var transport = new HttpsTransportBindingElement(); 19 transport.MaxReceivedMessageSize = 20000000; // 20 megs 20 21 binding.Elements.Add(security); 22 binding.Elements.Add(encoding); 23 binding.Elements.Add(transport); 24 25 RealTimeOnlineClient client = new RealTimeOnlineClient(binding, 26 new EndpointAddress(url)); 27 28 // to use full client credential with Nonce uncomment this code: 29 // it looks like this might not be required - the service seems to work without it 30 client.ChannelFactory.Endpoint.Behaviors.Remove<System.ServiceModel.Description.ClientCredentials>(); 31 client.ChannelFactory.Endpoint.Behaviors.Add(new CustomCredentials()); 32 33 client.ClientCredentials.UserName.UserName = username; 34 client.ClientCredentials.UserName.Password = password; 35 36 return client; 37 }
This returns a service client that's ready to call other service methods.
The key item in this code is the ChannelFactory endpoint behavior modification that that first removes the original ClientCredentials and then adds the new one. The ClientCredentials property on the client is read only and this is the way it has to be added.
Summary
It's a bummer that WCF doesn't suport WSE Security authentication with nonce values out of the box. From reading the comments in posts/articles while I was trying to find a solution, I found that this feature was omitted by design as this protocol is considered unsecure. While I agree that plain text passwords are rarely a good idea even if they go over secured SSL connection as WSE Security does, there are unfortunately quite a few services (mosly Java services I suspect) that use this protocol. I've run into this twice now and trying to find a solution online I can see that this is not an isolated problem - many others seem to have struggled with this. It seems there are about a dozen questions about this on StackOverflow all with varying incomplete answers. Hopefully this post provides a little more coherent content in one place.
Again I marvel at WCF and its breadth of support for protocol features it has in a single tool. And even when it can't handle something there are ways to get it working via extensibility. But at the same time I marvel at how freaking difficult it is to arrive at these solutions. I mean there's no way I could have ever figured this out on my own. It takes somebody working on the WCF team or at least being very, very intricately involved in the innards of WCF to figure out the interconnection of the various objects to do this from scratch. Luckily this is an older problem that has been discussed extensively online and I was able to cobble together a solution from the online content. I'm glad it worked out that way, but it feels dirty and incomplete in that there's a whole learning path that was omitted to get here…
Man am I glad I'm not dealing with SOAP services much anymore. REST service security - even when using some sort of federation is a piece of cake by comparison :-) I'm sure once standards bodies gets involved we'll be right back in security standard hell…