Brothers In Code

...a serious misallocation of .net resources

Getting Active Directory Information From ASP.net The Easy Way

A couple years ago I wrote a short article on an old blog about connecting to Active Directory from an ASP.net project.  I thought the article was fairly complete until I tried to help somebody else do the same thing.  I had forgotten two major parts:

  • Manually sifting thru the different AD containers to find the right LDAP string. 
  • Ensuring the correct security context.

The security context can be an easy thing.  A console or winforms .net app runs in the context of the currently logged on user.  For simplicity the ASP.Net development server in Visual Studio does the same thing.  So as long as your user account as access to the AD (which is the norm unless your AD admin has locked down querying), then you should be fine while you are developing.

Running under IIS is a different story.  Depending on the version, the user context of the iis process could be all sorts of things - ASPNET, Network Service, Local Service, the new IIS app pool accounts, etc.  During testing the easiest thing to do is enable impersonation:


<configuration>
  <system.web>
    <identity impersonate="true" userName="contoso\Jane" password="********" />
  </system.web>
</configuration>

Optionally, you can remove the userName and password attributes and make sure that NTLM/Integrated security is still checked in the IIS configuration.  This will make IIS operate similar to the ASP.Net Development Server and use the current user as the execution context.

So with the security context out of the way, the next task is to actually query AD for a user object.  In my old example, I would specify the container to connect to whe creating a DirectoryEntry object.  For example:


