Friday, August 14, 2015

How to integrate ASP.NET Identity with EPiServer 8

Introduction

The standard way of authenticate in EPiServer with the membership provider version has been used for more than a decade and has become very limited to how modern authenticate and authorization is done.

I believe that several of us has customers that request new functionality that we cannot provide with the standard EPiServer membership provider. I often see and hear that they want to be able to login with Facebook or other Identity providers. They want to be able to login with cusomter number, phone number og email as the user name.  Could we customize the identity data model with more fields? Could we have a two-factor login process, including sms?

And more..

By using ASP.NET Identity, you will be able to solve these requests. To avoid getting a very large blog post I have divide it into several.

  • First will be how you integrate the ASP.NET Identity in a standard EPiServer Alloy Demo with a login screen.
  • Second, will contain how to create an Admin tool to create and manage users and roles.
  • Third, will be about how you optimize and migrate data from the membership to the Identity model. Yes, by optimize the data model you could improve the performance a lot. I'll also show how you can keep the old passwords after migration.

For those who is not familiar with ASP.NET Identity this could be a good place to start here

In addition, ASP.NET Identity has its fall pits, which Brock has summery up here.

First, we need a demo project. For those of you are not familiar with how to setup an EPiServer CMS Alloy demo with MVC, here is a quick introduction.

Source Code

Demo source code for this solution can be downloaded, which also includes the Admin tool:
https://github.com/saandahl/IdentityDemo


Create an EPiServer site

First, you will need to install the EPiServer extensions in Visual Studio. Which you can find here.

This will give you the ability to create new EPiServer sites. Just open Visual Studio, go to File and select New and Project. Then select Templates, Visual C#, EPiServer, and EPiServer Web Site, and provide a name.


Then select Alloy (MVC) for a site with sample content.


Hit “OK” to finalize the creation of the project. Then press F5 to build and open the website in a browser. Then the site should come up and look like this:


NB! To avoid any pitfalls later on I suggest that you restore the database (.mdf files) from the App_Data folder to an SQL server. ASP.NET Identity will also need its own database, but this will be generated from Entity Framework when you start it up the first time, if you got the correct access rights.

I also recommend setting up a site in IIS and add Network Service account to both SQL and IIS application pool identity.

The only thing you would need to change is the connectionString in the web.config. You will find example on how it could look like further down.

Install necessary Nuget Packages

So far so good. Now we need to install some nuget packages. Open the Tools > Nuget Package Manager > Package Manager Console and run this commands:

PM> Install-Package Microsoft.Owin.Security.Cookies
PM> Install-Package Microsoft.Owin.Host.SystemWeb
PM> Install-Package Microsoft.AspNet.Identity.EntityFramework
PM> Install-Package Microsoft.AspNet.Identity.Owin
PM> Install-Package Microsoft.AspNet.WebApi


This will download the packages and add the necessary references to the project and all the packages references will be added to the packages.config.

The marked files/folders are the only places you will need to add or modify the code to get this up and running.


App_Start > IdentityConfig.cs

This is where you can tighten or loosen up the password strengths. To be able to use small test password I have set it very low.


using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.EntityFramework;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using TestSite.Models.Account;
 
namespace TestSite
{
    // Configure the application user manager used in this application. UserManager is defined in ASP.NET Identity and is used by the application.
    public class ApplicationUserManager : UserManager<ApplicationUser>
    {
        public ApplicationUserManager(ApplicationUserStore store)
            : base(store)
        {
        }
 
        public static ApplicationUserManager Create(IdentityFactoryOptions<ApplicationUserManager> options, IOwinContext context)
        {
            var manager = new ApplicationUserManager(new ApplicationUserStore(context.Get<ApplicationDbContext>()));
            // Configure validation logic for usernames
            manager.UserValidator = new UserValidator<ApplicationUser>(manager)
            {
                AllowOnlyAlphanumericUserNames = false,
                //RequireUniqueEmail = true
            };
 
            // Configure validation logic for passwords
            manager.PasswordValidator = new PasswordValidator
            {
                RequiredLength = 6,
                RequireNonLetterOrDigit = false,
                RequireDigit = false,
                RequireLowercase = false,
                RequireUppercase = false,
            };
 
            // Configure user lockout defaults
            manager.UserLockoutEnabledByDefault = true;
            manager.DefaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(5);
            manager.MaxFailedAccessAttemptsBeforeLockout = 5;
 
            // Register two factor authentication providers. This application uses Phone and Emails as a step of receiving a code for verifying the user
            // You can write your own provider and plug it in here.
            manager.RegisterTwoFactorProvider("Phone Code", new PhoneNumberTokenProvider<ApplicationUser>
            {
                MessageFormat = "Your security code is {0}"
            });
            manager.RegisterTwoFactorProvider("Email Code", new EmailTokenProvider<ApplicationUser>
            {
                Subject = "Security Code",
                BodyFormat = "Your security code is {0}"
            });
 
            var dataProtectionProvider = options.DataProtectionProvider;
            if (dataProtectionProvider != null)
            {
                manager.UserTokenProvider =
                        new DataProtectorTokenProvider<ApplicationUser>(dataProtectionProvider.Create("ASP.NET Identity"));
            }
            return manager;
        }
    }
 
