Wednesday, April 19, 2017

Proxy Windows Authentication with a WCF Relay Service

Motivation

The problem I'm going to address here is pretty specific, and the solution I came up with isn't terribly intuitive, but it works.  I ran into a situation where we were trying to integrate web services with a vendor product.  This is a .NET app running on IIS, and we wanted to authenticate using Active Directory by passing in a username and password to a "UserLogin" soap action (this action returns a security token that is used to make other calls).  While initially we thought this worked, it turned out that it wasn't behaving the way we thought it should.  Any request was getting a security token, even if the username and password were garbage.

Yeah... not what we were going for...

After the systems guy fiddled with it for a while, with the help of the vendor, we settled on using Windows authentication at the IIS level to secure the service.  This was certainly secure, in the same sense that turning the whole thing off would be.  Trying to make a call to a web service with a tool like SoapUI using Kerberos authentication is a real pain, and our ESB product doesn't support it at all (we'd have to write a custom Java component using CFX to even have a shot).  This was going to create a lot of pain for us long term if we couldn't come up with something else.

My idea for a solution was to create some kind of "proxy" service, that could live next to the vendor product on the same IIS server.  This proxy or "relay" as I came to call it, would intercept "UserLogin" calls, extract the username and password, and somehow log in with the supplied creds to the domain and get a proper credential to attach to the request.  Any other request could be forwarded using a service account credential, since the only way to get a valid security token would be to go through the UserLogin action, and the vendor product would return an error without a valid token.



Now if the user supplies invalid credentials, the application server hosting the vendor product will never see the request.  No request means no security token, which means no access for forwarded calls to other methods.  If they try to call another action (not UserLogin), with a fake, expired, or otherwise invalid security token, the vendor service would return an error to the effect of "Not logged in, heck off".



To make this happen, I had a few questions to answer:

  • How do I create a WCF relay service?
  • How do I log in an arbitrary username and password against a domain?
  • How do I serve up the wsdl?

All three questions needed to be addressed if I hoped to have a 100% functional solution.



The Relay Service


The first thing I needed to do was figure out how I could create a service that just forwarded everything wholesale to another service.  If I got that figured out, I could start fiddling around with the message, adding authentication and all that.  Google didn't let me down, and I soon stumbled across a Stack Overflow question that gave me just what I needed.  This is also when I started thinking of the service as being a relay instead of a proxy.  So I end up with a stupid simple service contract:

    [ServiceContract]
    public interface IServiceRelay
    {
        [OperationContract(Action = "*", ReplyAction = "*")]
        Message Relay(Message msg);
    }


And an initial implementation that doesn't do much, but at least it gives me a seam I can work with:

    public Message Relay(Message message)
    {
        Uri dest = 
		    new Uri(ConfigurationManager.AppSettings.Get("outbound.endpoint"));
        MessageBuffer buffer = message.CreateBufferedCopy(524288);
        Message output = buffer.CreateMessage();
        output.Headers.To = dest;

        BasicHttpBinding binding = new BasicHttpBinding("Outbound");
        
        IChannelFactory<IRequestChannel> factory = 
		binding.BuildChannelFactory<IRequestChannel>(dest);
        factory.Open();

        EndpointAddress endpoint = new EndpointAddress(dest);
        IRequestChannel channel = factory.CreateChannel(endpoint);
        channel.Open();

        result = channel.Request(output);

        factory.Close();
        channel.Close();
        message.Close();
        output.Close();

        return result;
    }


I actually added the ConfigurationManager stuff much later, but it keeps it nice and clean so I'll pretend I started it out that way.



Windows Impersonation


