Thursday, 01 April 2010

IIS 7 FTP Extensions - The Guide

For a couple of years now the FTP server we used at my full-time job was FileZilla. It's a good server, open source, easily configurable, stable. But one of the main features we needed was to be able to dynamically add and remove FTP users, each locked up in their own folder, all grouped in a couple of main Categories. The users were generated by the main ASP.NET powered web app, and had to be able to use their ftp accounts immediately.

FileZilla stores these accounts in an xml file, so to add new accounts we had to open up the xml, add the new user, save the configuration file, and poll the ftp server to reload it's config file. While this approach worked, it was far from perfect. And apart from the fact that we were storing the credentials in two places (they were stored in the main DB already) the main problem surfaced when 2 users tried to create accounts at the same time generating a file lock.

I wanted to move to a different server that would store all the credentials in a SQL database (ideally within our own structure), and I've finally found what I was looking for - Microsoft has released FTP7 - an ftp server that runs on IIS 7 (Windows Vista and higher only, sorry Win2003 :) )

The best thing about it is that you can write custom extensions for it that will control the authentication process, the user roles, the home folder, and logging. It sounds simple and straightforward at first, but it's not as perfect as we'd like at first. After working on it for the better part of a day (slowly becoming a long night) I decided to write this semi-definitive guide to setting up custom authentication in FTP 7!

Prerequisites

The entry requirements are simple and straightforward - you need to have a machine with IIS 7 installed. Vista can support this, but only with SP1 installed, and the support is a lot more limited there, so I wouldn't recommend it. It's a lot easier to use it on a Server 2008 R2 or Windows 7 machine.

So, to get the ftp up and running you need:

  • IIS 7 installed and running. You can install if from the Add Windows Features screen in Windows Vista/Server or by adding the relevant server roles in the server OS.
  • FTP 7 installed. If you're running on Windows 7 / Server 2008 R2 it should already be available alongside IIS. Don't forget to enable the FTP manager service (FTP publishing in Windows) and the FTP Extensions support. On Windows server 2008 you can get it as a downloadable installation:
  • If you’re running on Windows Server 2008 (not the R2 version), I’d also recommend to install the IIS Administration Pack (x86 / x64). It's not absolutely necessary, but it's a lot easier to change the settings in the IIS manager, and not by running console commands. For the sake of this article I'll assume that you have installed it.
  • While you're developing the module I strongly recommend to disable the credential caching, to save you a lot of headaches. Removing a login, and still being able to log it with the deleted credentials can drive you into the wrong direction, and stop you from moving on for a long time.
    Start up IIS manager, click on your server, and find the Configuration manager in the Features View. Once inside, select system.ftpServer/caching in the Section dropdown on top, and disable the caching. You can enable it later, if you need it.

With this you have all the basics installed and set up. At this point you're able to create a new FTP site in the IIS manager, enable access to anonymous users or set up basic authentication, set up the main root folder, and start using it, but if you want more then we're not stopping just yet!

Setting up the VS project

As recommended by the tutorials on IIS.NET - I used both Visual Studio 2008 and 2010 to create an authentication module, and it works in both cases. I assume you already have experience with it, so I'll jump over the boring bits, and just mark the important ones.

  1. Start up Visual Studio and create a new Class Library project. Let's call it - CustomFtpAuthDemo.
  2. The first thing to do is to go to the project properties and add the reference paths. On Windows 7 add the path to: C:\Program Files\Reference Assemblies\Microsoft\IIS, and if you're developing on Server 2008 or Vista: C:\Windows\assembly\GAC_MSIL\Microsoft.Web.FtpServer\7.5.0.0__31bf3856ad364e35
  3. You have to sign your assembly, so head on to the Signing tab, and sign it. It doesn't have to be password protected unless you want to :)
  4. To be able to use your library you'll have to add it to the GAC first. What's recommended on IIS.net and I support them is to add the post build event commands, that will register your library and restart the ftp server. Again: this will really make things simpler! Head on to the Build events, and copy this into the Post-build event command line box:
  • In Visual Studio 2008:
net stop ftpsvc
call "%VS090COMNTOOLS%\vsvars32.bat">null
gacutil.exe /if "$(TargetPath)"
net start ftpsvc
  • In Visual Studio 2010:
net stop ftpsvc
call "%VS100COMNTOOLS%\vsvars32.bat">null
gacutil.exe /if "$(TargetPath)"
net start ftpsvc
  1. This step I haven't seen in other tutorials, but I have used it out of necessity. On my local machine the dll that I was building was cached, and even if I rebuilt the library, and after re-registering it in the GAC - the FTP did not pick up the new code, and was still using the old one. Fed up with this I realised that if I change the version number of the assembly I can point FTP to use the version I need. It's not as pretty, and it can get annoying changing it every time, but it works like a charm, so go to the Application tab, Assembly information and set assembly version to something like 1.0.0.*. The asterix will force VS to change the version number of every build.
  2. The last step is to add the required library references. Head to the Add Reference window, and pick up these two assemblies from the .NET tab: System.Web and Microsoft.Web.FtpServer.
    Note: VS 2008 listed Microsoft.Web.FtpServer in the list of assemblies in the .NET tab, but VS 2010 stubbornly continued to ignore it. So I just opened up the .csproj file, and added the reference manually, as follows:
<ItemGroup>
  <Reference Include="Microsoft.Web.FtpServer, Version=7.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL" />
  <!-- [...] -->