    // Configure the application sign-in manager which is used in this application.
    public class ApplicationSignInManager : SignInManager<ApplicationUser, string>
    {
        public ApplicationSignInManager(ApplicationUserManager userManager, IAuthenticationManager authenticationManager)
            : base(userManager, authenticationManager)
        {
        }
 
        public override Task<ClaimsIdentity> CreateUserIdentityAsync(ApplicationUser user)
        {
            return user.GenerateUserIdentityAsync((ApplicationUserManager)UserManager);
        }
 
        public static ApplicationSignInManager Create(IdentityFactoryOptions<ApplicationSignInManager> options,
            IOwinContext context)
        {
            return new ApplicationSignInManager(context.GetUserManager<ApplicationUserManager>(), context.Authentication);
        }
    }
 
    public class ApplicationUserStore : UserStore<ApplicationUser>
    {
        public ApplicationUserStore(ApplicationDbContext ctx)
            : base(ctx)
        {
        }
    }
 
    public class ApplicationRoleStore : RoleStore<IdentityRole>
    {
        public ApplicationRoleStore(ApplicationDbContext ctx)
            : base(ctx)
        {
        }
    }
 
    public class ApplicationRoleManager : RoleManager<IdentityRole>
    {
        public ApplicationRoleManager(ApplicationRoleStore roleStore)
            : base(roleStore)
        {
        }
    }
}

Controllers > AccountController.cs


using System.Threading.Tasks;
using System.Web;
using System.Web.Mvc;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin.Security;
using TestSite.Models.Account;
 
namespace TestSite.Controllers
{
    [Authorize]
    public class AccountController : Controller
    {
        private ApplicationSignInManager _signInManager;
        private ApplicationUserManager _userManager;
 
        public AccountController()
        {
        }
 
        public AccountController(ApplicationUserManager userManager, ApplicationSignInManager signInManager)
        {
            UserManager = userManager;
            SignInManager = signInManager;
        }
 
        public ApplicationSignInManager SignInManager
        {
            get
            {
                return _signInManager ?? HttpContext.GetOwinContext().Get<ApplicationSignInManager>();
            }
            private set
            {
                _signInManager = value;
            }
        }
 
        public ApplicationUserManager UserManager
        {
            get
            {
                return _userManager ?? HttpContext.GetOwinContext().GetUserManager<ApplicationUserManager>();
            }
            private set
            {
                _userManager = value;
            }
        }
 
        //
        // GET: /Account/Login
        [AllowAnonymous]
        public ActionResult Login(string returnUrl)
        {
            ViewBag.ReturnUrl = returnUrl;
            return View();
        }
 
        //
        // POST: /Account/Login
        [HttpPost]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> Login(LoginViewModel model, string returnUrl)
        {
            if (ModelState.IsValid)
            {
                var user = await UserManager.FindAsync(model.UserName, model.Password);
                if (user != null)
                {
                    await SignInAsync(user, model.RememberMe);
                    return RedirectToLocal(returnUrl);
                }
                ModelState.AddModelError("", "Invalid username or password.");
            }
 
            // If we got this far, something failed, redisplay form
            return View(model);
        }
 
        //
        // POST: /Account/LogOff
        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult LogOff()
        {
            Request.GetOwinContext().Authentication.SignOut();
            return Redirect("/");
        }
   
        protected override void Dispose(bool disposing)
        {
            if (disposing && UserManager != null)
            {
                UserManager.Dispose();
                UserManager = null;
            }
            base.Dispose(disposing);
        }
 
        #region Helpers
 
        private IAuthenticationManager AuthenticationManager
        {
            get { return HttpContext.GetOwinContext().Authentication; }
        }
 
