Saturday, January 20, 2007

Attempting to reach remote resources with C#

This is related to the project in my previous post. Now that we had the IVR working correctly, we needed a way to move the voice recordings from the IVR server to a web server where our customers can retrieve them. Last year I wrote a web service called ResourceProxy that accomplishes the task of moving files from one server to another very nicely and with very strict security restrictions (it requires authentication, it can restrict the paths and file types allowed, etc.). Unfortunately, the IVR is not a web server, it's a phone server. So installing IIS to allow web connectivity was not a solution we wanted to use.

So what we have are two file shares. One is read-only and allows me to pick up the recordings from the phone server, and a second is read/write and allows me to drop them off on the web server. I have to authenticate using two different domain accounts to access each share, and of course the web service that accomplishes this is running as the ASP.NET machine account.

So this creates a small problem. How does one authenticate a process running as the ASP.NET machine account to allow it to connect to remote servers? Looks like we need to get down and dirty with P/Invoke.

My first solution was elegant. I would use LogonUser to authenticate each account, impersonate the principal, and then connect to the file share UNCs. The code looks something like this:


[DllImport("advapi32.dll")]
private static int LogonUser(string lpszUsername, string lpszDomain,
string lpszPassword, int dwLogonType, int dwLogonProvider,
ref IntPtr phToken);

[DllImport("advapi32.dll")]
private static int DuplicateToken(IntPtr hExistingToken, int ImpersonationLevel,
ref IntPtr phNewToken);

public void ImpersonateUser(string domain, string user, string password)
{
IntPtr token = IntPtr.Zero;

int result = LogonUser(user, domain, password,
LOGON32_LOGON_INTERACTIVE,
LOGON32_PROVIDER_DEFAULT, ref token);

if (result != 0)
{
IntPtr newToken = IntPtr.Zero;
result = DuplicateToken(token,
SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
ref newToken);

if (result != 0)
{
WindowsIdentity id = new WindowsIdentity(newToken);
WindowsImpersonationContext context = id.Impersonate();
}
}
}


By the way, that's the incredibly pruned-down code. If you want a more extensive example, look at the WindowsImpersonationContext class.

This code worked beautifully. I could impersonate each of the domain accounts I needed to access the remote UNCs and copy my files over. Basically I just had to impersonate the first user, load the file into a MemoryStream, impersonate the other user, and write it to disk. (This was an acceptable approach because the files in question are always fairly small, typically less than 50k.)

It worked great on the development server, which is a Windows 2003 machine. Unfortunately, the production web server is still running Windows 2000. When we tried it there, everything broke. Argh!

For some reason, on Windows 2000 I was getting a bad username/password every time I tried to impersonate one of the domain principals, even though the exact same username/password combos worked fine on Windows 2003. I tried several different permutations of parameters to the LogonUser function to see if that would help. Eventually I ended up locating a Windows 2000 box on our development domain and tried my code there, just to see if it was an environment issue in production. It didn't work there either. Same code, same users and passwords; it just wouldn't work when moving from Windows 2003 to Windows 2000.

One thing that is mentioned on the page for LogonUser at MSDN is that on Windows 2000, the account executing LogonUser must have the SE_TCB_NAME privilege. You can grant this privilege to an account (such as the ASP.NET machine account in my case) by going in to the Local Security Policy for the server and adding the account to the "Act as a part of the operating system" policy. I tried this, and again it was no go.

So after fighting with that for a few hours, I decided to try a different approach. My boss mentioned that one technique he used in a classic ASP application to achieve the effect I wanted was to map the UNCs as network drives in the code before accessing them. This had some merit, so I investigated how to achieve this in C#.

The function we want this time is called WNetAddConnection2:


[DllImport("mpr.dll")]
private static int WNetAddConnection2(
ref NETRESOURCE lpNetResource,
string lpPassword, string lpUsername, int dwFlags);

public void MapNetworkDrive(string unc, string drive, string user, string password)
{
NETRESOURCE pRes = new NETRESOURCE();
int result = WNetAddConnection2(ref pRes, password, user, 0);
}


On the surface, this code looks a lot simpler, but I actually wrote a lot of peripheral code to make sure I wasn't trying to connect to the same resource twice and also to make sure I didn't map over an existing logical drive on the server. It doesn't hurt anything to map the resource twice, because WNetAddConnection2 will tell you what happened, but WNetAddConnection2 itself can be a somewhat costly operation, so I figured it better to avoid calling it if I can.

This code worked beautifully as well. After I had mapped the network shares I wanted, I could use the typical .NET IO classes to access the UNC directly without a problem.

Of course, after running it on Windows 2000, it broke again. Argh and double argh!

For some reason, on Windows 2000 this code kept throwing back an ERROR_WRONG_TARGET_NAME. According to the documentation I could find, this meant that the UNCs were not correct (target name referring the server destination). So why would the UNCs be correct on Windows 2003 but not on Windows 2000?

Who knows? At this point I was so frustrated with it I was willing to try anything. What I found is that if I specified the IP address (\\192.168.1.1\share as opposed to \\servername\share), Windows 2000 was perfectly happy with it. And now the code works fine in production, so I'm not going to play with it anymore.

No comments: