Partilhar via


Solving a WS-Oops with WSE 3.0 and a custom policy assertion

When a customer asked about supporting WSE 1.0 in .NET 2.0, it took some research to finally figure out that they really wanted to be able to support the UsernameToken from the draft version of WS-Security.  It doesn't really matter if it is WSE 1.0 or something else.  So I started trying to figure out how to make this work using something else.  I wrote a quick ASMX header implementation, and then I wrote another SoapExtension implementation. Those seemed like the easier approach with the lightest footprint.

Having written my own WSE token implementation before, I couldn't help but think it just might work. After fighting the SoapExtension for awhile, I figured it couldn't be that hard to write a token and token manager really quickly. I had the code for a custom token and its accompanying SecurityTokenManager done in almost no time. The majority of the work for a custom token is reading and writing to the DOM.

 using System;
using System.Xml;
using Microsoft.Web.Services3.Security.Tokens;
using Microsoft.Web.Services3.Security.Cryptography;
using System.Security.Permissions;
using System.Xml.XPath;
using Microsoft.Web.Services3;

namespace Msdn.Web.Services
{
    public class TokenNames
    {
        public const string NamespaceURI = "https://schemas.xmlsoap.org/ws/2002/07/secext";
        public const string UtilityURI = "https://schemas.xmlsoap.org/ws/2002/07/utility";
    }

    [SecurityPermission(SecurityAction.Demand, Flags = SecurityPermissionFlag.UnmanagedCode)]
    public class WSSEDraftToken : SecurityToken
    {
        internal string _userName;
        internal string _password;
        internal DateTime _dateTime;
        public WSSEDraftToken(string userName, string password)
            : base(TokenNames.NamespaceURI)
        {
            this._userName = userName;
            this._password = password;
            this._dateTime = DateTime.Now;
        }

        public WSSEDraftToken(XmlElement element)
            : base(element, TokenNames.NamespaceURI)
        {
        }


        public override bool Equals(SecurityToken token)
        {
            if (token == null || token.GetType() != GetType())
                return false;
            else
            {
                WSSEDraftToken t = (WSSEDraftToken)token;
                if (t._userName == _userName && t._password == _password)
                {
                    return true;
                }
            }
            return false;
        }

        public override int GetHashCode()
        {
            return this._userName.GetHashCode();
        }

        public override System.Xml.XmlElement GetXml(XmlDocument document)
        { 
            if (document == null)
            {
                throw new ArgumentNullException("document");
            }

            //Create the XML.. DO NOT append it to the passed doc.
            XmlElement tokenElement = document.CreateElement("wssecext", "UsernameToken", TokenNames.NamespaceURI);
            XmlAttribute nsAttribute = document.CreateAttribute("xmlns:wsu");
            nsAttribute.Value = TokenNames.UtilityURI;
            tokenElement.Attributes.Append(nsAttribute);

            XmlElement usernameElement = document.CreateElement("wssecext", "Username", TokenNames.NamespaceURI);
            usernameElement.InnerText = this._userName;
            tokenElement.AppendChild(usernameElement);

            XmlElement passwordElement = document.CreateElement("wssecext", "Password", TokenNames.NamespaceURI);
            XmlAttribute typeAttribute = document.CreateAttribute("Type");
            typeAttribute.Value = "wsse:PasswordText";
            passwordElement.Attributes.Append(typeAttribute);
            passwordElement.InnerText = this._password;
            tokenElement.AppendChild(passwordElement);

            XmlElement createdElement = document.CreateElement("wsu", "Created", TokenNames.UtilityURI);
            createdElement.InnerText = XmlConvert.ToString(System.DateTime.Now, "yyyy-MM-ddTHH:mm:ssZ");
            tokenElement.AppendChild(createdElement);

            return tokenElement;
        }

        public override bool IsCurrent
        {
            get { return true; }
        }

        public override KeyAlgorithm Key
        {
            get { return null; }
        }