</ItemGroup>

Now we're ready for the fun part:

Coding the authentication class

I won't make it too easy for you, and I'll just drop the example for you here. It's really straightforward, so there's nothing hard. If you want more detailed examples, you can check them out on the iis.net pages.

This example here shows what you need for the user/password lookups, roles, home directory and logging. It's not perfect, and not the way to go in production, but will give you an idea of what to do.

using System;
using System.IO;
using System.Text;

using Microsoft.Web.FtpServer;

namespace FtpAuthentication
{
    public class CustomFtpAuth : BaseProvider, IFtpAuthenticationProvider, IFtpRoleProvider, IFtpHomeDirectoryProvider, IFtpLogProvider
    {
        // Password hash, based on a seed value, login and password.
        public static Guid HashPassword(String userName, String password)
        {
            var hashAlgorithm = System.Security.Cryptography.MD5.Create();
            var src = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes("DC299333C5D5" + userName.ToLower() + password));
            return new Guid(src);
        }
        #region IFtpAuthenticationProvider Members

        public bool AuthenticateUser(string sessionId, string siteName, string userName, string userPassword, out string canonicalUserName)
        {
            canonicalUserName = userName;

            // For demo purposes, we hardcode the login and password here
            // Login is TestLogin, case insensitive, password is PassWord, case sensitive
            if (userName.Equals("TestLogin", StringComparison.OrdinalIgnoreCase) &&
                HashPassword(userName, userPassword).Equals(new Guid("304a82c3-830d-0a4f-08a9-aefd835a35cc")))
            {
                return true;
            }

            return false;
        }

        #endregion

        #region IFtpRoleProvider Members

        public bool IsUserInRole(string sessionId, string siteName, string userName, string userRole)
        {
            // Again, for demo purposes we just assume that the TestLogin user is in a TestRole role
            if (userName.Equals("TestLogin", StringComparison.OrdinalIgnoreCase) &&
                userRole.Equals("TestRole", StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }

            return false;
        }

        #endregion

        #region IFtpHomeDirectoryProvider Members

        public string GetUserHomeDirectoryData(string sessionId, string siteName, string userName)
        {
            // This is just a demo, so we're doing it simple
            // If you store the home folder in a DB - you'll need this method!
            var homePath = Path.Combine(@"C:\inetpub\ftproot", userName);

            if (!Directory.Exists(homePath))
                Directory.CreateDirectory(homePath);

            return homePath;
        }

        #endregion

        #region IFtpLogProvider Members

        public void Log(FtpLogEntry logEntry)
        {
            // I haven't tried this method - but it's obvious what it does
            // I'll let you implement it yourselves
        }

        #endregion
    }
}

One piece of advice: I didn't manage to debug the module while it's running in IIS, and the ftp server wasn't spitting out the exceptions with the stack trace and all. There isn't a way (none that I found so far) to just write custom messages to the user, so trying to fix issues wasn't always easy. What I did in this case was send debugging messages using System.Diagnostics.Debug.WriteLine(), and the catch them using a nice little tool called Debug view from Sysinternals.

With the library ready and compiling, we've got one last thing to do, link it all in IIS

Add the provider to IIS

Sadly, the IIS/FTP interface isn't quite perfected yet, and some options can't be done from there, so we'll have to get our hands dirty. We'll start with the easy bits, and get down to the hard ones later.

  1. Open up windows explorer, navigate to C:\Windows\assembly and find your library there. What you'll need to get is the culture, version and the public key token.
  2. Start up the IIS manager, click on your computer name, go to FTP Authentication (server wide, not the "per-site" one). In the actions pane, click on the Custom Providers, and register your assembly. In the Managed Provider (.NET) field enter your class details in the following form:
{namespace}.{className},{assembly},Culture={culture},PublicKeyToken={token},version={version}  

For example:

FtpAuthentication.CustomFtpAuth,CustomFtpAuthDemo,Culture=neutral,PublicKeyToken=426f62526f636b73,version=1.0.0.0
  1. Once it's saved, find your FTP site in the list (or create one if you didn't yet), double click on FTP Authentication, and enable the custom provider. If it's not in the list - click on the Custom providers link in the actions pane, and tick the checkbox next to it. At this point we have enabled the authentication, the roles and logging modules, but the home folders are still used from the default IIS settings.
  2. Open an admin command line window, navigate to C:\Windows\System32\inetsrv, and type in the following commands:
AppCmd set site "FTP Site Name" /+ftpServer.customFeatures.providers.[name='CustomFtpAuthDemo',enabled='true']
AppCmd set site "FTP Site Name" /ftpServer.userIsolation.mode:Custom

They will enable the Custom Features module from your assembly (the home folder lookup), and set your website to use Custom user isolation (greyed out in the interface until you do this). Don't forget to replace "FTP Site Name" and CustomFtpAuthDemo with your values.

  1. We're almost done. Go to your website's FTP Authorization section in the IIS Manager and add some rules. Remember, by default all users are denied access, so a good starting point is to enable access to all users (they will of course be denied if the credentials are wrong, so it's absolutely safe)
    One strange thing about these rules is that the Deny rules are... denying everything. If you create a deny rule, that would deny write access - it will deny ALL access to the selected users. Not quite what I expected at first

Conclusion

This is it. You've now got your own ftp server, picking up the login credentials from wherever you want it to. If you have any comments, suggestions or improvements for this guide - I'll be happy to hear them.