Sunday, March 05, 2006

WSE3 and ASP.NET Membership Provider authentication

I am not an expert in WSE3 by anyone's definition.  So when I went looking for a way to add authentication requirements to my web service that tied into the ASP.NET 2.0 Membership Provider model class that I had already written for my web site, I was sorely disappointed when I could not find any articles on that exact topic.  One very good MSDN article came close, but just like all the articles I saw, it seemed to focus more on using Active Directory authentication, or heavy message-level encryption, or some other advanced feature I did not need or want.

My primary focus was to write a web service that required authentication while preserving the ability to serve to PHP and Java clients that have not yet been graced by WSE3.  I would use SSL for encryption to simplify the process.  But since no one covered the topic of what I would do, I finally managed to adapt the article mentioned above to what I was doing and I share what I have learned with you.  I still have not managed to get a PHP web service client to call my WSE3 web service though.

It seems like a very common situation to want to have your web services simple to access, yet require authentication through the same mechanism that your ASP.NET Forms Authentication uses.  So I hope my findings will help you.
  1. Visual Studio 2005 should already be installed, and not running.
  2. Install the WSE 3 extensions for Visual Studio.  During the install, be sure to install all the development tools.
  3. Open your Visual Studio 2005 solution file.
  4. Configure your project WSE Settings:
    1. Right-click on your web project, and click WSE Settings 3.0.  If this is your first time clicking this, a wizard will start.  The rest of these steps assume you've been through the wizard, and that the regular settings box appears.  If you get the wizard, try to figure out how to apply these next steps to the wizard, and then come back to the settings box afterward to make sure all is well.  Sorry for the confusion here.
    2. Under the General tab, check both the Enable this project for Web Services Enhancements and Enable Microsoft Web Services Enhancement Soap Protocol Factory.
    3. Under the Security tab, in the Security Tokens Managers area, click Add.
      1. Fill in the fields as follows:
      1. Type: "CustomUsernameTokenManager, __code"
      2. Namespace: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
      3. LocalNode: "UsernameToken"
      4. Click OK.
    4. Under the Policy tab, check Enable Policy, and click Add.
      1. Name your new application policy "usernameTokenSecurity".
    5. Click OK to the WSE Settings dialog box.  This will add a new file called wse3policyCache.config to your web project.
  5. Customize the contents of your new wse3policyCache.config file.
    1. It should start looking like this:
      <policies xmlns="http://schemas.microsoft.com/wse/2005/06/policy">
      <extensions>
      <extension name="usernameOverTransportSecurity" type="Microsoft.Web.Services3.Design.UsernameOverTransportAssertion, Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
      <extension name="requireActionHeader" type="Microsoft.Web.Services3.Design.RequireActionHeaderAssertion, Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
      </< span>extensions>
      <policy name="usernameTokenSecurity">
      <usernameOverTransportSecurity />
      <requireActionHeader />
      </< span>policy>
      </< span>policies>
    2. Add an section if you would like to require your web service consumers to belong to a specific role or roles.  It is very important that you put the tag above your tag.  When you're done, it may look something like this:
      <policies xmlns="http://schemas.microsoft.com/wse/2005/06/policy">
      <extensions>
      <extension name="usernameOverTransportSecurity" type="Microsoft.Web.Services3.Design.UsernameOverTransportAssertion, Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
      <extension name="requireActionHeader" type="Microsoft.Web.Services3.Design.RequireActionHeaderAssertion, Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
      </< span>extensions>
      <policy name="usernameTokenSecurity">
      <authorization>
      <allow role="webserviceprofessional"/>
      <deny role="*"/>
      </< span>authorization>
      <usernameOverTransportSecurity />
      <requireActionHeader />
      </< span>policy>
      </< span>policies>
  6. Create your CustomUsernameTokenManager class in your web project.
    1. In your web project's App_Code directory, create a CustomUsernameTokenManager.cs file.
    2. Copy and paste this code into your CustomUsernameTokenManager.cs file:
      using System;
      using System.Xml;
      using System.Web.Security;
      using System.Security.Permissions;
      using System.Security.Principal;

      using Microsoft.Web.Services3.Security;
      using Microsoft.Web.Services3.Security.Tokens;

      ///
      /// By implementing UsernameTokenManager we can verify the signature
      /// on messages received.
      ///

      [SecurityPermissionAttribute(SecurityAction.Demand, Flags = SecurityPermissionFlag.UnmanagedCode)]
      public class CustomUsernameTokenManager : UsernameTokenManager
      {
      ///
      /// Constructs an instance of this security token manager.
      ///

      public CustomUsernameTokenManager()
      {
      }

      ///
      /// Constructs an instance of this security token manager.
      ///

      /// <param name="nodes" />An XmlNodeList containing XML elements from a configuration file.</param>
      public CustomUsernameTokenManager(XmlNodeList nodes)
      : base(nodes)
      {
      }

      ///
      /// Returns the password or password equivalent for the username provided.
      /// Adds a principal to the token with user's roles.
      ///

      /// <param name="token" />The username token</param>
      /// The password (or password equivalent) for the username
      protected override string AuthenticateToken(UsernameToken token)
      {
      bool validCredentials = Membership.ValidateUser(token.Username, token.Password);
      if (!validCredentials) throw new UnauthorizedAccessException();

      GenericIdentity identity = new GenericIdentity(token.Username);
      GenericPrincipal principal = new GenericPrincipal(identity, Roles.GetRolesForUser(token.Username));
      token.Principal = principal;

      return token.Password;
      }
      }
  7. As a reminder, I am assuming you already have ASP.NET 2.0 Membership and Roles providers set up in your web site and included in your Web.config file.  If not, you might as well stop here and get that working first.  And an explanation of how to do so is beyond the scope of this particular blog.
  8. Make sure that the following segments appear in your Web.config file.  Pay attention to detail.  I'm pretty sure one of the problems I struggled with was that just one of these lines were not automatically put in by the WSE Settings dialog, and this only works if it's all there.
    <configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0">
    <configSections>

    <section name="microsoft.web.services3" type="Microsoft.Web.Services3.Configuration.WebServicesConfiguration, Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
    </< span>configSections>

    <system.web>
    <compilation debug="true">
    <assemblies>

    <add assembly="Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
    </< span>assemblies>
    </< span>compilation>

    <webServices>
    <soapExtensionImporterTypes>
    <add type="Microsoft.Web.Services3.Description.WseExtensionImporter, Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
    </< span>soapExtensionImporterTypes>
    <soapServerProtocolFactory type="Microsoft.Web.Services3.WseProtocolFactory, Microsoft.Web.Services3, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
    </< span>webServices>
    </< span>system.web>

    <microsoft.web.services3>
    <policy fileName="wse3policyCache.config" />
    <security>
    <securityTokenManager>
    <add type="CustomUsernameTokenManager, __code" namespace="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" localName="UsernameToken" />
    </< span>securityTokenManager>
    </< span>security>
    </< span>microsoft.web.services3>
    </< span>configuration>
  9. Now open up each of your web service classes (either your .asmx files or associated code-behind files) and add the following class attribute to your web service class:
    [Microsoft.Web.Services3.Policy("usernameTokenSecurity")]
  10. You are now done configuring you web service to authenticate each web service request via a WSE 3 SOAP header, relying only on encryption provided by your transport (HTTPS, for example).