System.DirectoryServices.DirectoryEntry directoryEntry
        = new System.DirectoryServices.DirectoryEntry(LDAP://CN=Users,DC=contoso,DC=com);

This worked fine for me but was a bit to limiting as an example to go by.  Many domains have an additional "corp" domain context.  "Users" might be named something different like "DomainUsers".  There are all sorts of variables with AD.  To combat this I added an extra query to "RootDSE" which is sort of the current context.  With that I'm able to get the address of an actuall domain controller and from there, can make the query on a higher level.  In the example below, I'm trying to get the AD User object for the currently authenticated user, but there's lots of diagnostic output that would let you find other things or narrow down the container that you're querying (for performance reasons).

 


<%@ Page Language="C#" %>
<%@ Import Namespace="System.DirectoryServices" %>
<%@ Import Namespace="System.Collections.Generic" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<script runat="server">

</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
  <script runat="server">
    protected void Page_Load(object sender, EventArgs e)
    {
      OptLocalUser.Text = User.Identity.Name;
     
      Dictionary<String, String> directoryPropertyDictionary;
       
     
      //get the currently connected AD server info:
      DirectoryEntry de = new DirectoryEntry("LDAP://RootDSE");
     
      directoryPropertyDictionary = new Dictionary<string, string>(de.Properties.Count);
      foreach (string key in de.Properties.PropertyNames)
      {
        String allValues = "";
        foreach (object value in de.Properties[key])
          allValues += value;
        directoryPropertyDictionary.Add(key, allValues);
      }
      GridView1.DataSource = directoryPropertyDictionary;
      GridView1.DataBind();
     
      //connect to the current server
      de = new DirectoryEntry("LDAP://" + de.Properties["dnsHostName"][0].ToString());

      DirectorySearcher ds = new DirectorySearcher(de);
      String[] nameParts = User.Identity.Name.Split('\\');
      ds.Filter = "(&(objectClass=user)(sAMAccountName=" + nameParts[1] + "))";
      SearchResult result = ds.FindOne();


      OptUserPath.Text = result.Path;
      directoryPropertyDictionary.Clear();
      foreach (string key in result.Properties.PropertyNames)
      {
        String allValues = "";
        foreach (object value in de.Properties[key])
          allValues += value;
        directoryPropertyDictionary.Add(key, allValues);
      }
      GridView2.DataSource = directoryPropertyDictionary;
      GridView2.DataBind();

      OptLocalUser.Text = User.Identity.Name;
    }
  </script>
</head>
<body>
    <form id="form1" runat="server">
    <div>
      Local User:<asp:Label ID="OptLocalUser" runat="server" /><br/>
     
      RootDSE:
      <asp:GridView ID="GridView1" runat="server">
      </asp:GridView>
     
      AD User Path:<asp:Label ID="OptUserPath" runat="server" /><br/>
      <asp:GridView ID="GridView2" runat="server">
      </asp:GridView>
    </div>
    </form>
</body>
</html>

AsyncPostBackTrigger On A Control External To An Update Panel Doesn't Display An Update Progress Control

Consider the following code:


    <asp:ScriptManager ID="ScriptManager1" runat="server">
    </asp:ScriptManager>   
    <div>
      <asp:Label ID="Label2" runat="server" Text="Label"></asp:Label>
      <asp:Button ID="Button2" runat="server" Text="Button" />
      <asp:Button ID="Button3" runat="server" Text="Button" />
      <asp:UpdatePanel ID="UpdatePanel1" runat="server">
   <Triggers>
     <asp:AsyncPostBackTrigger ControlID="Button2" />
   </Triggers>
      <ContentTemplate>
        <asp:Button ID="Button1" runat="server" Text="Button" />
        <asp:Label ID="Label1" runat="server" Text="Label"></asp:Label>
      </ContentTemplate>
      </asp:UpdatePanel>
     <asp:UpdateProgress ID="UpdateProgress1" runat="server" AssociatedUpdatePanelID="UpdatePanel1">
      <ProgressTemplate>
       Please Wait
      </ProgressTemplate>
     </asp:UpdateProgress>
    </div>


//code behind:
  protected void Page_Load(object sender, EventArgs e)
  {
    Label1.Text = DateTime.Now.ToString();
    Label2.Text = DateTime.Now.ToString();

    if (Page.IsPostBack)
    {
      System.Threading.Thread.Sleep(2000);
    }
  }

In the above example, Button1 will do a partial page update for UpdatePanel1.  Button3 will cause a full postback.  Button two will also do a partial page update for UpdatePanel1 since it is listed as a trigger.  However, Button2 will not cause UpdateProgress1 to be displayed.

There is some help for this on the buttom of an article on codeproject.com, but I needed something for multiple controls.  My solution ended up being two functions:


  private void RegisterExternalTriggerFixForUpdateProgress()
  {
    String updateProgressWTriggerFix = @"
      var triggerMappings = new Array();

      function TriggerMapping(controlId, updatePanelId)
        {
          this.TriggerControlId = controlId;
          this.UpdatePanelId = updatePanelId;
        }
        function RegisterTriggerMapping(controlId, updatePanelId)
        {
          triggerMappings.push(new TriggerMapping(controlId, updatePanelId));
        }
        function GetTriggerMapping(control)
        {
          for(var i=0; i<triggerMappings.length; i++)
          {
            if(triggerMappings[i].TriggerControlId == control.id)
            {
              return triggerMappings[i];
            }
          }
          return null;
        }

        var prm = Sys.WebForms.PageRequestManager.getInstance();
        function CancelAsyncPostBack() {
            if (prm.get_isInAsyncPostBack()) {
              prm.abortPostBack();
            }
        }


        prm.add_initializeRequest(InitializeRequest);
        prm.add_endRequest(EndRequest);
        var postBackElement;
        function InitializeRequest(sender, args) {
            if (prm.get_isInAsyncPostBack()) {
                args.set_cancel(true);
            }
            postBackElement = args.get_postBackElement();
       
            var triggerMapping = GetTriggerMapping(postBackElement);
            if (triggerMapping != null) {
                $get(triggerMapping.UpdatePanelId).style.display = 'block';
            }
        }
        function EndRequest(sender, args) {
            var triggerMapping = GetTriggerMapping(postBackElement);
            if (triggerMapping != null) {
                $get(triggerMapping.UpdatePanelId).style.display = 'none';
            }
        }";

    this.Page.ClientScript.RegisterStartupScript(typeof(Page), "UpdateProgressWTriggerFix", updateProgressWTriggerFix, true);
  }
  protected void RegisterExternalAsyncTrigger(Control triggerControl)
  {
    this.Page.ClientScript.RegisterStartupScript(
      this.GetType(),
      triggerControl.ClientID,
      String.Format(@"RegisterTriggerMapping('{0}','{1}');",
        triggerControl.ClientID,
        UpdateProgress1.ClientID),
      true);
  }

Then to fix a particular AsyncPostBackTrigger control, you just need the following call in your Page_Load:


    //fix for async update from a control outside of the update panel
    RegisterExternalTriggerFixForUpdateProgress();
    RegisterExternalAsyncTrigger(Button2);

Oracle ODP.net xcopy deployment for asp.net.

I just got done wrestling with multiple versions of the oracle client.  We wanted to install an Asp.net app that uses the 11g ODP.net install on a server that already had a 10g client installed.  While 11g and 10gR2 are supposed to have decent support for multiple oracle homes, the 10g client that was installed was only R1.  To make matters worse, the app using the 10g client was an old asp app via an ODBC dsn.  So rather than risking messing up the existing asp apps, I decided to give the xcopy deployment a shot. 

Getting the actual software setup is pretty well documented with an article on Oracle's website.  It really came down to just unzipping the package and running:

install.bat odp.net20 c:\oracle\product\1.1.0-xcopy-dep odac11_xcopy

However, my whole point in doing this was to avoid polluting the existing environment so a couple of items were out.   First of all, the last step in the article is to add a couple of environment variables.  Because I didn't want to interfer with the oracle home of an existing client install, i wanted to avoid this.  However without it, I got a nice exception: "Oracle.DataAccess.Client.OracleException The provider is not compatible with the version of Oracle client"

The fix was to add the following to my Application_Start event in the global.asax:

Environment.SetEnvironmentVariable("PATH", @"c:\oracle\product\1.1.0-xcopy-dep;c:\oracle\product\1.1.0-xcopy-dep\bin;", EnvironmentVariableTarget.Process);
Environment.SetEnvironmentVariable("ORACLE_HOME", @"c:\oracle\product\1.1.0-xcopy-dep;", EnvironmentVariableTarget.Process);

Inline TNSNAMES

This got me to an oracle error that I recognized: "Oracle.DataAccess.Client.OracleException ORA-12154: TNS:could not resolve the connect identifier specified."  This is usally fixed by changing tnsnames.ora but in this case it didn't work.  While it might be possible to use tnsnames with this xcopy deployment, I decided on a different route.  I could have used the host/service name format in the connection string, but this oracle environment is setup with RAC (clustering).  The oracle driver has a nice feature where the tns infomation can be inline in the connection string. 
So this:"Data Source=MyTNSName;User Id=user;Password=pwd;"
became this (which is right out of my tnsnames.ora):

 "Data Source=  (DESCRIPTION =
          (ADDRESS = (PROTOCOL = TCP)(HOST = orahost1.company.com)(PORT = 1522))
          (ADDRESS = (PROTOCOL = TCP)(HOST = orahost2.company.com)(PORT = 1522))
          (LOAD_BALANCE = on)
          (CONNECT_DATA =
            (SERVER = DEDICATED)
            (SERVICE_NAME = MyTNSService.company.com)
            (FAILOVER_MODE=(TYPE=SELECT)(METHOD=BASIC)(RETRIES=180)(DELAY=5))
          )
        )
      ;
      User Id=user;Password=pwd;"

Bypassing Distributed Transactions and the OracleMTSRecoveryService

That nearly got everything working.  The only thing left was a particular block of code that I had wrapped in a System.Transactions.TransactionScope block.  This normally requires the OraceMTSRecoveryService.  This is something that is installable with the xcopy deployment (run install.bat with no params for usage), but this would have overwritten an existing version of the service if it was already there.  Instead I decided to force the transactions to local only (it was only a single call to the db so I knew I wouldn't need a distributed transaction).  This could be done by adding "Promotable Transactions=local" to the connection string but I'm compiling against an older version of the driver (2.111.6.20 x64) in order to support both 64 and 32 bit with the same code and this option wasn't supported in this version.  Instead I changed the following registry key:

[HKEY_LOCAL_MACHINE\SOFTWARE\ORACLE\ODP.NET\2.111.7.20]
"PromotableTransaction"="local"

After that everything seems to be working.

P.S. 

Consider using a config change instead of a registry change with:
  <oracle.dataaccess.client>
    <settings>
      <add name="PromotableTransaction" value="local"/>
    </settings>
  </oracle.dataaccess.client>

Reason? I forgot to change the registry fix back and fought with "there was an error promoting the transaction to a distributed transaction oracle" for a day before I figured it out Embarassed.

5/17/2011 - Update:

I've done this again with the 4.0 provider and tnsnames.ora worked fine.  I think the first time I did this I didn't also update sqlnet.ora to use tnsnames with names.directory_path=(TNSNAMES,EZCONNECT). 

Another thing I don't remember doing in the 2.0 version was using the xcopydir\odp.net\bin\4\oraprovcfg utility.  I needed to put Oracle.DataAccess.dll in the GAC, but doing it manually didn't work.  I had to run oraprovcfg /action:gac /providerpath:<fullpath to Oracle.DataAccess.dll>

Last, I've also discovered that ODP.net supports the following configuration that should be a replacement for setting the path and oracle_home environment variables in code:


<configuration>
  <oracle.dataaccess.client>
    <add key="DllPath" value="c:\oracle\product\1.1.0-xcopy-dep\BIN"/>
  </oracle.dataaccess.client>
</configuration>

 See the following for more info:http://docs.oracle.com/html/E10927_01/InstallODP.htm

Registry entry for creating a new event log source.

I'll log errors to the event log for just about any app that I write with something like the following:

EventLog eventLog = new EventLog();
eventLog.Source = "My App Name";
eventLog.WriteEntry(message, EventLogEntryType.Error);

This is no problem for a windows app, but for an asp.net app running under a restricted account, you'll likely get an error:

System.Security.SecurityException: Requested registry access is not allowed.

The issue described here recommends either adding the event source manually in the registry or creating an EventLogInstaller.  Unless you're creating a commercial software package, you're probabaly going to go the registry route.  The only issue here is that the article leaves a peice out.  Adding only the key will allow your app to write to the event log but rather than your own neatly formatted error message you'll see:

The description for Event ID ( 0 ) in Source (<application name>) could not be found. It contains the following insertion string(s):

If you look at other keys you'll see they contain an "EventMessageFile" reg_expand_sz (expandable string value).  Asp.net 2.0 or higher apps will have the value "c:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\EventLogMessages.dll".  If you want a quick fix, here's the reg file code (make sure you save as unicode):

 

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Eventlog\Application\YourSourceNameHere]
"EventMessageFile"=hex(2):63,00,3a,00,5c,00,57,00,49,00,4e,00,44,00,4f,00,57,\
  00,53,00,5c,00,4d,00,69,00,63,00,72,00,6f,00,73,00,6f,00,66,00,74,00,2e,00,\
  4e,00,45,00,54,00,5c,00,46,00,72,00,61,00,6d,00,65,00,77,00,6f,00,72,00,6b,\
  00,5c,00,76,00,32,00,2e,00,30,00,2e,00,35,00,30,00,37,00,32,00,37,00,5c,00,\
  45,00,76,00,65,00,6e,00,74,00,4c,00,6f,00,67,00,4d,00,65,00,73,00,73,00,61,\
  00,67,00,65,00,73,00,2e,00,64,00,6c,00,6c,00,00,00
 
 
 
 Here's the .net 4.0 version....

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\services\eventlog\Application\YourSourceNameHere]
"EventMessageFile"=hex(2):63,00,3a,00,5c,00,57,00,49,00,4e,00,44,00,4f,00,57,\
  00,53,00,5c,00,4d,00,69,00,63,00,72,00,6f,00,73,00,6f,00,66,00,74,00,2e,00,\
  4e,00,45,00,54,00,5c,00,46,00,72,00,61,00,6d,00,65,00,77,00,6f,00,72,00,6b,\
  00,5c,00,76,00,34,00,2e,00,30,00,2e,00,33,00,30,00,33,00,31,00,39,00,5c,00,\
  45,00,76,00,65,00,6e,00,74,00,4c,00,6f,00,67,00,4d,00,65,00,73,00,73,00,61,\
  00,67,00,65,00,73,00,2e,00,64,00,6c,00,6c,00,00,00