        public override void LoadXml(XmlElement element)
        {
            if (null == element)
            {
                throw new ArgumentNullException("element");
            }
            if ((element.LocalName != "UsernameToken") || (element.NamespaceURI != TokenNames.NamespaceURI))
            {
                throw new ArgumentException("Expected UsernameToken in namespace " + TokenNames.NamespaceURI);
            }
            if (element.HasChildNodes)
            {
                foreach (XmlElement child in element.ChildNodes)
                {
                    if ((child.NamespaceURI != TokenNames.NamespaceURI) && (child.NamespaceURI != TokenNames.UtilityURI))
                    {
                        throw new System.Security.SecurityException("Expected element in namespace " + TokenNames.NamespaceURI);
                    }
                    else
                    {
                        //It is in the right namespace
                        switch (child.LocalName)
                        {
                            case "Username":
                                this._userName = child.InnerText;
                                break;
                            case "Password":
                                if (!child.HasAttributes)
                                    throw new System.Security.SecurityException("Expected type attribute.");
                                else
                                {
                                    string passwordOption = child.Attributes["Type"].Value;
                                    if (passwordOption == null)
                                    {
                                        throw new System.Security.SecurityException("Expected type attribute value.");
                                    }
                                    else
                                    {
                                        if (passwordOption != "wsse:PasswordText")
                                        {
                                            throw new NotImplementedException("This type of password option is not implemented");                                            
                                        }
                                    }
                                }
                                this._password = child.InnerText;
                                break;
                            case "Created":
                                this._dateTime = XmlConvert.ToDateTime(child.InnerText, "yyyy-MM-ddTHH:mm:ssZ");
                                break;                            
                            default:
                                throw new System.Security.SecurityException("Unexpected element encountered in WSSEDraftToken");
                                
                        }
                    }
                }
                if (this._userName == string.Empty)
                    throw new System.Security.SecurityException("Username cannot be empty");
            }
        }

        public override bool SupportsDataEncryption
        {
            get { return false; }
        }

        public override bool SupportsDigitalSignature
        {
            get { return false; }
        }
    }
}

It looks like a lot of code, but most of the code is just overriding the base class' methods.  The real meat is in the ReadXml and GetXml methods, of which there really isn't that much to it.  When a token is included in a message, the SecurityTokenManager is configured to handle the token based on its name and namespace in the XML. 

 using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.Web.Services3.Security;

namespace Msdn.Web.Services
{
    public class WSSEDraftSecurityTokenManager : Microsoft.Web.Services3.Security.Tokens.SecurityTokenManager
    {
        public WSSEDraftSecurityTokenManager()
        {

        }
        public override string TokenType
        {
            get { return TokenNames.NamespaceURI; }
        }

        public override Microsoft.Web.Services3.Security.Tokens.SecurityToken LoadTokenFromXml(System.Xml.XmlElement element)
        {
            WSSEDraftToken token = new WSSEDraftToken(element);                    
            return token;
        }

        public override void VerifyToken(Microsoft.Web.Services3.Security.Tokens.SecurityToken token)
        {
            //TODO:  Lookup the username and password in a database or something.
            //       Hard-coded for testing purposes.
            WSSEDraftToken draftToken = token as WSSEDraftToken;
            if (draftToken != null)
            {
                if ((draftToken._userName == "webserviceuser") && (draftToken._password == "web"))
                {
                    //Successful login
                }
                else
                {
                    //Failed login... throw SecurityFault
                    throw new SecurityFault("The security token could not be authenticated or authorized", SecurityFault.FailedAuthenticationCode);
                }
            }
        }
    }
}

Configuring the security token manager is simple enough, just add an entry in the .config file for the application.

 <configuration>
    <configSections>
        <section name="microsoft.web.services3"
                 type="Microsoft.Web.Services3.Configuration.WebServicesConfiguration, Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
    </configSections>
    
    <microsoft.web.services3>
        <security>
            <securityTokenManager>
               <add localName="UsernameToken" type="Msdn.Web.Services.WSSEDraftSecurityTokenManager,
    Msdn.Web.Services"
                    namespace="https://schemas.xmlsoap.org/ws/2002/07/secext"/>
            </securityTokenManager> 
        </security>
    </microsoft.web.services3>
</configuration>

I kind of got my hopes up that this would work, but when it didn't I wasn't surprised.  The SOAP message that is posted from the existing clients uses a different namespace than what WSE 3.0 understands.

 <wsse:Security soap:mustUnderstand="true" 
    xmlns:wsse="https://schemas.xmlsoap.org/ws/2002/07/secext" >
    <wsse:UsernameToken 
        xmlns:wsu="https://schemas.xmlsoap.org/ws/2002/07/utility">
        <wsse:Username>webserviceuser</wsse:Username>
        <wsse:Password Type="wsse:PasswordText">somepassword</wsse:Password>
        <wsu:Created>2006-01-29T11:10:03Z</wsu:Created>
    </wsse:UsernameToken>
</wsse:Security>

The problem is the namespace that is highlighted. WSE 3.0 doesn't know anything about that namespace, it expects security tokens to be contained within a wsse:Security element, bound to the OASIS namespace:

 <wsse:Security 
    xmlns:wsse="https://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">

When WSE 3.0 receives a message with a wsse:Security header bound to the draft namespace, it ignores it.  And, since we are using ASMX for the service, we get the mustUnderstand SoapHeader behavior, indicating that the header was not understood.  The challenge, then is to get WSE 3.0 to understand not only our custom token, but get it to recognize the draft WS-Security namespace for the wsse:Security element as well. 

I didn't really appreciate the WSE 3.0 policy framework until today, as I started coding with it.  I quickly realized just how much easier this framework is to work within to extend SOAP capabilities, easier than SoapExtensions (I still find the ChainStream thing unnatural).  Now that I have gotten a custom policy assertion under my belt, I am once again enamored with WSE's architecture.

We will keep the custom token and security token manager, it makes sense to keep that functionality contained in those classes. We will implement a custom policy assertion for WSE 3.0. There is a sample in the SDK that shows how to create a custom policy assertion, and I kept my eye on the example while writing my code, but it is so straight-forward that I didn't have to peer too long at the docs. 

Create a class that derives from PolicyAssertion.  That class provides 4 methods that you need to override, aptly named for their behavior:  CreateServiceInputFilter, CreateServiceOutputFilter, CreateClientInputFilter, and CreateClientOutputFilter.  These methods are analgous to what you would do to create a SoapExtension, checking the SoapMessageStage enumeration, but are more intuitive since you have the entire message at your disposal in a DOM object.  A client output filter represents a SOAP request (sending a message out) while the client input filter represents an incoming message (receiving a response).  The server has the opposite view:  a service input filter is an incoming request, and the service output filter represents the outgoing response.

 using System;
using Microsoft.Web.Services3.Design;
using Microsoft.Web.Services3;
using System.Xml;
using System.Xml.XPath;
using System.Collections.Generic;

namespace Msdn.Web.Services
{
    public class WSSEDraftPolicyAssertion : PolicyAssertion
    {
        private string _userName;
        private string _password;

        public override Microsoft.Web.Services3.SoapFilter CreateServiceInputFilter(FilterCreationContext context)
        {
            return new WSSEDraftServiceInputFilter();
        }

        public override Microsoft.Web.Services3.SoapFilter CreateServiceOutputFilter(FilterCreationContext context)
        {
            return new WSSEDraftServiceOutputFilter();
        }

        public override Microsoft.Web.Services3.SoapFilter CreateClientInputFilter(FilterCreationContext context)
        {
            return new WSSEDraftClientInputFilter();
        }

        public override Microsoft.Web.Services3.SoapFilter CreateClientOutputFilter(FilterCreationContext context)
        {
            return new WSSEDraftClientOutputFilter(_userName,_password);
        }

Before we go any further, we need to look at the ReadXml method. This wasn't called out very explicitly in the documentation, but is important.  ReadXml will read the configuration from wherever the policy is configured, which may be within the application's config file or in a separate policyCache.config file.  To save yourself time in trying to find where to load the values from, the ReadXml method accepts an XmlReader that is already positioned on the configuration data for your policy.  My policy uses a config with an element WSSEDraftAssertion, with two attributes.  To make the ReadXml method work, you *must* move the cursor forward, else your ReadXml method will be called again and again and again in an endless loop.

 
        public override void ReadXml(XmlReader reader, IDictionary<string, Type> extensions)
        {
            if (reader == null)
                throw new ArgumentNullException("reader");
            if (extensions == null)
                throw new ArgumentNullException("extensions");

            _userName = reader.GetAttribute("userName");
            _password = reader.GetAttribute("password");
            
            bool isEmpty = reader.IsEmptyElement;

            reader.ReadStartElement("WSSEDraftAssertion");
            if (!isEmpty)
            {
                reader.Skip();
            }
        }

The next bit of code to call out is the GetExtensions method. This registers your custom policy as the object that is called for a given configuration element name.  As stated above, the config element name I am using here is "WSSEDraftAssertion" with two attributes (one for the username, one for the password).  The following method signals to WSE 3.0 that this is the class to call GetXml on.

 
        public override IEnumerable<KeyValuePair<string, Type>> GetExtensions()
        {
            return new KeyValuePair<string, Type>[] { new KeyValuePair<string, Type>("WSSEDraftAssertion", this.GetType()) };
        }

Now that we have a pipeline of calls available, we just need to plug in our SoapFilter implementations.  Our ServiceInputFilter implementation is responsible for looking for the presence of our custom UsernameToken and calling the SecurityTokenManager implementation.  As you can see, I couldn't find a better way to indicate the SoapHeader was understood other than to remove it from the node.

