Tutorial: Mvc application using Azure acs and forms authentication Part 2


This is the second post in a series of posts on converting an asp.net mvc project to use both forms authentication and Azure appFabric Access Control Service (ACS) authentication. The first post focused on creating the project, configuring the site for acs and forms authentication and setting up the database. This post will focus on the association process and the hybrid form where users can choose between forms authentication and ACS authentication.

Important code from ACS Mvc3 sample

Next you will need some code from the MVC3CustomSignInPage sample. Download the source from acs.codeplex.com (please note that these samples are under the Apache 2 license). We will be making changes to some of these files but I thought it would be good for people to know their origin. Extract the package and browse to the Websites\MVC3CustomSignInPage\MVC3CustomSignInPage directory and do the following.

  • Copy the Hrd folder to your project
  • Copy the Util folder to your project
  • Copy Models\HrdIdentityProvider.cs to the Models folder in your project
  • Open Controllers\AccountController.cs and copy the GetUrlFromContext method to your AccountController.cs
  • Copy Views\Account\_IdentityProvidersWithServerSideCode.cshtml to Views\Account in you project.
  • Copy Content\HrdPage.css to Content in your project

Now we should have all the code we need to complete the project.

Hybrid logon page

You must set the request validation mode in the web.config else you will get an error upon returning from ACS. Open web.config and find the following:

<authentication mode="Forms"><forms loginUrl="~/Account/LogOn" timeout="2880" /></authentication>

Below insert the following

<httpRuntime requestValidationMode="2.0" />

Add a section to the layout for any custom styles we will render in our views. And modify our menu to include a link to a list of associated identities. Open Views\Shared\_Layout.cshtml add replace the code with the following:

<!DOCTYPE html>
<html>
<head>
    <title>@ViewBag.Title</title>
    <link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
    @RenderSection("Styles",false)
    <script src="@Url.Content("~/Scripts/jquery-1.5.1.min.js")" type="text/javascript"></script>
</head>
<body>
    <div class="page">
        <div id="header">
            <div id="title">
                <h1>My MVC Application</h1>
            </div>
            <div id="logindisplay">
                @Html.Partial("_LogOnPartial")
            </div>
            <div id="menucontainer">
                <ul id="menu">
                    <li>@Html.ActionLink("Home", "Index", "Home")</li>
                    <li>@Html.ActionLink("Identities", "Identities", "Account")</li>
                </ul>
            </div>
        </div>
        <div id="main">
            @RenderBody()
        </div>
        <div id="footer">
        </div>
    </div>
</body>
</html>

Open Views\Account\LogOn.cshtml and replace the code with the following:

@using ASPNETSimpleMVC3.Models;
@model MVC3MixedAuthenticationSample.Models.LogOnModel

@{
    ViewBag.Title = "Log On";
}

@section Styles {
    <link href="@Url.Content("~/Content/HrdPage.css")" rel="stylesheet" type="text/css" />
    <style type="text/css">
        #FormsAuth 
        {
            width:400px;
            margin:0;
            padding:0;
            float:left;
        }
    </style>
}



<h2>Log On</h2>
<p>
    Please enter your user name and password. @Html.ActionLink("Register", "Register") if you don't have an account.
</p>

@*Needed for Visual Studio Intellisense*@
@if (false)
{
    <script src="/Scripts/jquery-1.5.1-vsdoc.js" type="text/javascript"></script>
}

<script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>

<script type="text/javascript">
    $(function () {
        $('#AssociateCancel').click(CancelAssociation);
    });

    function CancelAssociation() {
        $.post('@Url.Action("CancelAssociation")',function(data){
           if(data) {
          
            $('#identity-provider-content').show();
            $('#AssociationMessage').hide();
           } 
           else{
            alert("Unexpected error. Try again later");
           }
        });
        
    }
    
</script>