        private async Task SignInAsync(ApplicationUser user, bool isPersistent)
        {
            AuthenticationManager.SignOut(DefaultAuthenticationTypes.ExternalCookie);
            var identity = await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);
            AuthenticationManager.SignIn(new AuthenticationProperties { IsPersistent = isPersistent }, identity);
        }
 
   
        private ActionResult RedirectToLocal(string returnUrl)
        {
            if (Url.IsLocalUrl(returnUrl))
            {
                return Redirect(returnUrl);
            }
            return Redirect("/");
        }
 
        #endregion
 
    }
}

Models > Account > AccountViewModels.cs


using System.ComponentModel.DataAnnotations;
 
namespace TestSite.Models.Account
{
    public class LoginViewModel
    {
        [Required]
        [Display(Name = "User name")]
        public string UserName { get; set; }
 
        [Required]
        [DataType(DataType.Password)]
        [Display(Name = "Password")]
        public string Password { get; set; }
 
        [Display(Name = "Remember me?")]
        public bool RememberMe { get; set; }
    }
}

Models > Account > ApplicationUser.cs

using System.Data.Entity;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.EntityFramework;
 
namespace TestSite.Models.Account
{
    // You can add profile data for the user by adding more properties to your ApplicationUser class, please visit http://go.microsoft.com/fwlink/?LinkID=317594 to learn more.
    public class ApplicationUser : IdentityUser
    {
        public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager)
        {
            // Note the authenticationType must match the one defined in CookieAuthenticationOptions.AuthenticationType
            var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie);
            // Add custom user claims here
            return userIdentity;
        }
    }
 
    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext()
            : base("EcfSqlConnection", throwIfV1Schema: false)
        {
            Database.SetInitializer<ApplicationDbContext>(new DropCreateDatabaseIfModelChanges<ApplicationDbContext>());
        }
 
        public static ApplicationDbContext Create()
        {
            return new ApplicationDbContext();
        }
    }
}

Views > Account > Login.cshtml

@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>
    </div>
</body>
@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

Startup.cs

using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Owin;
using System;
using System.Web.Helpers;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using System.Security.Claims;
using System.Threading.Tasks;
using TestSite.App_Start;
using TestSite.Models.Account;
 
 
[assembly: OwinStartup(typeof(TestSite.Startup))]
 
namespace TestSite
{
    public class Startup
    {
        private const string LogoutUrl = "/util/logout.aspx";
 
        public void Configuration(IAppBuilder app)
        {
            ConfigureAuth(app);
        }
 
        // For more information on configuring authentication, please visit http://go.microsoft.com/fwlink/?LinkId=301864
        public void ConfigureAuth(IAppBuilder app)
        {
            // Configure the db context, user manager and signin manager to use a single instance per request
            app.CreatePerOwinContext(ApplicationDbContext.Create);
            app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create);
            app.CreatePerOwinContext<ApplicationSignInManager>(ApplicationSignInManager.Create);
 
            // Enable the application to use a cookie to store information for the signed in user
            // and to use a cookie to temporarily store information about a user logging in with a third party login provider
            // Configure the sign in cookie
            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
                LoginPath = new PathString("/Account/Login"),
                Provider = new CookieAuthenticationProvider
                {
                    // Enables the application to validate the security stamp when the user logs in.
                    // This is a security feature which is used when you change a password or add an external login to your account.  
                    OnValidateIdentity =
                        SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
                            validateInterval: TimeSpan.FromMinutes(30),
                            regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager))
                },
                LogoutPath = new PathString("/Account/LogOff")
            });
 
            app.Map(LogoutUrl, map =>
            {
                map.Run(ctx =>
                {
                    ctx.Authentication.SignOut();
                    ctx.Response.Redirect("/");
                    return Task.FromResult(0);
                });
            });
 
            //Tell antiforgery to use the name claim
            AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.Name;
 
            app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
 
            // Enables the application to temporarily store user information when 
            // they are verifying the second factor in the two-factor authentication process.
            app.UseTwoFactorSignInCookie(
                DefaultAuthenticationTypes.TwoFactorCookie,
                TimeSpan.FromMinutes(5));
 
            // Enables the application to remember the second login verification factor such 
            // as phone or email. Once you check this option, your second step of 
            // verification during the login process will be remembered on the device where 
            // you logged in from. This is similar to the RememberMe option when you log in.
            app.UseTwoFactorRememberBrowserCookie(
                DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);
 
            // Uncomment the following lines to enable logging in 
            // with third party login providers
            //app.UseMicrosoftAccountAuthentication(
            //    clientId: "",
            //    clientSecret: "");
 
            //app.UseTwitterAuthentication(
            //   consumerKey: "",
            //   consumerSecret: "");
 
            //app.UseFacebookAuthentication(
            //   appId: "",
            //   appSecret: "");
 
            //app.UseGoogleAuthentication();
        }
    }
}

