I realize that's a pretty boring title, but let me explain:
I have a few websites that I've created over the past several years that I use quite frequently. These include:
- A bug tracking system that I developed a few years ago that I'm in the process of overhauling
- A custom media server that streams audio content based off of custom, user-defined playlists from my personal collection of WMA and miscellaneous files
- A special family gallery of photographs
Needless to say I am not the only user of these sites. Various friends, associates, and family members frequent the sites (despite their work-in-progress nature). However, there is a catch: anonymous access is not allowed. If a user wants to view the contents of a site, he must log in. With the myriad of individuals out there that access these sites it would be impractical, not to mention extremely labor-intensive and would expose my network to security risks to provide a Windows logon for each user...that would be insanity.
For these and other reasons I opted for a Forms-over-SSL-based authentication mechanism to gain access to these sites. The forms authentication backends ultimately to a SQL Server database that maintains the users and their encrypted credentials. Ok, nothing magic here. Simple, routine computing. But this is where the fun begins.
A user may have access to one, two, or all of the sites. I don't want to store multiple credentials for the same user (potentially with all the same values), one for each site. Heaven forbid that we create a user credentials database for each site! I wanted a single repository for all credentials. Ok, still nothing magic - everyone has to solve these kinds of problems. Despite these relatively mundane and trivial tasks, the whole reason I bring this up is because I think my solution is pretty cool. Note: I haven't yet tested this solution for scalability nor have I profiled it. I'm not expecting a boatload of traffic, so this suits my personal needs fine, but I would test this for any real-world application...don't just take my word for it that it's good.
On the backend I have a SQL Server 2000 database that maintains a master list of websites and logins and mappings between the two among some other stuff. Stored procedures exist that allow for the management of these tables as well as the validating that a user has permissions to a particular site.
Over this database exists a set of web services that make calls to the stored procedures. These web services (by their very nature) are accessible to the web sites but instead of accessing them directly, the web sites rely on a set of helper classes that abstract the web service interfaces. For security reasons, the web services are hidden completely behind a firewall with no outside access. Additionally, the only user with access to the database procedures (and only the procedures) is the user associated with the web service virtual directory in IIS.
An example of the web service call that validates the user is the following:
[WebMethod(Description="Checks to determine if the login id and password are associated with a particular website.")]
public bool IsValidLogin(string loginID, string password, string webSite) {
string pwdHash = FormsAuthentication.HashPasswordForStoringInConfigFile(password, "SHA1");
using ( SqlCommand cm = new SqlCommand("dsProc_ssoIsValidLogin", getConnection()) ) {
cm.CommandType = CommandType.StoredProcedure;
cm.Parameters.Add("@LoginID", SqlDbType.NVarChar, 25).Value = loginID;
cm.Parameters.Add("@Password", SqlDbType.NVarChar, 40).Value = pwdHash;
cm.Parameters.Add("@Website", SqlDbType.NVarChar, 30).Value = webSite;
return (int)cm.ExecuteScalar() > 0;
}
}
As can be ascertained, each website to which the users can gain access has a name. The userid/password combination is matched up to the website name. In the case of a match, a value of True is returned.
I mentioned a second ago that there is a set of helper classes that provide the interfaces to the Web Services. Now with Visual Studio .NET I could have just as easily created a web reference and called it good. However, I don't know if you may have had the same experiences that I've had, but I tend to open the References.cs file (which is automatically generated when the web reference is created) and edit it. Mainly the edits involve the line that reads
this.Url = "http://localhost/.....";
because this url differs between development and production. I usually edit it to read the value from a configuration file (Web.config). It's a pain to have to edit that file only to have to reedit it when the web reference is refreshed - it's easy to forget to do it, that's for sure. Not that my web services change their contracts often but you know.
That's one of the reasons I decided to create the set of helper classes, but the main driving reason was that I wanted a consistent, simplified, and shared object that didn't behave like a web service. I didn't want to have to create an instance of the soap client to call a method and then dispose of it. I wanted this class to be sharable across all of the web sites that needed the same functionality.
The solution I came up with was to create a class called SignOnValidator that has a single static method called IsValidLogin that takes the user's login id and password as parameters. This method then resolves the name of the website by reflecting on the calling assembly to find a custom attribute called SignOnWebSiteNameAttribute instead of relying on a parameter passed in. Then it creates an instance of an internal class that manages the call to the web service proper, returning the result. I might need to do some optimization to the code, but here it is (unoptimized):
using System;
using System.Configuration;
using System.Reflection;
using System.Web.Services;
using System.Web.Services.Protocols;
[assembly:AssemblyVersion("1.0.0.0")]
[assembly:AssemblyCopyright("Copyright © 2004, Devstone Software")]
namespace Devstone.Web.WebAuth {
public sealed class SignOnValidator {
private SignOnValidator() { /* no public constructor */ }
public static bool IsValidLogin(string loginID, string password) {
string siteName;
// get the instance of the attribute in the calling assembly
Assembly caller = Assembly.GetCallingAssembly();
if ( caller.IsDefined(typeof(SignOnWebSiteNameAttribute), false) ) {
// retrieve the SignOnName for the calling application
SignOnWebSiteNameAttribute attrib = caller.GetCustomAttributes(typeof(SignOnWebSiteNameAttribute), false)[0] as SignOnWebSiteNameAttribute;
siteName = attrib.SiteName;
}
else {
throw new ArgumentException("Unable to authenticate the user. Please make sure that the calling assembly has the SignOnWebSiteNameAttribute specified.");
}
using ( SignOnWebService svc = new SignOnWebService() ) {
return svc.IsValidLogin(loginID, password, siteName);
}
}
} // SignOnValidator class
[WebServiceBinding(Name="SignOnValidatorSoap", Namespace="http://signon.devstone.com/")]
internal sealed class SignOnWebService : SoapHttpClientProtocol {
internal SignOnWebService() {
string url = ConfigurationSettings.AppSettings["LogonServiceUrl"];
if ( null == url )
throw new ArgumentException("Unable to resolve the URL for the logon service. Please make sure your application's configuration settings are correct.");
else
this.Url = url;
}
[SoapDocumentMethod("http://signon.devstone.com/IsValidLogin",
RequestNamespace="http://signon.devstone.com/",
ResponseNamespace="http://signon.devstone.com/",
Use=System.Web.Services.Description.SoapBindingUse.Literal,
ParameterStyle=System.Web.Services.Protocols.SoapParameterStyle.Wrapped)]
public bool IsValidLogin(string loginID, string password, string webSite) {
object[] results = this.Invoke("IsValidLogin", new object[] { loginID, password, webSite});
return ((bool)(results[0]));
}
} // SignOnWebService class
} // Devstone.Web.WebAuth namespace
Then of course there's the question of the SignOnWebSiteNameAttribute. Each website that utilizes this sign on mechanism will have to define this attribute, but in my book this simplifies management of the site names.
[assembly: SignOnWebSiteName("mysitename")]
Done. Everything else will be taken care of because the helper class will automatically resolve the proper site name on invocation. Oh, and if you're wanting the code for the attribute class, here it is:
using System;
namespace Devstone.Web.WebAuth {
[AttributeUsage(AttributeTargets.Assembly)]
public sealed class SignOnWebSiteNameAttribute : Attribute {
private string _siteName = null;
public SignOnWebSiteNameAttribute(string siteName) {
_siteName = siteName;
}
public string SiteName {
get { return _siteName; }
}
} // SignOnWebSiteNameAttribute class
} // Devstone.Web.WebAuth namespace
I really like this solution despite the fact that I have to maintain changes made in the web service manually in the SignOnValidator class. Apart from everything else, I found a good example for using a custom Attribute! I really think this will simplify the management of website users while providing a very simple programming model for validating users. Not to mention the benefit that a user has a single login id, name, password, and email across all sites - I hate trying to remember 50 million user IDs and passwords