@Html.ValidationSummary(true, "Login was unsuccessful. Please correct the errors and try again.")
@if (ViewData["HasClaim"]!=null)
{
<div id="AssociationMessage">
    <div id="AssociateMessageText">Please login to associate your account</div>
    <div id="AssociateMessageActions"><button id="AssociateCancel">Cancel</button></div> 
    <div class="clear"></div>
</div>
}
@using (Html.BeginForm()) {
    <div id="FormsAuth">
        <fieldset>
            <legend>Account Information</legend>

            <div class="editor-label">
                @Html.LabelFor(m => m.UserName)
            </div>
            <div class="editor-field">
                @Html.TextBoxFor(m => m.UserName)
                @Html.ValidationMessageFor(m => m.UserName)
            </div>

            <div class="editor-label">
                @Html.LabelFor(m => m.Password)
            </div>
            <div class="editor-field">
                @Html.PasswordFor(m => m.Password)
                @Html.ValidationMessageFor(m => m.Password)
            </div>

            <div class="editor-label">
                @Html.CheckBoxFor(m => m.RememberMe)
                @Html.LabelFor(m => m.RememberMe)
            </div>

            <p>
                <input type="submit" value="Log On" />
            </p>
        </fieldset>

    </div>
    
}
@Html.Partial("_IdentityProvidersWithServerSideCode")

Open Views\Account\_IdentityProvidersWithServerSideCode.cshtml and replace the code with the following:

@using ASPNETSimpleMVC3.Models;
@{IEnumerable<HrdIdentityProvider> Providers = null;}
@{
    if (!string.IsNullOrEmpty(ViewData["Providers"].ToString()) && (ViewData["Providers"] as IEnumerable<HrdIdentityProvider>).Count() > 0)
    { 
        Providers = ViewData["Providers"] as IEnumerable<HrdIdentityProvider>;
    }
}
  

@*Needed for Visual Studio Intellisense*@
@if (false)
{
    <script src="/Scripts/jquery-1.5.1-vsdoc.js" type="text/javascript"></script>
}

@if (Providers != null)
{
<div id="identity-provider-content"  class="@(ViewData["HasClaim"] != null ? "provider-content-hidden" : "")">
    
    
    <h2>
        Or sign in using
    </h2>
    <div id="identity-providers">
        @foreach (HrdIdentityProvider identityProvider in Providers)
        {
            <div  class="identity-provider"><button onclick="window.location='@Html.Raw(identityProvider.LoginUrl)'">@identityProvider.Name</button></div>
        }
    </div>
</div>

<div class="clear"></div>
}

Open Content/HrdPage.css and change the code to the following:


#identity-provider-content
{
    width:250px;
    text-align:center;   
    float:left;
   
}

#identity-providers
{
    text-align:center;
    width:100%;
}

div.identity-provider
{

    text-align:center;
    margin: 10px 0px 10px 0px;
}

div.identity-provider button
{
    padding:10px;
    width:150px;
    vertical-align:middle;
}

#AssociationMessage
{
    border: 1px solid #4F8A10;
    background-color: #DFF2BF;
    color:#4F8A10;
    padding:5px;
    width:400px;
}

.provider-content-hidden
{
    display:none;
}

#AssociateMessageActions
{
    float:right;
}
#AssociateMessageText
{
    float:left;
}

Open Controllers\AccountController.cs and make the following changes:

Add an action called SignIn

        /// <summary>
        /// Action used to sign user in from ACS
        /// </summary>
        /// <param name="forms"></param>
        /// <returns></returns>
        [HttpPost]
        [ValidateInput(false)]
        public ActionResult SignIn(FormCollection forms)
        {
            //Extract claims
            var principal = HttpContext.User;
            var claim = new IdentityClaim(principal.Identity as IClaimsIdentity);

            //Delete session cookie so the module cannot reset principal
            SessionAuthenticationModule sam = FederatedAuthentication.SessionAuthenticationModule;
            sam.DeleteSessionTokenCookie();

            if (claim.HasIdentity)
            {
                var db = new IdentityRepository();

                var identity = db.FindByProvderAndValue(claim.IdentityProvider, claim.IdentityValue);
                string returnUrl = GetUrlFromContext(forms) ?? Url.Action("Index", "Home", null);
                if (identity != null)
                {
                    var user = Membership.GetUser(identity.UserId);

                    if (user != null)
                    {
                        FormsAuthentication.SetAuthCookie(user.UserName, false);
                        return Redirect(returnUrl);
                    }

                }
                else
                {
                    //Save identity values for processing in the association page
                    claim.SaveToSession();

                    return RedirectToAction("LogOn", new { ReturnUrl = returnUrl });

                }

            }

            return RedirectToAction("LogOn", "Account");
        }