         class WSSEDraftServiceInputFilter : SoapFilter
        {
            public override SoapFilterResult ProcessMessage(SoapEnvelope envelope)
            {
                //TODO:  The following will not work with SOAP 1.2
                XmlNamespaceManager nsmanager = new XmlNamespaceManager(envelope.NameTable);
                nsmanager.AddNamespace("wsse", "https://schemas.xmlsoap.org/ws/2002/07/secext");
                nsmanager.AddNamespace("soap", "https://schemas.xmlsoap.org/soap/envelope/");
                XmlNodeList tokens = envelope.SelectNodes("/soap:Envelope/soap:Header/wsse:Security/wsse:UsernameToken", nsmanager);
                if (tokens.Count > 0)
                {
                    XmlElement tokenNode = (XmlElement)tokens[0];
                    WSSEDraftToken token = new WSSEDraftToken(tokenNode);
                    WSSEDraftSecurityTokenManager manager = new WSSEDraftSecurityTokenManager();
                    manager.VerifyToken(token);                    
                }

                //TODO:  This is bad.  There's got to be some way to indicate
                //  did understand without tearing the mustUnderstand attribute off.
                XmlElement wssedraft = (XmlElement)envelope.SelectNodes("/soap:Envelope/soap:Header/wsse:Security", nsmanager)[0];
                wssedraft.Attributes.Remove( (XmlAttribute)wssedraft.Attributes.GetNamedItem("mustUnderstand","https://schemas.xmlsoap.org/soap/envelope/"));
                
                return SoapFilterResult.Continue;
            }
        }

The ServiceOutputFilter is largely uninteresting, just returning the timestamp in the draft utility namespace.

         class WSSEDraftServiceOutputFilter : SoapFilter
        {
            public override SoapFilterResult ProcessMessage(SoapEnvelope envelope)
            {
                XmlElement header = envelope.CreateHeader();
                XmlElement timestampElement = envelope.CreateElement("wsu", "Timestamp", TokenNames.UtilityURI);
                
                XmlElement createdElement = envelope.CreateElement("wsu", "Created", TokenNames.UtilityURI);
                createdElement.InnerText = XmlConvert.ToString(System.DateTime.Now, "yyyy-MM-ddTHH:mm:ssZ");
                timestampElement.AppendChild(createdElement);

                XmlElement expiresElement = envelope.CreateElement("wsu", "Expires", TokenNames.UtilityURI);
                expiresElement.InnerText = XmlConvert.ToString(System.DateTime.Now.AddMinutes(5), "yyyy-MM-ddTHH:mm:ssZ");
                timestampElement.AppendChild(expiresElement);

                header.AppendChild(timestampElement);

                return SoapFilterResult.Continue;
            }
        }

