Thursday, December 3, 2015

How to login to EPiServer with Facebook

Wouldn't it be great to be able to login with another Identity Provider, e.g. Facebook, in your Episerver solution? Which could giving you the ability to extract Facebook information from you users. In the picture below, you can see your Facebook picture and username displayed in the header on the standard Episerver Alloy demo after login.





Prerequisite 
First, you will need to create a login based on the ASP.NET Identity instead of Membership provider. You find how to do that here. This example should work with EPiServer version 8 from 14 November 2014 and up. However, I recommend updating to the latest version.

Create a connection with Facebook
You will need to setup an App on Facebook to be able to create a connection. Go to https://developers.facebook.com/apps to do that. Here is a good link to how to setup the connection step-by-step, http://www.oauthforaspnet.com/providers/facebook/

Tips! Remember that after you have create an App, you can add a platform. In this case, you would add a “website”. Since this is a test case, you can add a localhost address here. Notice the App ID and App Secret, which you will need later on.


Necessary nuget packages
You will need at least one nuget package to be able to create this connection in your project.

  • Install-Package Microsoft.Owin.Security.Facebook 

Other nuget packages that could come handy later on:

  • Install-Package Microsoft.Owin.Security.Google 
  • Install-Package Microsoft.Owin.Security.MicrosoftAccount 
  • Install-Package Microsoft.Owin.Security.Twitter 

If you want to extract more information from Facebook, you will need to install this one too.

  • Install-Package Facebook


Startup.cs
If you only want the plain login you don’t need specify anything more than the following lines in the Startup.cs.

   var facebookOptions = new FacebookAuthenticationOptions();
   facebookOptions.AppId = "xxxxxxxxxxxxxxxx";
   facebookOptions.AppSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
   app.UseFacebookAuthentication(facebookOptions);

However, if you want to extract more information through scopes and token it could look something like this:

    var facebookOptions = new FacebookAuthenticationOptions();
    facebookOptions.Scope.Add("email");
    facebookOptions.AppId = "xxxxxxxxxxxxxxxx";
    facebookOptions.AppSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
    facebookOptions.Provider = new FacebookAuthenticationProvider()
    {
        OnAuthenticated = async context =>
        {
            //Get the access token from FB and store it in the database and use FacebookC# SDK to get more information about the user
            context.Identity.AddClaim(new System.Security.Claims.Claim("FacebookAccessToken", context.AccessToken));
        }
    };
    facebookOptions.SignInAsAuthenticationType = DefaultAuthenticationTypes.ExternalCookie;
    app.UseFacebookAuthentication(facebookOptions);


Extend the login screen
Since I already have styled the login screen in Episerver style, I have just extended it with a partial view to be able to login with Facebook.


Add these views to the solution.

Account/Login.cshtml
Extend login with partial view to login with facebook and other identity providers.


@using System.Web.Optimization
@model TestSite.Models.Account.LoginViewModel
 
@{
    ViewBag.Title = "Log in";
    Layout = null;
}
 
<meta http-equiv="X-UA-Compatible" content="IE=EmulateIE7" />
<link type="text/css" rel="stylesheet" href="/util/styles/login.css" />
<meta name="robots" content="noindex,nofollow" />
 