InitializationRouting.cs

Instead of handle the routing in the Global.asax you can add this as a InitialeizableModule. Have added the routing for the admin tool in this part too.


using System.Web.Http;
using System.Web.Mvc;
using System.Web.Routing;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
 
namespace TestSite
{
    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class InitializationRouting : IInitializableModule
    {
        public static void Register(HttpConfiguration config)
        {
            // Attribute routing.
            config.MapHttpAttributeRoutes();
 
            RouteTable.Routes.MapRoute(
                name: "IdentityEditor",
                url: "Identity/{action}/{id}",
                defaults: new { controller = "Identity", action = "index", id = UrlParameter.Optional }
            );
 
            RouteTable.Routes.MapRoute(
                name: "Account",
                url: "Account/{action}/{id}",
                defaults: new { controller = "Account", action = "login", id = UrlParameter.Optional }
            );
 
        }
 
        public void Initialize(InitializationEngine context)
        {
            GlobalConfiguration.Configure(Register);
        }
        public void Preload(string[] parameters) { }
        public void Uninitialize(InitializationEngine context) { }
    }
}

Web.config



    <!--Change : You don't have to change the login code in the Alloy demo as long as you add the new loginurl.-->
    <authentication mode="None">
      <forms loginUrl="~/Account/Login" timeout="2880" />
    </authentication>
 
    <!--Change-->
    <membership enabled="false">
      <providers>
        <clear />
      </providers>
    </membership>
    <roleManager enabled="false">
      <providers>
        <clear />
      </providers>
    </roleManager>
  </system.web>
  <system.webServer>
 
    <!--Change-->
  <connectionStrings>
    <add name="EPiServerDB" connectionString="Data Source=<host>;Initial Catalog=EPiServerDB_4380c2ee;Integrated Security=False;User ID=dbUser;Password=*****;MultipleActiveResultSets=True" providerName="System.Data.SqlClient" />
    <add name="EcfSqlConnection" connectionString="Data Source=<host>;Initial Catalog=TestSiteIdentityDB;Integrated Security=True" providerName="System.Data.SqlClient" />
  </connectionStrings>


Now you could either add ~/Account/Login in the url or click on the login button in the Alloy Demo. Then something like this should pop up.


This solution could now easily be extended with "my page" functionality where the user could manage his own information. I haven't added this to keep the example as small as possible.

How to login?

The major problem now is that you don’t have any account to login with. Check out the Admin tool i have built to create and maintain users and roles for ASP.NET Identity. Read more about that here.

9 comments:

  1. Awesome solution. Very impressive. But what if I would like the route to the EpiLogin to be : ~/GUI instead of ~/Account/Login ?
    Cheers

    ReplyDelete
    Replies
    1. Thank you! Beside that you would have to rename the files and folder to support the Models, Views and Controllers, you would also have to change the the startup.cs, InitializationRouting.cs and web.config to be "GUI" instead of "Account".

      Delete
  2. How to assign built in user roles to authenticated users and also if we create custom roles then can we able to assign roles on pages in episerver ?

    ReplyDelete
    Replies
    1. New blog post out explains how to do it. -> http://sveinaandahl.blogspot.no/2015/11/how-to-fix-roles-in-episerver-when.html

      Delete
  3. Good tutorial, works like a charm! :) However, this seems to break the authentication för ImageVault UI. The trust relationship between the UI and Core can no longer be established, which affects large portions of site we are woking with. Is this something you have come across?

    ReplyDelete
    Replies
    1. Hi William, I haven't personally done any authentication work against ImageVault. But, I can see that you can handle different authentication for the UI. -> http://imagevault.se/en/documentation/api-documentation/?page=configuration\ui/authentication/

      Delete
  4. I implemented using your logic but I am facing one issue. I googled a lot but not found any clue

    "Default Membership Provider must be specified."

    My CMS Version:

    <package id="EPiServer.CMS" version="9.12.0" targetFramework="net45"
    <package id="EPiServer.CMS.Core" version="9.12.0" targetFramework="net45"
    <package id="EPiServer.CMS.UI" version="9.9.0" targetFramework="net45"
    <package id="EPiServer.CMS.UI.Core" version="9.9.0" targetFramework="net45"

    ReplyDelete
  5. I got some help from this link
    http://world.episerver.com/forum/developer-forum/-Episerver-75-CMS/Thread-Container/2015/12/projectmessagenotifier-error-in-cms-9.5/

    ReplyDelete