It turns out that logging in with Windows programmatically is not simple.  It requires making a call to unmanaged code, a process I've never encountered before.  Luckily I found a few good resources, such as this SO question and this article from 2008 (which is probably where the SO answers got it from anyway).  I defined a separate class for the Win32 calls:

    class External
    {
        [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
        public static extern bool LogonUser(String lpszUsername, 
            String lpszDomain, String lpszPassword, int dwLogonType, 
            int dwLogonProvider, out IntPtr phToken);
    }

    public enum LogonType
    {
        LOGON32_LOGON_INTERACTIVE = 2,
        LOGON32_LOGON_NETWORK = 3,
        LOGON32_LOGON_BATCH = 4,
        LOGON32_LOGON_SERVICE = 5,
        LOGON32_LOGON_UNLOCK = 7,
        LOGON32_LOGON_NETWORK_CLEARTEXT = 8, // Win2K or higher
        LOGON32_LOGON_NEW_CREDENTIALS = 9 // Win2K or higher
    }

    public enum LogonProvider
    {
        LOGON32_PROVIDER_DEFAULT = 0,
        LOGON32_PROVIDER_WINNT35 = 1,
        LOGON32_PROVIDER_WINNT40 = 2,
        LOGON32_PROVIDER_WINNT50 = 3
    }


I put the calls to External in a helper class, and added some caching so that I wasn't spamming the domain controller with duplicate login requests (most of the requests would use identical credentials in production since the service is actually being called by a service account anyway).  While I didn't like configuring the service account credentials in the app.config file, I didn't see another option, since only the calls to the UserLogin action include the username and password, and the security token changes with every request (so if I wanted to cache the Windows Identity object based on the security token, I'd have to parse every single response message, which is exactly what I'm trying to avoid... this is supposed to be a dumb relay):

class AuthHelper
{
    internal static WindowsIdentity Login(
        string username, string password, string domain)
    {
        //get default instance of MemoryCache
        ObjectCache cache = MemoryCache.Default;

        //use + and () as delineator: 
        // + is an invalid char in usernames
        // () are invalid in domain names
        string key = String.Format("(%s)+(%s)+(%s)", username, password, domain);

        //attempts to load the contents of the cache item
        WindowsIdentity serviceAcct = cache[key] as WindowsIdentity;

        //if this value is null, then the windows identity for the provided 
        //credentials was not found in cache
        if (serviceAcct == null)
        {
            IntPtr userToken = IntPtr.Zero;

            bool success = External.LogonUser(
              username,
              domain,
              password,
              (int)LogonType.LOGON32_LOGON_INTERACTIVE, //2
              (int)LogonProvider.LOGON32_PROVIDER_DEFAULT, //0
              out userToken);

            serviceAcct = new WindowsIdentity(userToken);

            //create a caching policy object with configured duration in minutes
            CacheItemPolicy policy = new CacheItemPolicy();
            double minutes = 1.0;
            Double.TryParse(ConfigurationManager.AppSettings
                .Get("cachepolicy.expiration.minutes"), out minutes);
            policy.AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(minutes);
            cache.Set(key, serviceAcct, policy);
        }
        return serviceAcct;
    }

    internal static WindowsIdentity GetServiceAcct()
    {
        string username = ConfigurationManager.AppSettings
            .Get("serviceaccount.username");
        string password = ConfigurationManager.AppSettings
            .Get("serviceaccount.password");
        string domain = ConfigurationManager.AppSettings
            .Get("serviceaccount.domain");
        return Login(username, password, domain);
    }
}


Now that I had these tools, I could create an impersonation context using the Windows Identity created with the supplied username and password.  Within this context, I can get the default network credential for the impersonated user, attach that to the request, and forward the request on to the actual service.  Then it's just a matter of closing everything and returning the result back to the caller.


public Message Relay(Message message)
{
    Uri dest = new Uri(ConfigurationManager.AppSettings.Get("outbound.endpoint"));
    MessageBuffer buffer = message.CreateBufferedCopy(524288);
    Message output = buffer.CreateMessage();
    output.Headers.To = dest;

    WindowsIdentity serviceAcct; 
    if (output.Headers.Action == "http://example.com/webservice/UserLogin")
    {
        //we have to reuse the "buffer" to create a copy message here because  
        //the Message class can only be read or copied once, ever...
        string content = buffer.CreateMessage().GetReaderAtBodyContents().ReadOuterXml();
        XDocument xml = XDocument.Parse(content);

        //we look for subelements with "username" and "password" contained in their 
        //names, ignoring case. I used "contains" to avoid issues with namespacing,
        //but it may be unnecessary
        string username = xml.Root.Descendants()
            .Where( x => x.Name.LocalName.ToLower()
            .Contains("username")).First().Value;
        string password = xml.Root.Descendants()
            .Where(x => x.Name.LocalName.ToLower()
            .Contains("password")).First().Value;
        string domain = ConfigurationManager.AppSettings.Get("serviceaccount.domain");

        serviceAcct = AuthHelper.Login(username, password, domain);
    }
    else
    {
        serviceAcct = AuthHelper.GetServiceAcct();
    }
    
    //create an impersonation context within a using block to automatically 
    //restore default security context when finished impersonating
    Message result = null;
    using (serviceAcct.Impersonate())
    {

        //creating an HttpBinding with Transport Security set to "Windows"
        BasicHttpBinding binding = new BasicHttpBinding("Outbound");
        
        //can't use the IChannelFactory here, because we need to set the Credential
        ChannelFactory<IRequestChannel> factory = 
            new ChannelFactory<IRequestChannel>(binding);
        factory.Credentials.Windows.ClientCredential = 
            CredentialCache.DefaultNetworkCredentials;
            
        //I don't know why impersonation leven needed to be set to Delegation, but it 
        //would not work otherwise
        factory.Credentials.Windows.AllowedImpersonationLevel = 
            TokenImpersonationLevel.Delegation;
        factory.Open();

        //the rest is straightforward, point channel at destination, 
        //open channel, send request. Boom!
        EndpointAddress endpoint = new EndpointAddress(dest);
        IRequestChannel channel = factory.CreateChannel(endpoint);
        channel.Open();

        result = channel.Request(output);

        factory.Close();
        channel.Close();
    }

    message.Close();
    output.Close();

    return result;
}


Don't get the wrong idea here, I didn't magically pull this code out of my ass, polished and ready to go.  I futzed around with it for hours, trying all kinds of different crap.  Some of the lessons learned:

  • It isn't enough to make the request within an impersonation context, you have to actually attach a credential somehow.
  • ICredentialFactory doesn't give you any way to attach a credential.
  • The vendor gave us a code snippet for an "adapter" style class, but this was no help since it would require me to add a service reference to the target service and basically implement a full proxy, complete with passthrough methods for all the dozens of available methods on the service... no thanks =(
  • While you can get a WindowsIdentity object just by passing in a username to the WindowsIdentity constructor, you can't use this object for impersonation.  
  • The "Message" class can only be read or copied once... which is real annoying when you need to tease information out of it, but still pass it up or down the chain.
  • While you can do this with an HttpWebRequest... it's ugly.  Avoid it...




The WSDL, or REST in WCF


The last part of this puzzle was taking care of serving up the wsdl file.  For the uninitiated, the wsdl is an xml document that describes a web service.  Many tools that consume web services can be pointed at a wsdl and immediately understand how to communicate with it, maybe generating a client proxy, what have you.  So it's pretty important for integration scenarios like we are dealing with.

Having got this far, I thought the hardest parts were behind me and this last bit would be a piece of cake. While I would say the Windows auth stuff was, indeed, marginally harder, trying to figure out how to forward a simple GET request proved to be surprisingly difficult.  Mostly just because it was about impossible to find relevant reference material online.

While what I really wanted to do was spoof the wsdl for the proxy service, I ended up settling for creating a second service, parked on a logical route, that simply requests the wsdl from the prod system (using the impersonated service account credential) and streams it back to the caller.  I got kind of cute with the streams on this one, which actually made for an elegant solution as I wasn't trying to parse and re-stream a bunch of xml text.

The service contract is just as simple as the service relay:


    [ServiceContract]
    interface IWsdlRelay
    {
        [OperationContract(Action = "*", ReplyAction = "*")]
        [WebGet(UriTemplate = "")]
        Stream Wsdl();
    }


The implementation is deceptively simple.  It took me forever to figure out how to get everything configured right in order for this to work correctly.


    public Stream Wsdl()
    {
        WebOperationContext.Current.OutgoingResponse.Format = WebMessageFormat.Xml;
        WebOperationContext.Current.OutgoingResponse.ContentType = "text/xml";

        string url = ConfigurationManager.AppSettings.Get("outbound.endpoint");
        url += "?wsdl";
        Uri dest = new Uri(url);

        HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(dest);
        using (AuthHelper.GetServiceAcct().Impersonate())
        {     
            request.Method = "GET";
            request.UseDefaultCredentials = true;
            request.PreAuthenticate = true;
            request.Credentials = CredentialCache.DefaultNetworkCredentials;
        }

        return request.GetResponse().GetResponseStream();
    }



Configuration


Half the battle with getting all this to work is having the application configured correctly.  This is basically what my final app.config file looked like (names have been changed to protect the innocent):


<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
  </configSections>
  <appSettings>
    <add key="aspnet:UseTaskFriendlySynchronizationContext" value="true" />
    <add key="serviceaccount.username" value="SERVICEACCOUNT"/>
    <add key="serviceaccount.password" value="PASSWORD"/>
    <add key="serviceaccount.domain" value="example.com"/>
    <add key="outbound.endpoint" value="https://example.com/webservice/WebService.asmx"/>
    <add key="cachepolicy.expiration.minutes" value="1.0"/>
  </appSettings>
  <system.serviceModel>
    <bindings>
      <basicHttpBinding>
        <binding name="Outbound">
          <security mode="Transport">
            <transport clientCredentialType="Windows" 
                proxyCredentialType="None" realm=""/>
          </security>
        </binding>
      </basicHttpBinding>
    </bindings>
    <services>
      <service name="Proxy.ServiceRelay">
        <endpoint address="" binding="basicHttpBinding" contract="Proxy.IServiceRelay">
          <identity>
            <dns value="localhost" />
          </identity>
        </endpoint>
        <endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange" />
        <host>
          <baseAddresses>
            <add baseAddress="http://localhost:8733/webservice/WebService.asmx" />
          </baseAddresses>
        </host>
      </service>
      <service name="Proxy.WsdlRelay">
        <endpoint address="wsdl" binding="webHttpBinding" contract="Proxy.IWsdlRelay" 
           behaviorConfiguration="poxBehavior"/>
        <host>
          <baseAddresses>
            <add baseAddress="http://localhost:8733/webservice/WebService" />
          </baseAddresses>
        </host>
      </service>
    </services>
    <behaviors>
      <serviceBehaviors>
        <behavior>
          <serviceMetadata httpGetEnabled="True"/>
          <!-- To receive exception details in faults for debugging purposes, 
          set the value below to true.  Set to false before deployment 
          to avoid disclosing exception information -->
          <serviceDebug includeExceptionDetailInFaults="False" />
        </behavior>      
      </serviceBehaviors>
      <endpointBehaviors>
        <behavior name="poxBehavior">
          <webHttp defaultOutgoingResponseFormat="Xml"/>
        </behavior>
      </endpointBehaviors>
    </behaviors>
  </system.serviceModel>
</configuration>


We've yet to deploy this solution (too close to a launch, but hopefully right after).  Since it will be living in the same IIS instance as the production service, I should be able to do the relaying all locally (so basically the target would be "https://localhost:443/webservice/WebService.asmx" and the relay base url something like "https://localhost:443/webservice.proxy/WebService.asmx").

No comments:

Post a Comment