<link href="../App_Themes/Default/Styles/system.css" type="text/css" rel="stylesheet" />
<link href="../App_Themes/Default/Styles/ToolButton.css" type="text/css" rel="stylesheet" />
 
 
<body class="epi-loginBody">
 
    <div class="epi-loginContainer">
        <div id="FullRegion_LoginControl">
 
            <div class="epi-loginTop">
            </div>
            <div class="epi-loginMiddle">
                <div class="epi-loginContent">
                    <div class="epi-loginLogo">@ViewBag.Title.</div>
                    <div class="epi-loginForm">
                        <h1><span style="color: Red;"></span></h1>
 
                        <div id="FullRegion_LoginControl_ValidationSummary1" style="display: none;">
 
                        </div>
                        <div class="epi-credentialsContainer">
 
 
                            @using (Html.BeginForm("Login", "Account", new { ReturnUrl = ViewBag.ReturnUrl }, FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
                            {
                                @Html.AntiForgeryToken()
 
                                @Html.ValidationSummary(true)
                                <div class="epi-float-left">
                                    @Html.LabelFor(m => m.UserName, new { @class = "episize80" })
                                    <br />
                                    <div class="">
                                        @Html.TextBoxFor(m => m.UserName, new { @class = "epi-inputText" })
                                        @Html.ValidationMessageFor(m => m.UserName)
                                    </div>
                                    <span id="FullRegion_LoginControl_RequiredFieldValidator1" style="display: none;">­</span>
                                </div>
 
                                <div class="epi-float-left">
                                    @Html.LabelFor(m => m.Password, new { @class = "episize80" })
                                    <br />
                                    <div class="">
                                        @Html.PasswordFor(m => m.Password, new { @class = "epi-inputText" })
                                        @Html.ValidationMessageFor(m => m.Password)
                                    </div>
                                    <span id="FullRegion_LoginControl_RequiredFieldValidator2" style="display: none;">­</span>
                                </div>
 
                                <div class="epi-button-container epi-float-left">
                                    <span class="epi-button">
                                        <span class="epi-button-child">
                                            <input type="submit" value="Log in" class="epi-button-child-item" />
                                        </span>
                                    </span>
                                </div>
                                <div class="epi-checkbox-container">
                                    <span class="epi-checkbox">
                                        @Html.CheckBoxFor(m => m.RememberMe)
                                        @Html.LabelFor(m => m.RememberMe)
                                    </span>
                                </div>
                            }
 
                        </div>
                    </div>
                </div>
                <div class="epi-loginBottom">
                </div>
            </div>
            <div class="epi-credentialsContainer" style="margin-left: 30px; margin-bottom: 10px;">
                @Html.Partial("_ExternalLoginsListPartial", new TestSite.Models.Account.ExternalLoginViewModel() { Action = "ExternalLogin", ReturnUrl = ViewBag.ReturnUrl })
            </div>
        </div>
 
    </div>
</body>
@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

Account/_ExternalLoginsListPartial.cshtml
List out the identity providers that has been configured.


@using Microsoft.Owin.Security
@model TestSite.Models.Account.ExternalLoginViewModel
 
<b>Log on using these Providers.</b><br />
 
@{
    var loginProviders = Context.GetOwinContext().Authentication.GetExternalAuthenticationTypes();
    if (loginProviders.Count() == 0)
    {
 
<p>There are no external authentication services configured. </p>
 
    }
    else
    {
        string action = Model.Action;
        string returnUrl = Model.ReturnUrl;
        using (Html.BeginForm(action, "Account", new { ReturnUrl = returnUrl }))
        {
            @Html.AntiForgeryToken()
            <div id="socialLoginList">
                <p>
                    @foreach (AuthenticationDescription p in loginProviders)
                    {
                    <button type="submit" class="btn btn-default" id="@p.AuthenticationType" name="provider" value="@p.AuthenticationType" title="Log in using your @p.Caption account">@p.AuthenticationType</button>
                    }
                </p>
            </div>
        }
    }
}

Account/ExternalLoginConfirmation.cshtml
Confirmation view after the user have logged in to facebook or other identity providers.


@using System.Web.Optimization
@model TestSite.Models.Account.ExternalLoginConfirmationViewModel
@{
    ViewBag.Title = "Register";
    Layout = null;
}
 
<meta http-equiv="X-UA-Compatible" content="IE=EmulateIE7" />
<link type="text/css" rel="stylesheet" href="/util/styles/login.css" />
<meta name="robots" content="noindex,nofollow" />
 
<link href="../App_Themes/Default/Styles/system.css" type="text/css" rel="stylesheet" />
<link href="../App_Themes/Default/Styles/ToolButton.css" type="text/css" rel="stylesheet" />
 
 
<body class="epi-loginBody">
 
    <div class="epi-loginContainer">
        <div id="FullRegion_LoginControl">
 
            <div class="epi-loginTop">
            </div>
            <div class="epi-loginMiddle">
                <div class="epi-loginContent">
                    <div class="epi-loginLogo">@ViewBag.Title.</div>
                    <h3>Associate your @ViewBag.LoginProvider account.</h3>
                    <div class="epi-loginForm">
                        <h1><span style="color: Red;"></span></h1>
 
                        <div id="FullRegion_LoginControl_ValidationSummary1" style="display: none;">
 
                        </div>
                        <div class="epi-credentialsContainer">
 
                            @using (Html.BeginForm("ExternalLoginConfirmation", "Account", new { ReturnUrl = ViewBag.ReturnUrl }, FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
                            {
                                @Html.AntiForgeryToken()
 
                                <h4>Association Form</h4>
                                <hr />
                                @Html.ValidationSummary(true)
                                <p class="text-info">
                                    You've successfully authenticated with <strong>@ViewBag.LoginProvider</strong>.
                                    Please enter a user name for this site below and click the Register button to finish
                                    logging in.
                                </p>
                                <br />
                                <div class="form-group">
                                    @Html.LabelFor(m => m.UserName, new { @class = "col-md-2 control-label" })
                                    <div class="col-md-10">
                                        @Html.TextBoxFor(m => m.UserName, new { @class = "form-control" })
                                        @Html.ValidationMessageFor(m => m.UserName)
                                    </div>
                                </div>
                                <div class="form-group">
                                    <div class="col-md-offset-2 col-md-10">
                                        <input type="submit" class="btn btn-default" value="Register" />
                                    </div>
                                </div>
                            }
 
 
                        </div>
                    </div>
                </div>
                <div class="epi-loginBottom">
                </div>
            </div>
        </div>
 
    </div>
    @section Scripts {
        @Scripts.Render("~/bundles/jqueryval")
    }

Account/ExternalLoginFailure.cshtml
This view can be extended more with layout.
@{
    ViewBag.Title = "Login Failure";
    Layout = null;
}
 
<h2>@ViewBag.Title.</h2>
<h3 class="text-error">Unsuccessful login with service.</h3>

Add this line in the Header.cshtml
Add this code snippet into the header view to render out picture and name of user.

      @{Html.RenderAction("_UserPartial", "Account");}

Account/_UserPartial.cshtml
Display the user picture from facebook and name.

<div>
    @if (!string.IsNullOrEmpty(ViewBag.ProviderKey))
    {
        <img src=@Url.Content("https://graph.facebook.com/" + ViewBag.ProviderKey + "/picture?type=small") alt="@ViewBag.UserName" />
    }
</div>
<div>
    <h3>@ViewBag.UserName</h3>
</div>

Add these code lines in the Models/Account/AccountViewModels.cs
    public class ExternalLoginViewModel
    {
        public string Action { get; set; }
        public string ReturnUrl { get; set; }
    }

Add these code lines in the Controllers/AccountController.cs
You don't need to add the StoreFacebookAuthToken if you aren't going to use facebook data in your solution. It also contain code that would add the facebook user to a Episerver role.
       //
        // POST: /Account/ExternalLogin
        [HttpPost]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public ActionResult ExternalLogin(string provider, string returnUrl)
        {
            // Request a redirect to the external login provider
            return new ChallengeResult(provider,
                Url.Action("ExternalLoginCallback", "Account", new { ReturnUrl = returnUrl }));
        }
 
        //
        // GET: /Account/ExternalLoginCallback
        [AllowAnonymous]
        public async Task<ActionResult> ExternalLoginCallback(string returnUrl)
        {
            var loginInfo = await AuthenticationManager.GetExternalLoginInfoAsync();
            if (loginInfo == null)
            {
                return RedirectToAction("Login");
            }
 
            // Sign in the user with this external login provider if the user already has a login
            var user = await UserManager.FindAsync(loginInfo.Login);
            if (user != null)
            {
                //Save the FacebookToken in the database if not already there
                await StoreFacebookAuthToken(user);
                await SignInAsync(user, false);
                return RedirectToLocal(returnUrl);
            }
            // If the user does not have an account, then prompt the user to create an account
            ViewBag.ReturnUrl = returnUrl;
            ViewBag.LoginProvider = loginInfo.Login.LoginProvider;
            return View("ExternalLoginConfirmation",
                new ExternalLoginConfirmationViewModel { UserName = loginInfo.DefaultUserName });
        }
 
        private async Task StoreFacebookAuthToken(ApplicationUser user)
        {
            var claimsIdentity = await AuthenticationManager.GetExternalIdentityAsync(DefaultAuthenticationTypes.ExternalCookie);
            if (claimsIdentity != null)
            {
                // Retrieve the existing claims for the user and add the FacebookAccessTokenClaim
                var currentClaims = await UserManager.GetClaimsAsync(user.Id);
                var facebookAccessToken = claimsIdentity.FindAll("FacebookAccessToken").First();
                if (!currentClaims.Any())
                {
                    await UserManager.AddClaimAsync(user.Id, facebookAccessToken);
                }
                //NB! These lines will add the user to the facebookgroup (role)
                var assingedRoles = await UserManager.GetRolesAsync(user.Id);
                if (!assingedRoles.Contains("WebAdmins") || !assingedRoles.Contains("WebEditors") || !assingedRoles.Contains("FacebookGroup"))
                {
                    await UserManager.AddToRoleAsync(user.Id, "FacebookGroup");
                }
            }
        }
 
        //
        // POST: /Account/ExternalLoginConfirmation
        [HttpPost]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> ExternalLoginConfirmation(ExternalLoginConfirmationViewModel model,
            string returnUrl)
        {
            if (User.Identity.IsAuthenticated)
            {
                return View(model); //ERROR?
            }
 
            if (ModelState.IsValid)
            {
                // Get the information about the user from the external login provider
                var info = await AuthenticationManager.GetExternalLoginInfoAsync();
                if (info == null)
                {
                    return View("ExternalLoginFailure");
                }
                var user = new ApplicationUser { UserName = model.UserName };
                var result = await UserManager.CreateAsync(user);
                if (result.Succeeded)
                {
                    result = await UserManager.AddLoginAsync(user.Id, info.Login);
                    if (result.Succeeded)
                    {
                        await StoreFacebookAuthToken(user);
                        await SignInAsync(user, false);
                        return RedirectToLocal(returnUrl);
                    }
                }
                AddErrors(result);
            }
 
            ViewBag.ReturnUrl = returnUrl;
            return View(model);
        }
 
        [AllowAnonymous]
        public ActionResult _UserPartial()
        {
            var userId = User.Identity.GetUserId();
            if (!string.IsNullOrEmpty(userId))
            {
                var claimsforUser = UserManager.GetClaims(userId);
                Claim firstOrDefault = claimsforUser.FirstOrDefault(x => x.Type == "FacebookAccessToken");
                if (firstOrDefault != null)
                {
                    var accessToken = firstOrDefault.Value;
                    var fb = new FacebookClient(accessToken);
                    dynamic myInfo = fb.Get("me?fields=first_name,last_name,id");
                    ViewBag.ProviderKey = myInfo.id;
                    ViewBag.UserName = myInfo.first_name + " " + myInfo.last_name;
                }
                else
                {
                    var user = await UserManager.FindByIdAsync(userId);
                    ViewBag.UserName = user.UserName;
                }
            }
            return PartialView();
        }

       // Used for XSRF protection when adding external logins
        private const string XsrfKey = "XsrfId";
 
        private class ChallengeResult : HttpUnauthorizedResult
        {
            public ChallengeResult(string provider, string redirectUri) : this(provider, redirectUri, null)
            {
            }
 
            public ChallengeResult(string provider, string redirectUri, string userId)
            {
                LoginProvider = provider;
                RedirectUri = redirectUri;
                UserId = userId;
            }
 
            public string LoginProvider { get; set; }
            public string RedirectUri { get; set; }
            public string UserId { get; set; }
 
            public override void ExecuteResult(ControllerContext context)
            {
                var properties = new AuthenticationProperties { RedirectUri = RedirectUri };
                if (UserId != null)
                {
                    properties.Dictionary[XsrfKey] = UserId;
                }
                context.HttpContext.GetOwinContext().Authentication.Challenge(properties, LoginProvider);
            }
        }

        private void AddErrors(IdentityResult result)
        {
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError("", error);
            }
        }

Testing the solution
So after all is implemented and compiled you will come to a facebook login screen after you hit the "facebook" button on the episerver login screen.


After you have logged in you get a form where you can register the user in the local storage, but then without the password.



After hitting the registration button you will return to the start screen where the facebook picture and name is displayed.

Database
If you look in the Identity database after you have register, you will notice the password is set to null on the facebook user.



Okey. That's it. Happy coding!



References

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.