Now to configure a client to test your new web service's authentication feature:
  1. Configure your client project for WSE.
    1. Right-click on your client project in Visual Studio and click WSE Settings 3.0.  If this is your first time clicking this, a wizard will start.  The rest of these steps assume you've been through the wizard, and that the regular settings box appears.  If you get the wizard, try to figure out how to apply these next steps to the wizard, and then come back to the settings box afterward to make sure all is well.  Sorry for the confusion here.
    2. Under the General tab, check "Enable this project for Web Service Enhancements".
    3. Under the Policy tab, check "Enable Policy" and add a policy called "usernameTokenSecurity".
    4. The wse3policyCache.config file should look like this:
  2. Add a couple of using statements to the file that you will be scripting your client in:
    using Microsoft.Web.Services3.Security;
    using Microsoft.Web.Services3.Security.Tokens;
  3. And if your WSE-enabled web service was called RIRegistration, you would use the following code to call its ValidateLogin method:
    RIRegistrationWse wse = new RIRegistrationWse();
    UsernameToken token = new UsernameToken(username, password, PasswordOption.SendPlainText);
    wse.SetClientCredential(token);
    wse.SetPolicy("usernameTokenSecurity");
    // Now you can call methods repeatedly, and the authentication
    // is automatically passed to the server each time.
    Debug.Assert(user.Username, wse.ValidateLogin());
  4. Notice how we call ValidateLogin without passing any parameters, yet it returns the username of the user you are logging in with. Let's see how this ValidateLogin method might be implemented:
    [WebMethod(Description = "SOAP header test.  If successful, it will return the username of the logged in user.")]
    public string ValidateLogin()
    {
    return Microsoft.Web.Services3.RequestSoapContext.Current.IdentityToken.Identity.Name;
    }
  5. Run your web client.  You should get the username you passed to authenticate back from the web method. 
  6. Try changing to an invalid credentials.  Your ASP.NET Membership provider should reject the login and an exception will be thrown to the client before your web method is ever called.
  7. If you added an section to your wse3policyCache.config file, try changing to credentials that are valid but do not belong to a required role.  Verify that your web service likewise rejects the invocation.
I hope this is helpful to you.  Feel free to ask questions, if your project is pretty close to the situation I describe.  I am still pretty new to this myself, so if your project deviates from this path much, I'm afraid I won't be able to help you.