        class WSSEDraftClientInputFilter : SoapFilter
        {
            public override SoapFilterResult ProcessMessage(SoapEnvelope envelope)
            {
                //TODO:  Does it matter if the message has expired?
                return SoapFilterResult.Continue;
            }
        }

The ClientOutputFilter is the coolest part of WSE 3.0. This is what makes it possible to secure a client application completely through policy, making security decisions a deployment consideration rather than forcing developers to try to implement the security themselves. This SoapFilter creates the draft version of the wsse:Security element (remember that this was the reason that the UsernameToken wouldn't just work before).

         class WSSEDraftClientOutputFilter : SoapFilter
        {
            string _userName;
            string _password;

            internal WSSEDraftClientOutputFilter(string userName, string password)
            {
                _userName = userName;
                _password = password;
            }

            public override SoapFilterResult ProcessMessage(SoapEnvelope envelope)
            {
                XmlElement securityElement = envelope.CreateElement("wsse", "Security", TokenNames.NamespaceURI);
                
                XmlAttribute mustUnderstandAttribute = envelope.CreateAttribute(envelope.DocumentElement.Prefix, "mustUnderstand", envelope.DocumentElement.NamespaceURI);
                mustUnderstandAttribute.Value = "true";
                securityElement.Attributes.Append(mustUnderstandAttribute);

                WSSEDraftToken token = new WSSEDraftToken(_userName, _password);
                securityElement.AppendChild(token.GetXml(envelope));

                envelope.CreateHeader().AppendChild(securityElement);
                                
                return SoapFilterResult.Continue;
            }
        }
    }
}

Now that we have the custom policy, which calls our TokenManager, which validates our custom token, we need to configure it. WSE 3.0 configuration is really simple if you just use the configuration tool from within Visual Studio 2005, but I'll list the config for simplicity. The application's .config file will point to a policy cache document.

 <?xml version="1.0" encoding="utf-8"?>
<configuration>
  <configSections>
    <section name="microsoft.web.services3" type="Microsoft.Web.Services3.Configuration.WebServicesConfiguration, Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
  </configSections>
  <microsoft.web.services3>
    <diagnostics>
      <trace enabled="true" input="InputTrace.webinfo" output="OutputTrace.webinfo" />
    </diagnostics>
    <policy fileName="wse3policyCache.config" />
  </microsoft.web.services3>
</configuration>

Here is what the wse3policycache.config document looks like.

 <policies xmlns="https://schemas.microsoft.com/wse/2005/06/policy">
    <extensions>
        <extension name="WSSEDraftAssertion" type="Msdn.Web.Services.WSSEDraftPolicyAssertion,Msdn.Web.Services"/>
    </extensions>
    <policy name="client">
        <WSSEDraftAssertion userName="webserviceuser" password="web" />
    </policy>
</policies>

Now, for what I think is the coolest part of the whole thing.  To secure our service from the client, we use an ASMX web service as usual, but we use the Microsoft.Web.Services3.WebServicesClientProtocol generated proxy.

 localhost.ServiceWse s = new ConsoleApplication1.localhost.ServiceWse();
s.SetPolicy("client");
string ret = s.HelloWorld();

Even better, here is what our service looks like. We configured our policy on the server, and just use the PolicyAttribute to specify the policy. Note the distinct lack of new ASMX headers or the need to know anything about security... we can truly make security a deployment consideration and separate it from the service implementation.

 using System;
using System.Web.Services;
using System.Web.Services.Protocols;
using Microsoft.Web.Services3;

[WebService(Namespace = "https://blogs.msdn.com/kaevans")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
[Policy("serverPolicy")]
public class Service : System.Web.Services.WebService
{
    [WebMethod]
    [SoapDocumentMethod(ParameterStyle = SoapParameterStyle.Bare)]    
    public string HelloWorld() 
    {
        return "Hello World";
    }
}

This was a really cool bit of code to write, mostly because it was so simple to write.  Yes, it was more verbose than both the custom ASMX header approach and the SoapExtension approach, but it yielded the easiest approach that will be the most flexible and easiest to configure in the long run.  The WSE 3.0 approach has the added benefit of allowing the customer to begin writing services in WSE 3.0 using the industry-standard WS-* specs and provides a migration path going forward to Indigo.

Comments

  • Anonymous
    January 30, 2006
    Adding Blogging to Your Apps with My.Blogs and Visual Basic
    2005 [Via: ]
    Ajax Talk: Washington DC...
  • Anonymous
    February 10, 2006
    Gosh, this is exactly where i'm at on some WS stuff as well. I want to use a service as an intermediary for logging and what not.
    I didnt expect i would have to lose the mustUnderstand attribute, but i suppose, during the actual handling of the method call, i can replace the attribute.
    Thanks for the insight though. I agree, the Policy Assertion method is pretty cool. Simple and straightforward.