Creating a Read-Only Snitz Membership Provider
For this blog post I'm going to take a brief departure from my FTP client series and share some code that I put together recently to help address a situation that presented itself a short time ago.
Problem Description
I keep a web site for my extended family that uses the Snitz Forums for private discussions between family members. Recently one of my relatives scanned several historical photographs of family members from the early 1900s, and I thought that uploading those to the family web site would be a great way to share them with everyone. Of course, I don't want to share those photos with the entire Internet, so I needed to come up with a way to share them with just my family members.
My site has been using the Snitz forums application since the dark ages, (meaning my pre-ASP.NET days), so I already have a list of family members that have active accounts on my site and I didn't want to roll out some new authentication method that would confuse everyone. I could write a photo gallery application of my own that used the Snitz accounts for authentication, but Bill Staples had already written a really cool sample photo gallery application as an HTTP module for IIS 7.0 that I wanted to use, which suggested to me that forms authentication was my best bet for the application. But how could I consume the existing Snitz accounts?
Problem Resolution
The answer was simple - create a membership and role provider using the Snitz database. Since the Snitz forums application already handles all of the user registration and account modification features, I didn't need a full membership provider - I just needed a simple provider that would perform user validation and role lookups.
Provider Design
I had just recently written a walkthrough for the learn.iis.net web site titled How to use the Sample Read-Only XML Membership and Role Providers with IIS 7.0 that describes how to create and use the MSDN sample membership and role providers for IIS 7.0, so I leveraged a great deal of that code design to create my new read-only Snitz membership provider. (You should notice a great deal of intentional similarities in my code. )
I chose to create a single namespace ("ReadOnlySnitzProvider") that contained two classes: one for the membership provider ("SnitzMembershipProvider") and the other for the role provider ("SnitzRoleProvider"); and I added an additional class ("SnitzUtils") with a couple of helper methods. (I had a few more helper methods at one point, but I trimmed down the code to just these methods. I'll say a little more about that later.)
The Snitz forums user accounts don't really have much of a concept of roles, so my provider only works with three roles: Members, Moderators, and Administrators. These roles are mapped to the corresponding user account levels that exist in Snitz. Several other account properties such as creation date/time, last logged in date/time, lockout status, etc., are all accurately represented by this provider. Note: I only check the FORUM_MEMBERS table, not the FORUM_MEMBERS_PENDING table, so any pending accounts will not show up in the list of users.
Step 1: Creating the Project
In this section you will create a project in Visual Studio for the membership/role provider.
- Open Microsoft Visual Studio 2008.
- Click the File menu, then New, then Project.
- In the New Project dialog:
- Choose Visual C# as the project type.
- Choose Class Library as the template.
- Type ReadOnlySnitzProvider as the name of the project.
- Uncheck the Create directory for solution box.
- Click OK.
- Add a reference path to the System.Configuration library:
- In the solution explorer, right-click the ReadOnlySnitzProvider project, then Add Reference...
- Click the .NET tab.
- Select "System.Configuration" in the list of assemblies.
- Click OK.
- Add a reference path to the System.Web library:
- In the solution explorer, right-click the ReadOnlySnitzProvider project, then Add Reference...
- Click the .NET tab.
- Select "System.Web" in the list of assemblies.
- Click OK.
- Add a strong name key to the project:
- In the solution explorer, right-click the ReadOnlySnitzProvider project, then Properties.
- Click the Signing tab.
- Check the Sign the assembly check box.
- Choose <New...> from the strong key name drop-down box.
- Enter ReadOnlySnitzProviderKey for the key file name.
- If desired, enter a password for the key file; otherwise, uncheck the Protect my key file with a password box.
- Click OK.
- Add a custom build event to automatically register the DLL in the GAC:
- In the solution explorer, right-click the ReadOnlySnitzProvider project, then Properties.
- Click the Build Events tab.
- Enter the following in the Post-build event command line box:
call "%VS90COMNTOOLS%\vsvars32.bat">null
gacutil.exe /if "$(TargetPath)"
- Save the solution.
Step 2: Add the provider classes for the project
In this second step you will create the classes for the membership and role providers. The code for these classes is based on the Membership Providers and Role Providers topics on MSDN.
- Open the Class1.cs file if it is not already open.
- Remove all of the existing code from the class.
- Paste the following sample code into the editor:
/* ======================================== */ // // ReadOnlySnitzProvider // // A read-only membership and role provider for Snitz forums. // /* ======================================== */ using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.Configuration; using System.Configuration.Provider; using System.Data.Odbc; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Web.Security; namespace ReadOnlySnitzProvider { /* ======================================== */ // // SnitzMembershipProvider // /* ======================================== */ public class SnitzMembershipProvider : MembershipProvider { private Dictionary<string, MembershipUser> _Users; private string _connectionStringName; private string _connectionString; private SnitzUtils _snitzUtils; /* ---------------------------------------- */ // MembershipProvider Properties /* ---------------------------------------- */ public override string ApplicationName { get { throw new NotSupportedException(); } set { throw new NotSupportedException(); } } /* ---------------------------------------- */ public override bool EnablePasswordRetrieval { get { return false; } } /* ---------------------------------------- */ public override bool EnablePasswordReset { get { return false; } } /* ---------------------------------------- */ public override int MaxInvalidPasswordAttempts { get { throw new NotSupportedException(); } } /* ---------------------------------------- */ public override int MinRequiredNonAlphanumericCharacters { get { throw new NotSupportedException(); } } /* ---------------------------------------- */ public override int MinRequiredPasswordLength { get { throw new NotSupportedException(); } } /* ---------------------------------------- */ public override int PasswordAttemptWindow { get { throw new NotSupportedException(); } } /* ---------------------------------------- */ public override MembershipPasswordFormat PasswordFormat { get { throw new NotSupportedException(); } } /* ---------------------------------------- */ public override string PasswordStrengthRegularExpression { get { throw new NotSupportedException(); } } /* ---------------------------------------- */ public override bool RequiresQuestionAndAnswer { get { throw new NotSupportedException(); } } /* ---------------------------------------- */ public override bool RequiresUniqueEmail { get { throw new NotSupportedException(); } } /* ---------------------------------------- */ // MembershipProvider Methods /* ---------------------------------------- */ public override void Initialize( string name, NameValueCollection config) { if (config == null) throw new ArgumentNullException("config"); if (String.IsNullOrEmpty(name)) name = "ReadOnlySnitzMembershipProvider"; if (string.IsNullOrEmpty(config["description"])) { config.Remove("description"); config.Add("description", "Read-only Snitz membership provider"); } base.Initialize(name, config); _connectionStringName = config["connectionStringName"]; if (String.IsNullOrEmpty(_connectionStringName)) { throw new ProviderException("No connection string was specified.\n"); } _connectionString = ConfigurationManager.ConnectionStrings[ _connectionStringName].ConnectionString; _snitzUtils = new SnitzUtils(); } /* ---------------------------------------- */ public override bool ValidateUser( string username, string password) { if (String.IsNullOrEmpty(username) || String.IsNullOrEmpty(password)) return false; try { ReadMembershipDataStore(); MembershipUser user; if (_Users.TryGetValue(username, out user)) { if ((user.Comment == _snitzUtils.PasswordHash(password)) && (user.IsLockedOut == false) && (user.IsApproved == true)) { return true; } } return false; } catch (Exception) { return false; } } /* ---------------------------------------- */ public override MembershipUser GetUser( string username, bool userIsOnline) { if (String.IsNullOrEmpty(username)) return null; ReadMembershipDataStore(); try { MembershipUser user; if (_Users.TryGetValue(username, out user)) return user; } catch (Exception ex) { throw new ProviderException("Error: " + ex.Message); } return null; } /* ---------------------------------------- */ public override MembershipUserCollection GetAllUsers( int pageIndex, int pageSize, out int totalRecords) { ReadMembershipDataStore(); MembershipUserCollection users = new MembershipUserCollection(); if ((pageIndex >= 0) && (pageSize >= 1)) { try { foreach (KeyValuePair<string, MembershipUser> pair in _Users.Skip(pageIndex * pageSize).Take(pageSize)) { users.Add(pair.Value); } } catch (Exception ex) { throw new ProviderException("Error: " + ex.Message); } } totalRecords = _Users.Count; return users; } /* ---------------------------------------- */ public override int GetNumberOfUsersOnline() { throw new NotSupportedException(); } /* ---------------------------------------- */ public override bool ChangePassword( string username, string oldPassword, string newPassword) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override bool ChangePasswordQuestionAndAnswer( string username, string password, string newPasswordQuestion, string newPasswordAnswer) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override MembershipUser CreateUser( string username, string password, string email, string passwordQuestion, string passwordAnswer, bool isApproved, object providerUserKey, out MembershipCreateStatus status) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override bool DeleteUser( string username, bool deleteAllRelatedData) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override MembershipUserCollection FindUsersByEmail( string emailToMatch, int pageIndex, int pageSize, out int totalRecords) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override MembershipUserCollection FindUsersByName( string usernameToMatch, int pageIndex, int pageSize, out int totalRecords) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override string GetPassword( string username, string answer) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override MembershipUser GetUser( object providerUserKey, bool userIsOnline) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override string GetUserNameByEmail(string email) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override string ResetPassword( string username, string answer) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override bool UnlockUser(string userName) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override void UpdateUser(MembershipUser user) { throw new NotSupportedException(); } /* ---------------------------------------- */ // MembershipProvider helper method /* ---------------------------------------- */ public void ReadMembershipDataStore() { lock (this) { if (_Users == null) { try { _Users = new Dictionary<string, MembershipUser>( 16, StringComparer.InvariantCultureIgnoreCase); string queryString = "SELECT * FROM FORUM_MEMBERS"; using (OdbcConnection connection = new OdbcConnection(_connectionString)) { OdbcCommand command = new OdbcCommand(queryString, connection); connection.Open(); OdbcDataReader reader = command.ExecuteReader(); while (reader.Read()) { string sUserName = reader["M_NAME"].ToString(); string sEmail = reader["M_EMAIL"].ToString(); string sPassword = reader["M_PASSWORD"].ToString(); DateTime dCreationDate = _snitzUtils.ConvertDate(reader["M_DATE"].ToString()); DateTime dLastLoginDate = _snitzUtils.ConvertDate(reader["M_LASTHEREDATE"].ToString()); DateTime dLastActivityDate = _snitzUtils.ConvertDate(reader["M_LASTPOSTDATE"].ToString()); if (dLastActivityDate == new DateTime(1980, 1, 1)) { dLastActivityDate = dLastLoginDate; } Int32 status = Convert.ToInt32(reader["M_STATUS"].ToString()); bool approved = (status == -1) ? false : true; bool locked = (status == 0) ? true : false; MembershipUser user = new MembershipUser( Name, // Provider name sUserName, // UserName null, // ProviderUserKey sEmail, // Email String.Empty, // PasswordQuestion sPassword, // Comment approved, // IsApproved locked, // IsLockedOut dCreationDate, // CreationDate dLastLoginDate, // LastLoginDate dLastActivityDate, // LastActivityDate dCreationDate, // LastPasswordChangedDate dCreationDate // LastLockoutDate ); _Users.Add(user.UserName, user); } reader.Close(); } } catch (Exception ex) { throw new ProviderException("Error: " + ex.Message); } } } } } /* ======================================== */ // // SnitzRoleProvider // /* ======================================== */ public class SnitzRoleProvider : RoleProvider { private string _connectionStringName; private string _connectionString; private SnitzUtils _snitzUtils; private Dictionary<string, string[]> _UsersAndRoles = new Dictionary<string, string[]>( 16, StringComparer.InvariantCultureIgnoreCase); private Dictionary<string, string[]> _RolesAndUsers = new Dictionary<string, string[]>( 16, StringComparer.InvariantCultureIgnoreCase); /* ---------------------------------------- */ // RoleProvider properties /* ---------------------------------------- */ public override string ApplicationName { get { throw new NotSupportedException(); } set { throw new NotSupportedException(); } } /* ---------------------------------------- */ // RoleProvider methods /* ---------------------------------------- */ public override void Initialize( string name, NameValueCollection config) { if (config == null) throw new ArgumentNullException("config"); if (String.IsNullOrEmpty(name)) name = "ReadOnlySnitzRoleProvider"; if (String.IsNullOrEmpty(config["description"])) { config.Remove("description"); config.Add("description", "Read-only Snitz role provider"); } base.Initialize(name, config); _connectionStringName = config["connectionStringName"]; if (String.IsNullOrEmpty(_connectionStringName)) { throw new ProviderException( "No connection string was specified.\n"); } _connectionString = ConfigurationManager.ConnectionStrings [_connectionStringName].ConnectionString; _snitzUtils = new SnitzUtils(); ReadRoleDataStore(); } /* ---------------------------------------- */ public override bool IsUserInRole( string username, string roleName) { if (username == null || roleName == null) throw new ArgumentNullException(); if (username == String.Empty || roleName == String.Empty) throw new ArgumentException(); if (!_UsersAndRoles.ContainsKey(username)) throw new ProviderException("Invalid user name"); if (!_RolesAndUsers.ContainsKey(roleName)) throw new ProviderException("Invalid role name"); string[] roles = _UsersAndRoles[username]; foreach (string role in roles) { if (String.Compare(role, roleName, true) == 0) return true; } return false; } /* ---------------------------------------- */ public override string[] GetRolesForUser(string username) { if (username == null) throw new ArgumentNullException(); if (username == String.Empty) throw new ArgumentException(); string[] roles; if (!_UsersAndRoles.TryGetValue(username, out roles)) throw new ProviderException("Invalid user name"); return roles; } /* ---------------------------------------- */ public override string[] GetUsersInRole(string roleName) { if (roleName == null) throw new ArgumentNullException(); if (roleName == string.Empty) throw new ArgumentException(); string[] users; if (!_RolesAndUsers.TryGetValue(roleName, out users)) throw new ProviderException("Invalid role name"); return users; } /* ---------------------------------------- */ public override string[] GetAllRoles() { int i = 0; string[] roles = new string[_RolesAndUsers.Count]; foreach (KeyValuePair<string, string[]> pair in _RolesAndUsers) roles[i++] = pair.Key; return roles; } /* ---------------------------------------- */ public override bool RoleExists(string roleName) { if (roleName == null) throw new ArgumentNullException(); if (roleName == String.Empty) throw new ArgumentException(); return _RolesAndUsers.ContainsKey(roleName); } /* ---------------------------------------- */ public override void CreateRole(string roleName) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override bool DeleteRole( string roleName, bool throwOnPopulatedRole) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override void AddUsersToRoles( string[] usernames, string[] roleNames) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override string[] FindUsersInRole( string roleName, string usernameToMatch) { throw new NotSupportedException(); } /* ---------------------------------------- */ public override void RemoveUsersFromRoles( string[] usernames, string[] roleNames) { throw new NotSupportedException(); } /* ---------------------------------------- */ // RoleProvider helper method /* ---------------------------------------- */ private void ReadRoleDataStore() { lock (this) { try { string queryString = "SELECT * FROM FORUM_MEMBERS"; using (OdbcConnection connection = new OdbcConnection(_connectionString)) { OdbcCommand command = new OdbcCommand(queryString, connection); connection.Open(); OdbcDataReader reader = command.ExecuteReader(); while (reader.Read()) { string user = reader["M_NAME"].ToString(); Int32 level = Convert.ToInt32(reader["M_LEVEL"].ToString()); ArrayList roleList = new ArrayList(); roleList.Add("Members"); if ((level == 2) || (level == 3)) roleList.Add("Moderators"); if (level == 3) roleList.Add("Administrators"); string[] roles = (string[])roleList.ToArray(typeof(string)); _UsersAndRoles.Add(user, roles); foreach (string role in roles) { string[] users1; if (_RolesAndUsers.TryGetValue(role, out users1)) { string[] users2 = new string[users1.Length + 1]; users1.CopyTo(users2, 0); users2[users1.Length] = user; _RolesAndUsers.Remove(role); _RolesAndUsers.Add(role, users2); } else _RolesAndUsers.Add(role, new string[] { user }); } } reader.Close(); } } catch (Exception ex) { throw new ProviderException("Error: " + ex.Message); } } } } /* ======================================== */ // // SnitzUtils // /* ======================================== */ internal class SnitzUtils { /* ---------------------------------------- */ internal string PasswordHash(string password) { try { SHA256 sha256 = new SHA256Managed(); byte[] byteArray = sha256.ComputeHash(Encoding.ASCII.GetBytes(password)); StringBuilder stringBuilder = new StringBuilder(byteArray.Length * 2); foreach (byte byteMember in byteArray) { stringBuilder.AppendFormat("{0:x2}", byteMember); } return stringBuilder.ToString(); } catch (Exception ex) { throw new ProviderException("Error: " + ex.Message); } } /* ---------------------------------------- */ internal DateTime ConvertDate(string snitzDate) { DateTime dateTime; try { if (String.IsNullOrEmpty(snitzDate)) { dateTime = new DateTime(1980, 1, 1); } else { dateTime = Convert.ToDateTime( snitzDate.Substring(0, 4) + "/" + snitzDate.Substring(4, 2) + "/" + snitzDate.Substring(6, 2) + " " + snitzDate.Substring(8, 2) + ":" + snitzDate.Substring(10, 2) + ":" + snitzDate.Substring(12, 2) ); } } catch { dateTime = new DateTime(1980, 1, 1); } return dateTime; } } }
- Save and compile the project.
Step 3: Add the provider to IIS
In this third step you will determine the assembly information for the membership and role provider, and then add that information to the list of trusted providers for IIS.
- Determine the assembly information for the provider:
- In Windows Explorer, open your "%WinDir%\assembly" path.
- Right-click the ReadOnlySnitzProvider assembly and click Properties.
- Copy the Culture value; for example: Neutral.
- Copy the Version number; for example: 1.0.0.0.
- Copy the Public Key Token value; for example: f0e1d2c3b4a59687.
- Click Cancel.
- Add the provider to the list of trusted providers for IIS:
- Open the Administration.config file for editing. (Note: This file is located in your "%WinDir%\System32\Inetsrv\Config" folder.)
- Add the providers with the assembly properties from the previous steps to the <trustedProviders> section using the following syntax:
<add type="ReadOnlySnitzProvider.SnitzMembershipProvider, ReadOnlySnitzProvider, Version=1.0.0.0, Culture=neutral, PublicKeyToken=f0e1d2c3b4a59687" />
<add type="ReadOnlySnitzProvider.SnitzRoleProvider, ReadOnlySnitzProvider, Version=1.0.0.0, Culture=neutral, PublicKeyToken=f0e1d2c3b4a59687" />
- Save and close the the Administration.config file.
Step 4: Configure your site for Forms Authentication using the Snitz provider
In this fourth step you will configure your Web site to use forms authentication with the membership and role providers by manually creating a Web.config file for your Web site that sets the requisite properties for forms authentication/authorization, and adding a Login.aspx page to the Web site that will process forms authentication requests. Note: This example will authorize all Snitz accounts through the "Members" role.
- Create a Login.aspx file for your Web site:
- Paste the following code into a text editor:
<%@ Page Language="C#" %> <%@ Import Namespace="System.ComponentModel" %> <html> <head runat="server"> <title>Login Page</title> </head> <body> <form id="form1" runat="server"> <asp:Login id="Login1" runat="server" BorderStyle="Solid" BackColor="#ffffcc" BorderWidth="1px" BorderColor="#cccc99" Font-Size="10pt" Font-Names="Verdana"> <TitleTextStyle Font-Bold="True" ForeColor="#ffffff" BackColor="#666666"/> </asp:Login> </form> </body> </html>
- Save the code as "Login.aspx" in the root of your Web site.
- Paste the following code into a text editor:
- Create a Web.config file for your Web site:
- Paste the following code into a text editor:
<configuration> <!-- Add the connection string for the providers. --> <connectionStrings> <add name="SnitzForums" connectionString="DRIVER={Microsoft Access Driver (*.mdb)};DBQ=C:\Inetpub\wwwdata\snitz_forums_2000.mdb" /> </connectionStrings> <system.web> <!-- Add the read-only membership provider and set it as the default. --> <membership defaultProvider="ReadOnlySnitzMembershipProvider"> <providers> <add name="ReadOnlySnitzMembershipProvider" type="ReadOnlySnitzProvider.SnitzMembershipProvider, ReadOnlySnitzProvider, Version=1.0.0.0, Culture=neutral, PublicKeyToken=f0e1d2c3b4a59687" description="Read-only Snitz membership provider" connectionStringName="SnitzForums" /> </providers> </membership> <!-- Add the read-only role provider and set it as the default. --> <roleManager defaultProvider="ReadOnlySnitzRoleProvider" enabled="true"> <providers> <add name="ReadOnlySnitzRoleProvider" type="ReadOnlySnitzProvider.SnitzRoleProvider, ReadOnlySnitzProvider, Version=1.0.0.0, Culture=neutral, PublicKeyToken=f0e1d2c3b4a59687" description="Read-only Snitz role provider" connectionStringName="SnitzForums" /> </providers> </roleManager> <!-- Set the authentication mode to forms authentication. --> <authentication mode="Forms" /> </system.web> <system.webServer> <modules> <!-- Set authentication for the application. --> <remove name="FormsAuthentication" /> <add name="FormsAuthentication" type="System.Web.Security.FormsAuthenticationModule" preCondition="" /> <remove name="DefaultAuthentication" /> <add name="DefaultAuthentication" type="System.Web.Security.DefaultAuthenticationModule" preCondition="" /> <remove name="RoleManager" /> <add name="RoleManager" type="System.Web.Security.RoleManagerModule" preCondition="" /> </modules> <security> <!-- Set authorization for the application. --> <authorization> <remove users="*" roles="" verbs="" /> <add accessType="Allow" roles="Members" /> </authorization> </security> </system.webServer> </configuration>
- Note: Make sure that the PublicKeyToken value contains the correct public key token from the assembly properties that you copied in previous steps, and the connectionString value contains the correct information for your Snitz database.
- Save the code as "Web.config" in the root of your Web site.
- Paste the following code into a text editor:
Additional notes for using the read-only Snitz provider
As mentioned before, all of the user account management features are built-in to the Snitz forums, so I did not add them to my provider. The being said, there are still several features that integrate nicely with IIS. The following screenshot shows the list of users for a Snitz forum in Internet Explorer:
You'll notice that several pieces of information are listed for each user: user name, title (role), account creation date, last visit, lockout status, etc. If you open the .NET Users feature for your site, you'll notice that the account information is mirrored there, as shown in the following illustration:
Likewise, if you open the .NET Roles feature for your site, you'll notice that the three roles are enumerated and the number of users per role is listed:
All of the above information in the .NET Users and .NET Roles features is read-only, so any attempt to modify user or role information will return an error that the specified method is not supported:
That being said, you can use the IIS manager to allow or deny and of the user accounts or Snitz roles using the Authorization Rules feature:
You should recall from earlier that you can use any of the three roles from the provider: Members, Moderators, and Administrators.
Summary and parting thoughts
So there you have it - a simple read-only membership and role provider for the Snitz forums. As previously mentioned - this is not a full-featured provider because I only needed it to fulfill a specific need for forms authentication. I had added more features at one point, and that's why the utility class used to be a little larger, but in the end I decided that it was overkill for my purpose and I deleted some of the original code. If you want to be a little adventurous, you could easily expand this provider to perform some of the additional provider tasks like adding and removing users or assigning users to roles.
I hope this provider helps someone out there, and I had a lot of fun writing it - which is the point of writing code, isn't it?