Still in Controllers\AccountController.cs add the following actions which will facilitate the association process.

         /// <summary>
        /// Remove pending association from session
        /// </summary>
        /// <returns></returns>
        [Authorize]
        public JsonResult CancelAssociation()
        {
            IdentityClaim.ClearSession();

            return Json(true);
        }

        /// <summary>
        /// Allows a user to remove an identity association
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        [Authorize]
        public JsonResult RemoveAssociation(int id)
        {
            var db = new IdentityRepository();
            var user = Membership.GetUser();
            db.DeleteById(id,user.ProviderUserKey.ToString());

            return Json(true);
        }

        /// <summary>
        /// Allows users to manage their associated identites
        /// </summary>
        /// <returns></returns>
        [Authorize]
        public ActionResult Identities()
        {
            var db = new IdentityRepository();
            var user = Membership.GetUser();

            var identities = db.FindByUserId(user.ProviderUserKey.ToString());

            return View(identities);
        }

Add a new view to Views\Account called Identities.cshtml and insert the following code:

@model IEnumerable<MVC3MixedAuthenticationSample.UserIdentity>
@using MVC3MixedAuthenticationSample.Models

@{
    ViewBag.Title = "Identities";
}

@*Needed for Visual Studio Intellisense*@
@if (false)
{
    <script src="/Scripts/jquery-1.5.1-vsdoc.js" type="text/javascript"></script>
}

<script type="text/javascript">
    function RemoveAssociation(element, id) {
        $.post('@Url.Action("RemoveAssociation")', { id: id }, function (data) {
            if (data) {
                $(element).parent().slideUp().Remove();
            }
            else {
                alert("There was an unexpected error. Try again later");
            }
        });
    }
</script>
<h2>Identities</h2>

<div id="ProviderList">
@foreach (var item in Model) {
  <div>
    @IdentityClaim.ProviderNiceName( item.IdentityProvider )  <button onclick="RemoveAssociation(this,@item.IdentityID)">Remove</button>
  </div>
}
</div>



Replace the parameter-less LogOn() action with the following code:

        public ActionResult LogOn()
        {
            HrdClient hrdClient = new HrdClient();

            WSFederationAuthenticationModule fam = FederatedAuthentication.WSFederationAuthenticationModule;
            HrdRequest request = new HrdRequest(fam.Issuer, fam.Realm, context: Request.QueryString["ReturnUrl"]);

            IEnumerable<HrdIdentityProvider> hrdIdentityProviders = hrdClient.GetHrdResponse(request);

            var claim = new IdentityClaim();
            if (claim.HasIdentity)
            {
                ViewData["HasClaim"] = true;
            }

            ViewData["Providers"] = hrdIdentityProviders;
            return View();
        }

