Automatic Login for SharePoint using Claims authentication
The Scenario
I've recently struck a scenario where I wanted to integrate my MVC web application to a SharePoint 2010 server and upload documents. This process happened on our server and occured in some parallel processing from our website.
The SharePoint implementation we have is cloud based and hence is set up with Claims authentication. This can be a bit of a challenge to understand but there are a lot of good articles on the web about it (although some are really complex). Basically claims authentication allows a 3rd party to control the credentials for access to the site. In this case Microsoft Office365 Live is the claim provider which provides the authenticated token to the SharePoint site which trusts Microsoft Office365 Live to give it a legitimate token. Thats why when you are using SharePoint and login it redirects you to the Office365 login page and then redirects you back to SharePoint afterwards.
This is all good except when you want to automate this process, for example you have the username and password for SharePoint encrypted somewhere and you want to access SharePoint using these credentials. This becomes hard for Claims based authentication as opposed to Forms or Windows where you can just set the username/password combination in the security token.
The Solutions
Firstly I don't by any means claim that I came up with the entire solution, I did however do a LOT of investigation and cobbled together a few different articles to come up with my solution. My main sources of documentation came from these two great articles:
Remote Authentication in SharePoint Online Using Claims-Based Authentication - provided by Microsoft, which provides a mechanism for challenging and obtaining credentials from a 3rd party application using a popup Web Browser.
Using the WebBrowser Control in ASP-NET - is an awesome CodeProject article by Dianyang Yu which shows how to use any type of Windows control within the context of a web application. I was very impressed by this and is well worth the read.
I basically took these and added my own code to make it work for my scenario as these don't fit my needs by themselves for these 2 reasons:
- The MSDN article is for a Windows based application where the WebBrowser control is used to popup and ask for the credentials. I could grab the cookie values from this and store is somewhere for use within the MVC application but those credentials expire after 10 hours (I think) so I would constantly have to update them.
- The CodeProject article didn't automate the credentials in the browser although it was pretty close.
My Tweaks
I started with the MSDN article code and the first thing I did was create a Sub class for the WebBrowser control which posted the credentials automatically to the server. This was a simple class:
public class AutoWebBrowser : WebBrowser
{
private static readonly ILog log = LogManager.GetLogger("RALPH");
public void SetCredentials(string username, string password)
{
username = HttpUtility.HtmlEncode(username);
password = HttpUtility.HtmlEncode(password);
Match match = Regex.Match(this.DocumentText, @".*PPFT.*value=""([^""]*)"".*");
string ppft = "";
if (match.Success)
{
ppft = match.Groups[1].Value;
}
else
{
throw new Exception(@"Could not find PPFT element in document:
" + this.DocumentText);
}
string postDataFormat = @"login={0}&passwd={1}&type=11&LoginOptions=2&MEST=&PPSX=Pass&PPFT={2}&PwdPad=&sso=&i1=1&i2=2&i3=4296&i4=&i8=&i9=&i10=&i12=1";
string postData = string.Format(postDataFormat, username, password, ppft);
byte[] data = new System.Text.UTF8Encoding().GetBytes(postData);
string postURL = "https://login.microsoftonline.com/ppsecure/post.srf" + this.Url.Query;
this.Navigate(postURL, "", data, "Content-Type: application/x-www-form-urlencoded\r\n");
}
}
In order to post the credentials to the browser I had to set the content type and obtain the correct Flow Token and URL. The post data contains the Flow Token attribute which changes everytime you login so I had to obtain that from the Login screen and put it in to the Post Data. Likewise the URL to post to is also included in the Document data but I elected to just hard code this in instead of pulling it out of the document text.
Fiddler came in very handy in figuring all this out.
Next I pulled in the IEBrowserContext from the CodeProject article and created a new class for that which runs the ClaimsWebAuth (from the MSDN article) in its context:
public class IEBrowserContext : ApplicationContext, IDisposable
{
private static readonly ILog log = LogManager.GetLogger("RALPH");
private Thread thread;
private ClaimsWebAuth claims;
private AutoResetEvent parentNotify;
public CookieCollection AuthenticationCookies;
public IEBrowserContext(string url, string username, string password, AutoResetEvent resultEvent)
{
parentNotify = resultEvent;
string lurl;
Uri nurl;
this.GetClaimParams(url, out lurl, out nurl);
thread = new Thread(new ThreadStart(
delegate
{
Init(lurl, nurl, username, password);
System.Windows.Forms.Application.Run(this);
}));
// set thread to STA state before starting
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
private void Init(string loginUrl, Uri navigationEndUrl, string username, string password)
{
claims = new ClaimsWebAuth(loginUrl, navigationEndUrl, this);
claims.UserName = username;
claims.Password = password;
}
public void Complete()
{
parentNotify.Set();
}
protected override void Dispose(bool disposing)
{
if (thread != null)
{
thread.Abort();
thread = null;
return;
}
claims.Dispose();
base.Dispose(disposing);
}
private void GetClaimParams(string targetUrl, out string loginUrl, out Uri navigationEndUrl)
{
HttpWebRequest webRequest = (HttpWebRequest)WebRequest.Create(targetUrl);
webRequest.Method = Constants.WR_METHOD_OPTIONS;
#if DEBUG
ServicePointManager.ServerCertificateValidationCallback = new RemoteCertificateValidationCallback(IgnoreCertificateErrorHandler);
#endif
try
{
WebResponse response = (WebResponse)webRequest.GetResponse();
ExtraHeadersFromResponse(response, out loginUrl, out navigationEndUrl);
}
catch (WebException webEx)
{
ExtraHeadersFromResponse(webEx.Response, out loginUrl, out navigationEndUrl);
}
}
private bool ExtraHeadersFromResponse(WebResponse response, out string loginUrl, out Uri navigationEndUrl)
{
loginUrl = null;
navigationEndUrl = null;
try
{
navigationEndUrl = new Uri(response.Headers[Constants.CLAIM_HEADER_RETURN_URL]);
loginUrl = (response.Headers[Constants.CLAIM_HEADER_AUTH_REQUIRED]);
return true;
}
catch
{
return false;
}
}
private bool IgnoreCertificateErrorHandler
(object sender,
System.Security.Cryptography.X509Certificates.X509Certificate certificate,
System.Security.Cryptography.X509Certificates.X509Chain chain,
System.Net.Security.SslPolicyErrors sslPolicyErrors)
{
return true;
}
}
The main difference with this is that we had to raise notification events so that the application could manually wait for the response from the Claims Authentication. I also made this use the MSDN article code as the basis for authentication.
Finally it was just a case of modifying the ClaimsWebAuth.cs file so that it automatically logs in.
I altered the Constructor as follows:
public ClaimsWebAuth(string loginUrl, Uri navigationUrl, IEBrowserContext ctx)
{
Owner = ctx;
LoginPageUrl = loginUrl;
NavigationEndUrl = navigationUrl;
log.Debug("Constructing ClaimsWebAuth for URL: " + loginUrl);
this.webBrowser = new AutoWebBrowser();
this.webBrowser.Navigated += new WebBrowserNavigatedEventHandler(ClaimsWebBrowser_Navigated);
this.webBrowser.DocumentCompleted += new WebBrowserDocumentCompletedEventHandler(webBrowser_DocumentCompleted);
this.webBrowser.ScriptErrorsSuppressed = true;
if (string.IsNullOrEmpty(this.LoginPageUrl)) throw new ApplicationException(Constants.MSG_NOT_CLAIM_SITE);
// navigate to the login page url.
this.webBrowser.Navigate(this.LoginPageUrl);
}
The constructor registers a new event for the DocumentCompleted and Navigates to the login page.
The DocumentCompleted event then sets the credentials on the AutoWebBrowser and I altered the Navigated event to notify the IEBrowserContext that we were all finished.
private void ClaimsWebBrowser_Navigated(object sender, WebBrowserNavigatedEventArgs e)
{
// check whether the url is same as the navigationEndUrl.
if (NavigationEndUrl != null && NavigationEndUrl.Equals(e.Url))
{
Owner.AuthenticationCookies = ExtractAuthCookiesFromUrl(this.LoginPageUrl);
Owner.Complete();
}
}
void webBrowser_DocumentCompleted(object sender, WebBrowserDocumentCompletedEventArgs e)
{
if (!credentialsSet)
{
this.webBrowser.SetCredentials(UserName, Password);
credentialsSet = true;
}
}
I then just remembered to dispose the AutoWebBrowser when the ClaimsWebAuth is disposed.
Usage
To use the new automatic login I created a simple Singleton object which stores the cookies as follows:
public void LoadCredentials(string url, string username, string password)
{
resultEvent = new AutoResetEvent(false);
System.Timers.Timer timer = new System.Timers.Timer(30000);
timer.AutoReset = false;
timer.Elapsed += new System.Timers.ElapsedEventHandler(timer_Elapsed);
using (IEBrowserContext webAuth = new IEBrowserContext(url, username, password, resultEvent))
{
timer.Start();
EventWaitHandle.WaitAll(new AutoResetEvent[] { resultEvent });
timer.Stop();
AuthenticationCookies = webAuth.AuthenticationCookies;
}
CredentialsLoaded = AuthenticationCookies != null;
timer.Dispose();
}
void timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
{
resultEvent.Set();
}
public CookieCollection AuthenticationCookies {get;set;}
I also set a timer so that if no response is returned in 30 seconds it fires an exception.
So thats how I did it. I did notice some problems when installing on the server, firstly make sure that the server can Browse to the login page and you can login (i.e Javascript is enabled etc). I also had some security issues around the web identity being used to run the WebBrowser control, in the end I set a new Credential on the Application pool which enabled the server to access the login page fine.
Once you have the authentication cookies then just follow the MSDN article to store them on the SharePoint access.
Good luck!