Wednesday 14 December 2011

Custom Login Page Sharepoint 2010

The implementation above actually works as expected -- well, up to a point.

The problem is that when you click the out-of-the-box Sign Out link anytime after authenticating via the custom Web Part, a rather nasty unhandled exception occurs:

[ArgumentException: Exception of type 'System.ArgumentException' was thrown. Parameter name: encodedValue] Microsoft.SharePoint.Administration.Claims.SPClaimEncodingManager.DecodeClaimFromFormsSuffix(String encodedValue) +25829214 Microsoft.SharePoint.Administration.Claims.SPClaimProviderManager.GetProviderUserKey(String encodedSuffix) +73 Microsoft.SharePoint.ApplicationRuntime.SPHeaderManager.AddIsapiHeaders(HttpContext context, String encodedUrl, NameValueCollection headers) +845 Microsoft.SharePoint.ApplicationRuntime.SPRequestModule.PreRequestExecuteAppHandler(Object oSender, EventArgs ea) +352 System.Web.SyncEventExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute() +80 System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously) +171

Obviously this exception doesn't occur when authenticating using the OOTB login pages. I also verified that it doesn't occur when using a custom application page for FBA and claims. This really had me stumped for a little while since my Web Part was calling the exact same code as the custom application page in order to perform the claims authentication.

Using Fiddler, I observed the following cookie when authenticating with the OOTB login page:

  • Cookies / Login
    • Cookie
      • FedAuth
        • 77u/PD94...

I noticed a similar cookie when authenticating with the custom application page (e.g. /_layouts/Fabrikam/SignIn.aspx).

However, when I logged in using the Claims Login Form Web Part, I found that there were two cookies:

  • Cookies / Login
    • Cookie
      • .ASPXAUTH=1D0DDE35...
      • FedAuth
        • 77u/PD94...

When I then clicked Sign Out, I noticed that while the "FedAuth" cookie was removed from the subsequent request, the ".ASPXAUTH" cookie was not. In other words, the presence of an ".ASPXAUTH" cookie (without a "FedAuth" cookie) causes SharePoint Server 2010 to "blow chunks."

To remedy the issue, I moved the code in the LoginForm_LoggedIn event handler into the LoginForm_Authenticate event handler. Since the user is redirected upon successful login, this prevents the ASP.NET Login control from generating the ".ASPXAUTH" cookie. With this change, the OOTB Sign Out link started working as expected.

COMPLETE WEBPART CODE.... can comment out SPLogger.Log functionality..  using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IdentityModel.Tokens;
using System.Web;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using Fabrikam.Demo.CoreServices.SharePoint.Diagnostics;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Administration;
using Microsoft.SharePoint.IdentityModel;
using Microsoft.SharePoint.Utilities;
using Microsoft.SharePoint.WebControls;
using System.Web.Security;
using System.Web.UI;

namespace Fabrikam.Demo.Web.UI.WebControls
{
///
/// A Web Part that displays a login form for claims-based authentication.
///

[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Microsoft.Naming",
"CA1726:UsePreferredTerms",
MessageId = "Login")]
[ToolboxItemAttribute(false)]
public class ClaimsLoginFormWebPart : SslRequiredWebPart
{
private Login loginForm;
private HyperLink windowsLoginLink;
private const string defaultWindowsLoginLinkText =
"Fabrikam Employee Sign In";

///
/// Gets or sets a value that indicates whether to display a check box
/// to enable the user to control whether a persistent cookie is sent
/// to their browser.
///

[Category("Presentation")]
[DefaultValue(true)]
[Personalizable(PersonalizationScope.Shared, true)]
[WebBrowsable(true)]
[WebDescription("Indicates whether to display a check box to enable the"
+ "user to control whether a persistent cookie is sent to their"
+ "browser.")]
[WebDisplayName("Display Remember Me")]
public bool DisplayRememberMe
{
get
{
if (HttpContext.Current == null)
{
// Avoid NullReferenceException when programmatically
// configuring pages outside the context of an HTTP request
// (e.g. when adding the Web Part to a page during feature
// activation through PowerShell)
return true;
}

this.EnsureChildControls();
return this.loginForm.DisplayRememberMe;
}
set
{
this.EnsureChildControls();
this.loginForm.DisplayRememberMe = value;
}
}

private static SPIisSettings IisSettings
{
get
{
SPWebApplication webApp = SPWebApplication.Lookup(
new Uri(SPContext.Current.Web.Url));

SPIisSettings settings = null;

if (webApp.IisSettings.ContainsKey(
SPContext.Current.Site.Zone) == true)
{
settings = webApp.IisSettings[
SPContext.Current.Site.Zone];
}
else
{
Debug.Assert(webApp.IisSettings.ContainsKey(
SPUrlZone.Default) == true);

settings = webApp.IisSettings[SPUrlZone.Default];
}

return settings;
}
}

///
/// Gets or sets the text for the link used for Windows authentication.
///

[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Microsoft.Naming",
"CA1726:UsePreferredTerms",
MessageId = "Login")]
[Category("Presentation")]
[DefaultValue(defaultWindowsLoginLinkText)]
[Personalizable(PersonalizationScope.Shared, true)]
[WebBrowsable(true)]
[WebDescription("Gets or sets the text for the link used for Windows"
+ " authentication.")]
[WebDisplayName("Windows Login Link Text")]
public string WindowsLoginLinkText
{
get
{
if (HttpContext.Current == null)
{
// Avoid NullReferenceException when programmatically
// configuring pages outside the context of an HTTP request
// (e.g. when adding the Web Part to a page during feature
// activation through PowerShell)
return defaultWindowsLoginLinkText;
}

this.EnsureChildControls();
return this.windowsLoginLink.Text;
}
set
{
this.EnsureChildControls();
this.windowsLoginLink.Text = value;
}
}