Replace the LogOn(LogOnModel model, string returnUrl) action with the following code:

        [HttpPost]
        public ActionResult LogOn(LogOnModel model, string returnUrl)
        {
            var claim = new IdentityClaim();

            if (ModelState.IsValid)
            {
                if (Membership.ValidateUser(model.UserName, model.Password))
                {
                   

                    if (claim.HasIdentity)
                    {
                        var db = new IdentityRepository();
                        var user = Membership.GetUser(model.UserName);
                        db.MapIdentity(user.ProviderUserKey.ToString(), claim.IdentityProvider, claim.IdentityValue);
                        IdentityClaim.ClearSession();
                    }

                    FormsAuthentication.SetAuthCookie(model.UserName, model.RememberMe);
                    if (Url.IsLocalUrl(returnUrl) && returnUrl.Length > 1 && returnUrl.StartsWith("/")
                        && !returnUrl.StartsWith("//") && !returnUrl.StartsWith("/\\"))
                    {
                        return Redirect(returnUrl);
                    }
                    else
                    {
                        return RedirectToAction("Index", "Home");
                    }
                }
                else
                {
                    ModelState.AddModelError("", "The user name or password provided is incorrect.");
                }
            }

            HrdClient hrdClient = new HrdClient();

            WSFederationAuthenticationModule fam = FederatedAuthentication.WSFederationAuthenticationModule;
            HrdRequest request = new HrdRequest(fam.Issuer, fam.Realm, context: Request.QueryString["ReturnUrl"]);

            IEnumerable<HrdIdentityProvider> hrdIdentityProviders = hrdClient.GetHrdResponse(request);

            if (claim.HasIdentity)
            {
                ViewData["HasClaim"] = true;
            }

            ViewData["Providers"] = hrdIdentityProviders;
            // If we got this far, something failed, redisplay form
            return View(model);
        }

Next open controllers\HomeController.cs and add the authorize attribute so we can test our new feature.

    [Authorize]
    public class HomeController : Controller
    {
      ...
    }

Add a new class called IdentityRepository.cs to the models folder and insert the following code:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace MVC3MixedAuthenticationSample.Models
{
     
    public class IdentityRepository 
    {
        private ASPNETDBEntities _context;

        public IdentityRepository()
        {
            _context = new ASPNETDBEntities();
        }

        public int MapIdentity(string userid, string identytProvider, string identityValue)
        {
            var mapping = new UserIdentity();
            mapping.UserId = new Guid(userid);
            mapping.IdentityProvider = identytProvider;
            mapping.IdentityValue = identityValue;
            _context.AddToUserIdentities(mapping);
            _context.SaveChanges();

            return mapping.IdentityID;
        }

        public IQueryable<UserIdentity> FindByUserId(string userId)
        {
           return _context.UserIdentities.Where(i => i.UserId == new Guid(userId));
        }

        public UserIdentity FindByProvderAndValue(string IdentityProvider, string identityValue)
        {
            return _context.UserIdentities.FirstOrDefault(i => i.IdentityProvider == IdentityProvider && i.IdentityValue == identityValue);
        }

        public void DeleteById(int id, string userId)
        {
            var identity = _context.UserIdentities.FirstOrDefault(i => i.IdentityID == id && i.UserId == new Guid(userId));
            if (identity != null)
            {
                _context.DeleteObject(identity);
            }
            _context.SaveChanges();
        }

       
    }
}

At this point the solution should look like this:

Next run the project and verify that the LogOn page looks like the following:

At this point you must verify that ACS is setup properly with the project url as a valid relying party (Please see the original ACS MVC3 sample tutorial if you have not been able to set this up) and has http://%5Bprojecturl%5D/Account/SignIn as the return url. If this is not configured properly you will get a bad request exception when attempting to load the Login page.

If you have not already selected the providers you want users to authenticate against you should do so at this point. Again, there are many tutorials out there which cover this process. Facebook is the only provider which requires extra steps to be added as a provider. You can read about it here.

You should now be able to click on one of the provider buttons on the LogOn page where you will sign in using any of the providers setup in your ACS account. After signing in you will return to the following screen on your site which prompts you to login to complete the association process:

After you login you should see your new identity listed on the Identites page.

Finally, you should be able to logout then click the same provider button you clicked before to get logged in without having to enter your username and password for the site.

The sample project used in this tutorial is available on codeplex for anyone who wants it. Have fun with ACS and Asp.net memberships.

To use the sample project you will need to configure the Windows Identity framework module for your own ACS namespace.

Advertisements