Solving a WS-Oops With ASMX 2.0

A customer emailed asking about WSE 1.0 support in .NET 2.0. Unfortunately, WSE 1.0 is no longer supported and is untested on .NET 2.0.  Customers are urged to migrate to WSE 3.0.

The customer has a number of external consumers hitting their existing WSE 1.0 services. WSE 1.0 was an implementation of the draft version of WS-Security, using the namespace https://schemas.xmlsoap.org/ws/2002/07/secext. WSE 3.0 implements the OASIS standards WS-Security 1.0 and WS-Security 1.1. WS-Security 1.0 uses the namespace https://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd. The XML understood by WSE 1.0 is different than the XML understood by WSE 3.0. Consider the simple UsernameToken with a plain text password. Here is the XML representation of a UsernameToken using the draft version:

   
<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>

Here is the same UsernameToken according to WS-Security 1.0:

 <wsse:Security 
    soap:mustUnderstand="1" 
    xmlns:wsse="https://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
    <wsu:Timestamp 
        wsu:Id="Timestamp-130c1faf-3ad2-45f8-95ae-469da57499f8"
        xmlns:wsu="https://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
      <wsu:Created>2006-01-29T15:52:15Z</wsu:Created>
      <wsu:Expires>2006-01-29T15:57:15Z</wsu:Expires>
    </wsu:Timestamp>
    <wsse:UsernameToken 
        xmlns:wsu="https://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" 
        wsu:Id="SecurityToken-dd604922-abdd-456e-ab42-aa7a3a6875f8">
      <wsse:Username>bob</wsse:Username>
      <wsse:Password 
        Type="https://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">somepassword</wsse:Password>
      <wsse:Nonce>j3yagQYshmv9tv/8vz3fhg==</wsse:Nonce>
      <wsu:Created>2006-01-29T15:52:15Z</wsu:Created>
    </wsse:UsernameToken>
</wsse:Security>

The customer now has a problem. They have lots of customers that are calling their web services using the draft version of WS-Security. The way to do that is through WSE 1.0, which doesn't run on .NET 2.0. WSE 3.0 runs on .NET 2.0, but it doesn't understand the dialect of XML that WSE 1.0 generates.  We need to enable both the customer who owns the service as well as their clients to leverage .NET 2.0... but how to do that? We can't let something as simple as a web service be a blocker for .NET 2.0 adoption.

Luckily, the customer is using UsernameTokens with plain text over SSL, so we don't have to worry about how to enable signatures or encryption. I am going to post several solutions on how to solve this problem, the first being the creation of an ASMX SOAP header.

The first task in creating our simple SoapHeader is to create a set of XML serializable classes, the root of which should derive from System.Web.Services.Protocols.SoapHeader:

 [XmlRoot("Security",Namespace="https://schemas.xmlsoap.org/ws/2002/07/secext")]
public class WSSEDraftSecurityHeader : SoapHeader
{
    private WSSEDraftUsernameTokenHeader _header;

    [XmlElement("UsernameToken",Namespace="https://schemas.xmlsoap.org/ws/2002/07/secext")]
    public WSSEDraftUsernameTokenHeader Header
    {
        get { return _header; }
        set { _header = value; }
    }   
}

public class WSSEDraftUsernameTokenHeader
{
    private string _userName;
    private string _password;
    private DateTime _created;

    [XmlElement("Username", Namespace="https://schemas.xmlsoap.org/ws/2002/07/secext")]
    public string Username
    {
        get { return _userName; }
        set { _userName = value; }
    }

    [XmlElement("Password", Namespace="https://schemas.xmlsoap.org/ws/2002/07/secext")]
    public string Password
    {
        get { return _password; }
        set { _password = value; }
    }

    [XmlElement("Created", Namespace="https://schemas.xmlsoap.org/ws/2002/07/utility")]
    public DateTime Created
    {
        get { return _created; }
        set { _created = value; }
    }
}

The next step is to implement the SOAP header in our web service.  ASMX 2.0 has added the great feature of allowing you to define an interface for your web service. We will use the interface to define the contract for our service, including the header. The SoapHeaderAttribute requires us to provide the name of the member variable that implements the SoapHeader, which we can control on our interface to make the behavior consistent across implementations of our interface. Our interface will specify that the HelloWorld method will require the UsernameToken header to accompany messages to it, and the UsernameToken header is provided in the "Security" property.

 [WebService(Namespace = "https://blogs.msdn.com/kaevans")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
public interface IMyWebService
{
    WSSEDraftSecurityHeader Security
    {
        get;
        set;
    }
    
    [WebMethod]
    [SoapHeader("Security",Direction=SoapHeaderDirection.In)]
    string HelloWorld();
}

The next step is to implement the interface.

 public class WebService : IMyWebService
{
    private WSSEDraftSecurityHeader _security;

    public WSSEDraftSecurityHeader Security
    {
        get {return _security;}
        set {_security = value;}
    }
    
    public string HelloWorld()
    {
        if (null == Security)
        {
            throw new SoapException("Security information is required", SoapException.ClientFaultCode);
        }
        else
        {
            if ((Security.Header.Username == "kirk") && (Security.Header.Password == "pass@word1"))
            {
                Security.DidUnderstand = true;
            }
            else
            {
                throw new SoapException("Unable to authenticate credentials", SoapException.ClientFaultCode);
            }
        }
        return "Hello World";        
    }
}

This is not a complete solution, since we don't return a timestamp, we hardcoded the security check, and we are stuck with the least secure type of authentication in WS-Security with plain-text UsernameTokens. It also has a code smell because we have to explicitly call authentication code when our method is called.  However, this simple approach does help us get over the block of not being able to use .NET 2.0 for our services while continuing to accept messages based on the WS-Security draft from our existing customers.

Still, I look at this solution and can't help but cringe a little. If you were to call this web service from a client, the code is a little verbose.

 localhost1.IMyWebService s2 = new ConsoleApplication1.localhost1.IMyWebService();
localhost1.WSSEDraftSecurityHeader header = new ConsoleApplication1.localhost1.WSSEDraftSecurityHeader();
header.UsernameToken = new ConsoleApplication1.localhost1.WSSEDraftUsernameTokenHeader();
header.UsernameToken.Created = System.DateTime.Now;
header.UsernameToken.Username = "kirk";
header.UsernameToken.Password = "pass@word1";
s2.Security = header;
Console.WriteLine(s2.HelloWorld());

I will post a more elegant solution using WSE 3.0 that will not require us to decorate our services or explicitly check for the username and password. WSE 3.0's policy framework will provide a solution that takes a little more code to write but provides a higher level of reusability and will greatly simplify how developers would secure their services using a custom WSE 3.0 policy.