///
/// Called by the ASP.NET page framework to notify server controls
/// that use composition-based implementation to create any child
/// controls they contain in preparation for posting back or rendering.
///

protected override void CreateChildControls()
{
base.CreateChildControls();

this.CssClass = "loginForm";

if (this.Page.Request.IsSecureConnection == false)
{
this.Controls.Add(new LiteralControl(
"

Warning: This page is not encrypted"
+ " for secure communication. The user name and"
+ " password entered in the form below will be sent in"
+ " clear text.

"));
}

this.loginForm = new Login();
this.loginForm.TextBoxStyle.CssClass = "text";

// HACK: ASP.NET hard-codes align="center" if not specified
this.loginForm.FailureTextStyle.HorizontalAlign =
HorizontalAlign.Left;

this.loginForm.FailureTextStyle.CssClass = "errorMessage";

this.loginForm.Authenticate += new AuthenticateEventHandler(
LoginForm_Authenticate);

this.loginForm.LoginError += new EventHandler(LoginForm_LoginError);

this.Controls.Add(loginForm);

windowsLoginLink = new HyperLink();
windowsLoginLink.Text = defaultWindowsLoginLinkText;

string returnUrl = "/";

if (string.IsNullOrEmpty(
this.Context.Request.QueryString["ReturnUrl"]) == false)
{
returnUrl = this.Context.Request.QueryString["ReturnUrl"];
}

windowsLoginLink.NavigateUrl =
"/_windows/default.aspx?ReturnUrl="
+ returnUrl;

this.Controls.Add(windowsLoginLink);
}

void LoginForm_LoginError(
object sender,
EventArgs e)
{
string membershipProviderName = null;
string roleProviderName = null;

GetClaimsProviderNames(
out membershipProviderName,
out roleProviderName);

MembershipUser user =
Membership.Providers[membershipProviderName].GetUser(
this.loginForm.UserName, false);

if (user == null)
{
return;
}

if (user.IsLockedOut == true)
{
loginForm.FailureText = "Your account has been locked out"
+ " because of too many invalid login attempts. Please"
+ " contact a site administrator to have your account"
+ " unlocked.";
}
else if (user.IsApproved == false)
{
loginForm.FailureText = "Your account has not yet been"
+ " approved. You cannot login until a site"
+ " administrator approves your account.";
}
}

private void DisableLoginForm()
{
this.EnsureChildControls();

this.loginForm.Enabled = false;

DisableLoginValidator(this.loginForm, "UserNameRequired");
DisableLoginValidator(this.loginForm, "PasswordRequired");

windowsLoginLink.Enabled = false;
}

private static void DisableLoginValidator(
Login loginControl,
string validatorID)
{
Debug.Assert(loginControl != null);
Debug.Assert(string.IsNullOrEmpty(validatorID) == false);

RequiredFieldValidator validator = loginControl.FindControl(
validatorID) as RequiredFieldValidator;

if (validator != null)
{
validator.Enabled = false;
}
}

private static void GetClaimsProviderNames(
out string membershipProviderName,
out string roleProviderName)
{
SPIisSettings iisSettings = IisSettings;

SPFormsAuthenticationProvider authProvider =
iisSettings.FormsClaimsAuthenticationProvider;

membershipProviderName = authProvider.MembershipProvider;
roleProviderName = authProvider.RoleProvider;
}

[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Microsoft.Naming",
"CA1726:UsePreferredTerms",
MessageId = "Login")]
private void LoginForm_Authenticate(
object sender,
AuthenticateEventArgs e)
{
if (e == null)
{
throw new ArgumentNullException("e");
}

SPLogger.Log(
LogCategory.WebParts,
TraceSeverity.Verbose,
"Authenticating forms user ({0}) on site ({1})...",
this.loginForm.UserName,
SPContext.Current.Web.Url);

e.Authenticated = SPClaimsUtility.AuthenticateFormsUser(
new Uri(SPContext.Current.Web.Url),
this.loginForm.UserName,
this.loginForm.Password);

if (e.Authenticated == false)
{
SPLogger.Log(
LogCategory.WebParts,
TraceSeverity.Verbose,
"Failed to authenticate forms user ({0}) on site ({1}).",
this.loginForm.UserName,
SPContext.Current.Web.Url);

return;
}

string membershipProviderName = null;
string roleProviderName = null;

GetClaimsProviderNames(
out membershipProviderName,
out roleProviderName);

SPLogger.Log(
LogCategory.WebParts,
TraceSeverity.Verbose,
"Getting security token for user ({0})...",
this.loginForm.UserName);

SecurityToken token =
SPSecurityContext.SecurityTokenForFormsAuthentication(
new Uri(SPContext.Current.Web.Url),
membershipProviderName,
roleProviderName,
this.loginForm.UserName,
this.loginForm.Password);

SPLogger.Log(
LogCategory.WebParts,
TraceSeverity.Verbose,
"Setting principal claim and writing session token for user"
+ " ({0})...",
this.loginForm.UserName);

SPFederationAuthenticationModule.Current.SetPrincipalAndWriteSessionToken(
token);

SPLogger.LogEvent(
LogCategory.WebParts,
EventSeverity.Verbose,
"Successfully authenticated forms user ({0}) on site ({1}).",
this.loginForm.UserName,
SPContext.Current.Web.Url);

RedirectToSuccessUrl();
}

///
/// Raises the event.
///

///
/// This method is overridden in order to disable the validators when
/// editing the page containing the Web Part. Note that the validators
/// must be disabled before the PreRender phase of the page life cycle
/// in order for this to work.
///

/// An object
/// that contains the event data.
protected override void OnLoad(
EventArgs e)
{
base.OnLoad(e);

EnsureChildControls();

SPControlMode formMode = SPContext.Current.FormContext.FormMode;

if (formMode == SPControlMode.Edit
|| formMode == SPControlMode.New)
{
DisableLoginForm();
}
else if (this.Page.User.Identity.IsAuthenticated == true)
{
DisableLoginForm();
}
}

///
/// Raises the event.
///

///
/// This method is overridden in order to hide the Web Part for
/// authenticated users.
///

/// An object
/// that contains the event data.
protected override void OnPreRender(
EventArgs e)
{
base.OnPreRender(e);

SPControlMode formMode = SPContext.Current.FormContext.FormMode;

if (formMode == SPControlMode.Edit
|| formMode == SPControlMode.New)
{
// Always show the Web Part when editing a page
}
else
{
if (this.Page.User.Identity.IsAuthenticated == true)
{
// Hide the login form for authenticated users

// HACK: Attempting to set the Visible property on a Web
// Part can result in an InvalidOperationException:
//
// "The Visible property cannot be set on Web Part
// 'LoginForm'. It can only be set on a standalone Web
// Part."
//
//this.Visible = false;
this.Controls.Clear();
}
}
}

///
/// The following method was originally snarfed from
/// Microsoft.SharePoint.IdentityModel.Pages.IdentityModelSignInPageBase.
///

private void RedirectToSuccessUrl()
{
string uriString = null;
if (
this.Context == null
|| this.Context.Request == null
|| this.Context.Request.QueryString == null)
{
uriString = null;
}
else if (
string.IsNullOrEmpty(
this.Context.Request.QueryString["loginasanotheruser"]) == false
&& string.Equals(
this.Context.Request.QueryString["loginasanotheruser"],
"true",
StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrEmpty(
this.Context.Request.QueryString["Source"]) == false)
{
uriString = this.Context.Request.QueryString["Source"];
}
else if (string.IsNullOrEmpty(
this.Context.Request.QueryString["ReturnUrl"]) == false)
{
uriString = this.Context.Request.QueryString["ReturnUrl"];
}

if (uriString == null)
{
uriString = "/";
}
else if (uriString.StartsWith(
"/_layouts/Authenticate.aspx",
StringComparison.OrdinalIgnoreCase) == true)
{
SPLogger.Log(
LogCategory.WebParts,
TraceSeverity.Medium,
"The user clicked the \"Sign In\" link on the custom"
+ " sign-in page (instead of immediately entering"
+ " credentials). Redirecting to '/' in order to"
+ " avoid \"Access Denied\" error.",
null);

uriString = "/";
}

SPLogger.Log(
LogCategory.WebParts,
TraceSeverity.Verbose,
"Redirecting authenticated user to '{0}'...",
uriString);

SPRedirectFlags trusted = SPRedirectFlags.Default;
if (((SPControl.GetContextWeb(this.Context) == null)
&& Uri.IsWellFormedUriString(uriString, UriKind.Absolute))
&& (SPWebApplication.Lookup(new Uri(uriString)) != null))
{
trusted = SPRedirectFlags.Trusted;
}

SPUtility.Redirect(uriString, trusted, this.Context);
}
}
}