Just a short, simple blog for Bob to share his thoughts.
30 June 2011 • by Bob • FTP, BlogEngine.NET, Extensibility
I ran into an interesting situation recently with BlogEngine.NET that I thought would make a good blog post.
Here's the background for the environment: I host several blog sites for friends of mine, and they BlogEngine.NET for their blogging engine. From a security perspective this works great for me, because I can give them accounts for blogging that are kept in the XML files for each of their respective blogs that aren't real user accounts on my Windows servers.
The problem that I ran into: BlogEngine.NET has great support for uploading files to your blog, but it doesn't provide a real way to manage the files that have been uploaded. So when one of my friends mentioned that they wanted to update one of their files, I was left in a momentary quandary.
My solution: I realized that I could write a custom FTP provider that would solve all of my needs. For my situation the provider needed to do three things:
Here's why item #3 was so important - my users have no idea about the underlying functionality for their blog, so I didn't want to simply enable FTP publishing for their website and give them access to their ASP.NET files - there's no telling what might happen. Since all of their files are kept in the path ~/App_Data/files, it made sense to have the custom FTP provider return home directories for each of their websites that point to their files instead of the root folders of their websites.
The following items are required to complete the steps in this blog:
Note: I used Visual Studio 2008 when I created my custom provider and wrote the steps that appear in this blog, although since then I have upgraded to Visual Studio 2010, and I have successfully recompiled my provider using that version. In any event, the steps should be similar whether you are using Visual Studio 2008 or Visual Studio 2010.;-]
In this step, you will create a project inVisual Studio 2008for the demo provider.
net stop ftpsvc call "%VS90COMNTOOLS%\vsvars32.bat">nul gacutil.exe /if "$(TargetPath)" net start ftpsvc
net stop ftpsvc call "%VS100COMNTOOLS%\vsvars32.bat">nul gacutil.exe /if "$(TargetPath)" net start ftpsvc
In this step, you will implement the logging extensibility interface for the demo provider.
using System; using System.Collections.Specialized; using System.Collections.Generic; using System.Configuration.Provider; using System.IO; using System.Security.Cryptography; using System.Text; using System.Xml; using System.Xml.XPath; using Microsoft.Web.FtpServer; public class FtpBlogEngineNetAuthentication : BaseProvider, IFtpAuthenticationProvider, IFtpRoleProvider, IFtpHomeDirectoryProvider { // Create strings to store the paths to the XML files that store the user and role data. private string _xmlUsersFileName; private string _xmlRolesFileName; // Create a string to store the FTP home directory path. private string _ftpHomeDirectory; // Create a file system watcher object for change notifications. private FileSystemWatcher _xmlFileWatch; // Create a dictionary to hold user data. private Dictionary<string, XmlUserData> _XmlUserData = new Dictionary<string, XmlUserData>( StringComparer.InvariantCultureIgnoreCase); // Override the Initialize method to retrieve the configuration settings. protected override void Initialize(StringDictionary config) { // Retrieve the paths from the configuration dictionary. _xmlUsersFileName = config[@"xmlUsersFileName"]; _xmlRolesFileName = config[@"xmlRolesFileName"]; _ftpHomeDirectory = config[@"ftpHomeDirectory"]; // Test if the path to the users or roles XML file is empty. if ((string.IsNullOrEmpty(_xmlUsersFileName)) || (string.IsNullOrEmpty(_xmlRolesFileName))) { // Throw an exception if the path is missing or empty. throw new ArgumentException(@"Missing xmlUsersFileName or xmlRolesFileName value in configuration."); } else { // Test if the XML files exist. if ((File.Exists(_xmlUsersFileName) == false) || (File.Exists(_xmlRolesFileName) == false)) { // Throw an exception if the file does not exist. throw new ArgumentException(@"The specified XML file does not exist."); } } try { // Create a file system watcher object for the XML file. _xmlFileWatch = new FileSystemWatcher(); // Specify the folder that contains the XML file to watch. _xmlFileWatch.Path = _xmlUsersFileName.Substring(0, _xmlUsersFileName.LastIndexOf(@"\")); // Filter events based on the XML file name. _xmlFileWatch.Filter = @"*.xml"; // Filter change notifications based on last write time and file size. _xmlFileWatch.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size; // Add the event handler. _xmlFileWatch.Changed += new FileSystemEventHandler(this.XmlFileChanged); // Enable change notification events. _xmlFileWatch.EnableRaisingEvents = true; } catch (Exception ex) { // Raise an exception if an error occurs. throw new ProviderException(ex.Message,ex.InnerException); } } // Define the event handler for changes to the XML files. public void XmlFileChanged(object sender, FileSystemEventArgs e) { // Verify that the changed file is one of the XML data files. if ((e.FullPath.Equals(_xmlUsersFileName, StringComparison.OrdinalIgnoreCase)) || (e.FullPath.Equals(_xmlRolesFileName, StringComparison.OrdinalIgnoreCase))) { // Clear the contents of the existing user dictionary. _XmlUserData.Clear(); // Repopulate the user dictionary. ReadXmlDataStore(); } } // Override the Dispose method to dispose of objects. protected override void Dispose(bool IsDisposing) { if (IsDisposing) { _xmlFileWatch.Dispose(); _XmlUserData.Clear(); } } // Define the AuthenticateUser method. bool IFtpAuthenticationProvider.AuthenticateUser( string sessionId, string siteName, string userName, string userPassword, out string canonicalUserName) { // Define the canonical user name. canonicalUserName = userName; // Validate that the user name and password are not empty. if (String.IsNullOrEmpty(userName) || String.IsNullOrEmpty(userPassword)) { // Return false (authentication failed) if either are empty. return false; } else { try { // Retrieve the user/role data from the XML file. ReadXmlDataStore(); // Create a user object. XmlUserData user = null; // Test if the user name is in the dictionary of users. if (_XmlUserData.TryGetValue(userName, out user)) { // Retrieve a sequence of bytes for the password. var passwordBytes = Encoding.UTF8.GetBytes(userPassword); // Retrieve a SHA256 object. using (HashAlgorithm sha256 = new SHA256Managed()) { // Hash the password. sha256.TransformFinalBlock(passwordBytes, 0, passwordBytes.Length); // Convert the hashed password to a Base64 string. string passwordHash = Convert.ToBase64String(sha256.Hash); // Perform a case-insensitive comparison on the password hashes. if (String.Compare(user.Password, passwordHash, true) == 0) { // Return true (authentication succeeded) if the hashed passwords match. return true; } } } } catch (Exception ex) { // Raise an exception if an error occurs. throw new ProviderException(ex.Message,ex.InnerException); } } // Return false (authentication failed) if authentication fails to this point. return false; } // Define the IsUserInRole method. bool IFtpRoleProvider.IsUserInRole( string sessionId, string siteName, string userName, string userRole) { // Validate that the user and role names are not empty. if (String.IsNullOrEmpty(userName) || String.IsNullOrEmpty(userRole)) { // Return false (role lookup failed) if either are empty. return false; } else { try { // Retrieve the user/role data from the XML file. ReadXmlDataStore(); // Create a user object. XmlUserData user = null; // Test if the user name is in the dictionary of users. if (_XmlUserData.TryGetValue(userName, out user)) { // Search for the role in the list. string roleFound = user.Roles.Find(item => item == userRole); // Return true (role lookup succeeded) if the role lookup was successful. if (!String.IsNullOrEmpty(roleFound)) return true; } } catch (Exception ex) { // Raise an exception if an error occurs. throw new ProviderException(ex.Message,ex.InnerException); } } // Return false (role lookup failed) if role lookup fails to this point. return false; } // Define the GetUserHomeDirectoryData method. public string GetUserHomeDirectoryData(string sessionId, string siteName, string userName) { // Test if the path to the home directory is empty. if (string.IsNullOrEmpty(_ftpHomeDirectory)) { // Throw an exception if the path is missing or empty. throw new ArgumentException(@"Missing ftpHomeDirectory value in configuration."); } // Return the path to the home directory. return _ftpHomeDirectory; } // Retrieve the user/role data from the XML files. private void ReadXmlDataStore() { // Lock the provider while the data is retrieved. lock (this) { try { // Test if the dictionary already has data. if (_XmlUserData.Count == 0) { // Create an XML document object and load the user data XML file XPathDocument xmlUsersDocument = GetXPathDocument(_xmlUsersFileName); // Create a navigator object to navigate through the XML file. XPathNavigator xmlNavigator = xmlUsersDocument.CreateNavigator(); // Loop through the users in the XML file. foreach (XPathNavigator userNode in xmlNavigator.Select("/Users/User")) { // Retrieve a user name. string userName = GetInnerText(userNode, @"UserName"); // Retrieve the user's password. string password = GetInnerText(userNode, @"Password"); // Test if the data is empty. if ((String.IsNullOrEmpty(userName) == false) && (String.IsNullOrEmpty(password) == false)) { // Create a user data class. XmlUserData userData = new XmlUserData(password); // Store the user data in the dictionary. _XmlUserData.Add(userName, userData); } } // Create an XML document object and load the role data XML file XPathDocument xmlRolesDocument = GetXPathDocument(_xmlRolesFileName); // Create a navigator object to navigate through the XML file. xmlNavigator = xmlRolesDocument.CreateNavigator(); // Loop through the roles in the XML file. foreach (XPathNavigator roleNode in xmlNavigator.Select(@"/roles/role")) { // Retrieve a role name. string roleName = GetInnerText(roleNode, @"name"); // Loop through the users for the role. foreach (XPathNavigator userNode in roleNode.Select(@"users/user")) { // Retrieve a user name. string userName = userNode.Value; // Create a user object. XmlUserData user = null; // Test if the user name is in the dictionary of users. if (_XmlUserData.TryGetValue(userName, out user)) { // Add the role name for the user. user.Roles.Add(roleName); } } } } } catch (Exception ex) { // Raise an exception if an error occurs. throw new ProviderException(ex.Message,ex.InnerException); } } } // Retrieve an XPathDocument object from a file path. private static XPathDocument GetXPathDocument(string path) { Exception _ex = null; // Specify number of attempts to create an XPathDocument. for (int i = 0; i < 8; ++i) { try { // Create an XPathDocument object and load the user data XML file XPathDocument xPathDocument = new XPathDocument(path); // Return the XPathDocument if successful. return xPathDocument; } catch (Exception ex) { // Save the exception for later. _ex = ex; // Pause for a brief interval. System.Threading.Thread.Sleep(250); } } // Throw the last exception if the function fails to this point. throw new ProviderException(_ex.Message,_ex.InnerException); } // Retrieve data from an XML element. private static string GetInnerText(XPathNavigator xmlNode, string xmlElement) { string xmlText = string.Empty; try { // Test if the XML element exists. if (xmlNode.SelectSingleNode(xmlElement) != null) { // Retrieve the text in the XML element. xmlText = xmlNode.SelectSingleNode(xmlElement).Value.ToString(); } } catch (Exception ex) { // Raise an exception if an error occurs. throw new ProviderException(ex.Message,ex.InnerException); } // Return the element text. return xmlText; } } // Define the user data class. internal class XmlUserData { // Create a private string to hold a user's password. private string _password = string.Empty; // Create a private string array to hold a user's roles. private List<String> _roles = null; // Define the class constructor requiring a user's password. public XmlUserData(string Password) { this.Password = Password; this.Roles = new List<String>(); } // Define the password property. public string Password { get { return _password; } set { try { _password = value; } catch (Exception ex) { throw new ProviderException(ex.Message,ex.InnerException); } } } // Define the roles property. public List<String> Roles { get { return _roles; } set { try { _roles = value; } catch (Exception ex) { throw new ProviderException(ex.Message,ex.InnerException); } } } }
Note: If you did not use the optional steps to register the assemblies in the GAC, you will need to manually copy the assemblies to your IIS 7 computer and add the assemblies to the GAC using the Gacutil.exe tool. For more information, see the following topic on the Microsoft MSDN Web site:
In this step, you will add the provider to your FTP service. These steps obviously assume that you are using BlogEngine.NET on your Default Web Site, but these steps can be easily amended for any other website where BlogEngine.NET is installed.
cd %SystemRoot%\System32\Inetsrv
appcmd.exe set config -section:system.ftpServer/providerDefinitions /+"[name='FtpBlogEngineNetAuthentication',type='FtpBlogEngineNetAuthentication,FtpBlogEngineNetAuthentication,version=1.0.0.0,Culture=neutral,PublicKeyToken=426f62526f636b73']" /commit:apphost
appcmd.exe set config -section:system.ftpServer/providerDefinitions /+"activation.[name='FtpBlogEngineNetAuthentication']" /commit:apphost
appcmd.exe set config -section:system.ftpServer/providerDefinitions /+"activation.[name='FtpBlogEngineNetAuthentication'].[key='xmlUsersFileName',value='C:\inetpub\wwwroot\App_Data\Users.xml']" /commit:apphost
appcmd.exe set config -section:system.ftpServer/providerDefinitions /+"activation.[name='FtpBlogEngineNetAuthentication'].[key='xmlRolesFileName',value='C:\inetpub\wwwroot\App_Data\Roles.xml']" /commit:apphost
appcmd.exe set config -section:system.ftpServer/providerDefinitions /+"activation.[name='FtpBlogEngineNetAuthentication'].[key='ftpHomeDirectory',value='C:\inetpub\wwwroot\App_Data\files']" /commit:apphost
Just like the steps that I listed earlier, these steps assume that you are using BlogEngine.NET on your Default Web Site, but these steps can be easily amended for any other website where BlogEngine.NET is installed.
At the moment there is no user interface that enables you to add custom home directory providers, so you will have to use the following command line:
cd %SystemRoot%\System32\Inetsrv
appcmd.exe set config -section:system.applicationHost/sites /+"[name='Default Web Site'].ftpServer.customFeatures.providers.[name='FtpBlogEngineNetAuthentication']" /commit:apphost
appcmd.exe set config -section:system.applicationHost/sites /"[name='Default Web Site'].ftpServer.userIsolation.mode:Custom" /commit:apphost
To help improve the performance for authentication requests, the FTP service caches the credentials for successful logins for 15 minutes by default. This means that if you change your passwords, this change may not be reflected for the cache duration. To alleviate this, you can disable credential caching for the FTP service. To do so, use the following steps:
cd /d "%SystemRoot%\System32\Inetsrv" Appcmd.exe set config -section:system.ftpServer/caching /credentialsCache.enabled:"False" /commit:apphost Net stop FTPSVC Net start FTPSVC
Note: This blog was originally posted at http://blogs.msdn.com/robert_mcmurray/
Tags: