Monday, July 3, 2017

How to setup OpenIdConnect integration between Azure AD B2C and Episerver

Setting up OpenIdConnect integration between Azure AD B2C and EPiserver isn’t straight forward. Together with my colleague Hugo Moen, we will share with you how we solved this.

This type of Identity Server can handle up to 10 million users. We found out it can be very interesting for small solutions too, since there are now cost with less than 50 thousand users and up to 50 thousand logins per month. You would need to require an azure subscription to use this service, but you probably have one if you have hosted an Episerver solution in Azure.

What’s the different between Azure AD and Azure AD B2C?
Azure AD is a directory service with the goal to server organizations and their needs for identity management in the cloud.

Azure AD B2C is another service built on the same technology, but not on the same in functionality as Azure AD. Azure AD B2C target is to build a directory for consumer applications where users can register with e-mail ID or social providers like LinkedIn, Facebook, Google and so on. The goal for Azure AD B2C is to allow organizations to manage single directory of customer identities shared among all applications.

Some more information can be found here: http://predica.pl/blog/azure-ad-b2b-b2c-puzzled-out/

Setting up Azure AD B2C
In https://portal.azure.com you will setup Azure AD B2C. You can read more about how to do that here: https://docs.microsoft.com/en-us/azure/active-directory-b2c/active-directory-b2c-get-started

Adding Groups
Today in Episerver we use groups to set access right to different pages, blocks and folders. We usually use WebAdmins or Administrators. In Azure AD B2C you can manage users and groups.
But this Azure AD B2C groups isn't a part of the claims in the token you recive after you have loggedin, so we would have to fetch them through the graph api after you have been authenticated.

When you create groups it's probably smart to call the first group "WebAdmins" to avoid challenges with Episerver later on. The groups that's added to the users will be synchronized to episerver through the code later on.



The code in the example is applied to a  Episerver CMS 10 version with the Alloy templates.

NB! Nuget Package
Verify that this nuget package isn’t installed in you in episerver solution:

  • EPiServer.CMS.UI.AspNetIdentity

If it is, then remove it, since it’s doesn’t work well with other IAM integrations. You could end up getting null references in different views. You only need it if you are going to use Episerver’s own version for ASP.NET Identity 2.0. If you use it already on existing solution, I would recommed to wait to remove it until you got this new B2C solution up and running.

Install necessary Nuget Packages
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.Owin.Security.OpenIdConnect

To be able to use the Graph API you will also need these packages.

PM> Install-Package Microsoft.Azure.ActiveDirectory.GraphClient
PM> Install-Package Microsoft.IdentityModel.Clients.ActiveDirectory


Code in Startup.cs


using System;
using System.Configuration;
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web;

using Owin;
using Microsoft.Owin;
using Microsoft.Owin.Extensions;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Notifications;
using Microsoft.Owin.Security.OpenIdConnect;

using EPiServer.Security;
using EPiServer.ServiceLocation;

using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.Azure.ActiveDirectory.GraphClient;

[assembly: OwinStartup(typeof(Alloydemo.Startup))]

namespace Alloydemo
{
    public class Startup
    {
        const string AAD_GRAPH_URI = "https://graph.windows.net";

        private static string clientId = ConfigurationManager.AppSettings["ida:ClientId"];
        private static string appKey = ConfigurationManager.AppSettings["ida:AppKey"];
        private static string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
        private static string tenant = ConfigurationManager.AppSettings["ida:Tenant"];

        private string authority = String.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
        
        private static readonly string PostLogoutRedirectUri = ConfigurationManager.AppSettings["ida:PostLogoutRedirectUri"];

        const string LogoutPath = "/util/logout.aspx";
        const string LoginUrl = "/util/login.aspx";

        public void Configuration(IAppBuilder app)
        {
            var authContext = new AuthenticationContext(string.Format(aadInstance, tenant));
            var clientCredential = new ClientCredential(clientId, appKey);
            var graphUri = new Uri(AAD_GRAPH_URI);
            var serviceRoot = new Uri(graphUri, tenant);
            this.aadClient = new ActiveDirectoryClient(serviceRoot, async () => await AcquireGraphAPIAccessToken(AAD_GRAPH_URI, authContext, clientCredential));

            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
            app.UseCookieAuthentication(new CookieAuthenticationOptions());
            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                ClientId = clientId,
                Authority = authority,
                PostLogoutRedirectUri = PostLogoutRedirectUri,
                TokenValidationParameters = new System.IdentityModel.Tokens.TokenValidationParameters
                {
                    ValidateIssuer = false,
                    RoleClaimType = ClaimTypes.Role
                },
                UseTokenLifetime = false, //To avoid that the session expires after 5 min 
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthenticationFailed = context =>
                    {
                        context.HandleResponse();
                        context.Response.Write(context.Exception.Message);
                        return Task.FromResult(0);
                    },
                    AuthorizationCodeReceived = async (context) => await AuthorizationCodeReceived(context),
                    RedirectToIdentityProvider = context =>
                    {
                        // Here you can change the return uri based on multisite
                        HandleMultiSitereturnUrl(context);

                        // To avoid a redirect loop to the federation server send 403 
                        // when user is authenticated but does not have access
                        if (context.OwinContext.Response.StatusCode == 401 &&
                            context.OwinContext.Authentication.User.Identity.IsAuthenticated)
                        {
                            context.OwinContext.Response.StatusCode = 403;
                            context.HandleResponse();
                        }
                        return Task.FromResult(0);
                    },
                    SecurityTokenValidated = (ctx) =>
                    {
                        var redirectUri = new Uri(ctx.AuthenticationTicket.Properties.RedirectUri, UriKind.RelativeOrAbsolute);
                        if (redirectUri.IsAbsoluteUri)
                        {
                            ctx.AuthenticationTicket.Properties.RedirectUri = redirectUri.PathAndQuery;
                        }
                        return Task.FromResult(0);
                    }
                }
            });
            app.UseStageMarker(PipelineStage.Authenticate);

            //Remap login
            app.Map(LoginUrl, map =>
            {
                map.Run(ctx =>
                {
                    if (ctx.Authentication.User == null ||
                        !ctx.Authentication.User.Identity.IsAuthenticated)
                    {
                        ctx.Response.StatusCode = 401;
                    }
                    else
                    {
                        ctx.Response.Redirect("/");
                    }
                    return Task.FromResult(0);
                });
            });
            app.Map(LogoutPath, map =>
            {
                map.Run(ctx =>
                {
                    ctx.Authentication.SignOut();
                    return Task.FromResult(0);
                });
            });
        }

        private Task AuthorizationCodeReceived(AuthorizationCodeReceivedNotification context)
        {
            return Task.Run(async () =>
            {
                var oidClaim = context.AuthenticationTicket.Identity.Claims.FirstOrDefault(c => c.Type == "http://schemas.microsoft.com/identity/claims/objectidentifier");

                var identity = new ClaimsIdentity(context.AuthenticationTicket.Identity.AuthenticationType);
                if (identity.IsAuthenticated)
                {
                    identity.AddClaims(context.AuthenticationTicket.Identity.Claims);

                    if (!string.IsNullOrWhiteSpace(oidClaim?.Value))
                    {
                        var pagedCollection = await this.aadClient.Users.GetByObjectId(oidClaim.Value).MemberOf.ExecuteAsync();

                        do
                        {
                            var directoryObjects = pagedCollection.CurrentPage.ToList();
                            foreach (var directoryObject in directoryObjects)
                            {
                                var group = directoryObject as Group;
                                if (group != null)
                                {
                                    identity.AddClaim(new Claim(ClaimTypes.Role, group.DisplayName, ClaimValueTypes.String));
                                }
                            }
                            pagedCollection = pagedCollection.MorePagesAvailable ? await pagedCollection.GetNextPageAsync() : null;
                        }
                        while (pagedCollection != null);
                    }

                    context.AuthenticationTicket = new AuthenticationTicket(
                        new ClaimsIdentity(identity.Claims, context.AuthenticationTicket.Identity.AuthenticationType), context.AuthenticationTicket.Properties);

                    //Sync user and the roles to EPiServer in the background
                    await ServiceLocator.Current.GetInstance<ISynchronizingUserService>().SynchronizeAsync(context.AuthenticationTicket.Identity);
                }

            });
        }

        private async Task<string> AcquireGraphAPIAccessToken(string graphAPIUrl, AuthenticationContext authContext, ClientCredential clientCredential)
        {
            AuthenticationResult result = null;
            var retryCount = 0;
            var retry = false;

            do
            {
                retry = false;
                try
                {
                    // ADAL includes an in-memory cache, so this will only send a request if the cached token has expired
                    result = await authContext.AcquireTokenAsync(graphAPIUrl, clientCredential);
                }
                catch (AdalException ex)
                {
                    if (ex.ErrorCode == "temporarily_unavailable")
                    {
                        retry = true;
                        retryCount++;
                        await Task.Delay(3000);
                    }
                }
            } while (retry && (retryCount < 3));

            if (result != null)
            {
                return result.AccessToken;
            }

            return null;
        }

        private void HandleMultiSitereturnUrl(
            RedirectToIdentityProviderNotification<Microsoft.IdentityModel.Protocols.OpenIdConnectMessage,
                OpenIdConnectAuthenticationOptions> context)
        {
            // here you change the context.ProtocolMessage.RedirectUri to corresponding siteurl
            // this is a sample of how to change redirecturi in the multi-tenant environment
            if (context.ProtocolMessage.RedirectUri == null)
            {
                var currentUrl = EPiServer.Web.SiteDefinition.Current.SiteUrl;
                context.ProtocolMessage.RedirectUri = new UriBuilder(
                    currentUrl.Scheme,
                    currentUrl.Host,
                    currentUrl.Port,
                    HttpContext.Current.Request.Url.AbsolutePath).ToString();
            }
        }

        public ActiveDirectoryClient aadClient { get; set; }

    }
}


Web.config
Add the following code snippets into the web.config.

  <appSettings>
    <add key="ida:ClientId" value="xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx" />
    <add key="ida:AppKey" value="<- app key ->" />
    <add key="ida:Tenant" value="<-YourTenant->.onmicrosoft.com" />
    <add key="ida:AADInstance" value="https://login.microsoftonline.com/{0}" />
    <add key="ida:RedirectUri" value="http://localhost:7676/" />
    <add key="ida:PostLogoutRedirectUri" value="http://localhost:7676/"/>
  </appSettings>
..
 <authentication mode="None"/>
..
    <membership>
      <providers>
        <clear />
      </providers>
    </membership>
    <roleManager enabled="false">
      <providers>
        <clear />
      </providers>
    </roleManager>
..
  <episerver.framework>
    <securityEntity>
      <providers>
        <add name="SynchronizingProvider" type="EPiServer.Security.SynchronizingRolesSecurityEntityProvider, EPiServer" />
      </providers>
    </securityEntity>


Access Rights to the Graph Api 

NB! You need to set the correct access right to the Graph Api or else you will end up with a error like this after you have logged in:


  • Insufficient privileges to complete the operation


So to fix this you have to install the Windows Azure AD Module for Windows PowerShell and apply the correct role to the application.

  1. Use a domain administrator account, when starting up Powershell.
    • Directory role => Global administrator
  2. Referring to the Microsoft documentation:
    • Ensure that your server meets the following Windows Azure AD Module for Windows PowerShell requirements:
      • Windows 7, Windows 8, Windows Server 2008 R2, or Windows Server 2012.
      • Microsoft .NET Framework 3.51 feature.
    • Download and install the appropriate Microsoft Online Services Sign-In Assistant version for your operating system (see Microsoft Online Services Sign-In Assistant for IT Professionals RTW).
  3.  Install the Windows Azure Active Directory Module for Windows PowerShell (see Install the Windows Azure AD Module ).
  4. Connect to Windows Azure AD by running the PowerShell command:
    •  import-module MSOnline 
  5. Logg on Azure:
    • Connect-MsolService => Login screen pop up
  6. List out application with ID's to find the correct application ID
    • Get-MsolServicePrincipal | ft DisplayName, AppPrincipalId -AutoSize
  7. Set correct value and ID:
    • $clientIdApp = 'XXXXXX-XXXX-XXXX-XXXX-XXXXXXXX'
    • $webApp = Get-MsolServicePrincipal –AppPrincipalId $clientIdApp
  8. Add a read role ("Directory Readers") to the application:
    • Add-MsolRoleMember -RoleName "Directory Readers" -RoleMemberType ServicePrincipal -RoleMemberObjectId $webApp.ObjectId 
So, if you have done everything correct, you should now be able to login to the Episerver through Azure AD B2C. The nice part is that if you use an AD account that is connected to other services, like office 365, you will have a perfect SSO solution.

EDIT:

You can also use this same approach for for Azure AD, but need to add following steps.

Ref: https://joonasw.net/view/the-grant-requires-admin-permission

In the login url add "&promt=admin_consent", example:

https://login.microsoftonline.com/ef8a53ea-1a1c-4189-b792-c832dcaea568/oauth2/authorize?client_id=c4386fb3-bc5b-494e-917f-fd64e7789f2a&response_mode=form_post&response_type=code+id_token&scope=openid+profile&state=OpenIdConnect.AuthenticationProperties%3dXJcVcEgbVsAldbQjX2Dp8dFStu_L2nT1CGUZu9QgwqocO3B_rfrAUuBd99k5AAblq6GVfyzMblfR2aAqlLCChKyQsREoyDKnnamAJzGETcaiUDa-ETcyA97AD5PM9RdpvXyoulkygC0jcHmeFaj2cbUaO0u0w-oyP2KyEJvDkIw9fNpg6znTuwjoEoi5sfk8r-Ja26SdDJ0Mgw&nonce=636516222037192017.YWINzEtMTkyNi00NmI1LWFiNmEtMTY5YjIwM2Y2ZjY3ZDY3ZjI5MmEtYWNiZS00ZDBkLWFmZTMWVhZTY2YzRmMTA2&redirect_uri=https%3a%2f%2fyourweb.episerver.com%3a443%2futil%2flogin.aspx&prompt=admin_consent

Steps:
1. Click on the login url which include the promt explained above.
2. Login as Admin.
3. Accept the consent.
4. Then all the AD users should manage to login without getting a grant required error.


Monday, April 25, 2016

How to setup OAuth2.0 integration between IdentityServer3 and Episerver

There exists a couple blog post on how to integrate Episerver with WS-Federation protocol, which also is support in IdentityServer3. But this blog post will introduce to you, how to setup OAuth2.0 integration between Episerver and IdentityServer3. IdentityServer3 is a popular open source security token service framework written in .NET, that implements the OpenID Connect and OAuth2 protocols. In this example we use the “Hybrid”-flow, which also contain the refresh token that’s used to obtain and renewed the identity token. Which is a really nice feature in modern SSO strategy.

For you who want to read an introduction to this, can click here: http://sveinaandahl.blogspot.no/2016/04/how-to-do-identity-and-access.html



Install necessary Nuget Packages
First of all you need to install som 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.Owin.Security.OpenIdConnect


NOTE
Since the implementaion of the OpenID Connect and OAuth2 protocols usually use JSON web token(JWT), the token format look something like this:
//claims
{
 "sub": "svein@knowit.no",
 "name": "Svein Aandahl",
 "role": "Administator"
}

While the URI in a assertion (claims) in a Saml1.1 token looks like this
Role and Name claim
http://schemas.microsoft.com/ws/2008/06/identity/claims/role
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name

Why is this importen to be aware of? The Episerver implementation only support the Saml 1.1 token standard. So when you use the OAuth 2.0 you would need to adjust some of the code to make this work in Episerver.

Code in Startup.cs
Create a startup.cs file on root and add following code.

using EPiServer.Security;
using EPiServer.ServiceLocation;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Owin;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web.Helpers;
using IdentityModel.Client;
using Microsoft.IdentityModel.Protocols;
using Microsoft.Owin.Security.OpenIdConnect;

[assembly: OwinStartup(typeof(EPiServer.Templates.Alloy.Startup))]

namespace EPiServer.Templates.Alloy
{
    public class Startup
    {
        const string LogoutUrl = "/util/logout.aspx";
        const string LoginUrl = "/login";
        const string clientId = "Client-Name";
        const string IdSrvUrl = "https://idsrv.local";
        const string ClientUrl = "http://website.local/";

        public void Configuration(IAppBuilder app)
        {
            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);

            AntiForgeryConfig.UniqueClaimTypeIdentifier = "sub";
            JwtSecurityTokenHandler.InboundClaimTypeMap = new Dictionary<string, string>();
            JwtSecurityTokenHandler.OutboundClaimTypeMap = new Dictionary<string, string>();

            // Sets the authentication type for the owin middleware
            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = "Cookies"
            });

            // Uses OpenIdConnect middleware for authentication
            app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                ClientId = clientId,

                Authority = IdSrvUrl + "/core/", 
                RedirectUri = ClientUrl, 
                PostLogoutRedirectUri = ClientUrl, 
                ResponseType = "code id_token token",
                Scope = "openid email profile offline_access roles",
                UseTokenLifetime = false, //To avoid that the session expires after 5 min 
                SignInAsAuthenticationType = "Cookies",

                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    AuthorizationCodeReceived = async n =>
                    {
                        // use the code to get the access and refresh token
                        var tokenClient = new TokenClient(
                            IdSrvUrl + "/core/connect/token",
                            clientId, 
                            "secret");

                        var tokenResponse = await tokenClient.RequestAuthorizationCodeAsync(n.Code, n.RedirectUri);

                        // use the access token to retrieve claims from userinfo
                        var userInfoClient = new UserInfoClient(
                            new Uri(IdSrvUrl + "/core/connect/userinfo"),
                            tokenResponse.AccessToken);

                        var userInfoResponse = await userInfoClient.GetAsync();

                        // create new identity
                        var id = new ClaimsIdentity(n.AuthenticationTicket.Identity.AuthenticationType);
                        if (id.IsAuthenticated)
                        { 
                            id.AddClaims(userInfoResponse.GetClaimsIdentity().Claims);
                        
                            // Add name claim as http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
                            var name = id.Claims.Where(x => x.Type == "name").Select(x => x.Value).FirstOrDefault() ?? 
                                       id.Claims.Where(x => x.Type == "preferred_username").Select(x => x.Value).FirstOrDefault();
                            id.AddClaim(new Claim(ClaimTypes.Name, name));

                            // Add all role claims for the user as http://schemas.microsoft.com/ws/2008/06/identity/claims/role
                            IEnumerable<Claim> roles = id.Claims.Where(x => x.Type == "role");
                            foreach (var role in roles)
                            {
                                id.AddClaim(new Claim(ClaimTypes.Role, role.Value));
                            }

                            id.AddClaim(new Claim("access_token", tokenResponse.AccessToken));
                            id.AddClaim(new Claim("expires_at", DateTime.Now.AddSeconds(tokenResponse.ExpiresIn).ToLocalTime().ToString()));
                            id.AddClaim(new Claim("refresh_token", tokenResponse.RefreshToken));
                            id.AddClaim(new Claim("id_token", n.ProtocolMessage.IdToken));
                        }

                        n.AuthenticationTicket = new AuthenticationTicket(
                            new ClaimsIdentity(id.Claims, n.AuthenticationTicket.Identity.AuthenticationType),
                            n.AuthenticationTicket.Properties);

                    },

                    RedirectToIdentityProvider = n =>
                    {
                        // if signing out, add the id_token_hint
                        if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.LogoutRequest)
                        {
                            var idTokenHint = n.OwinContext.Authentication.User.FindFirst("id_token");
                            if (idTokenHint != null)
                            {
                                n.ProtocolMessage.IdTokenHint = idTokenHint.Value;
                            }
                        }
                        return Task.FromResult(0);
                    },

                    SecurityTokenValidated = (ctx) =>
                    {
                        //Ignore scheme/host name in redirect Uri to make sure a redirect to HTTPS does not redirect back to HTTP
                        var redirectUri = new Uri(ctx.AuthenticationTicket.Properties.RedirectUri, UriKind.RelativeOrAbsolute);
                        if (redirectUri.IsAbsoluteUri)
                        {
                            ctx.AuthenticationTicket.Properties.RedirectUri = redirectUri.PathAndQuery;
                        }

                        //Sync user and the roles to EPiServer in the background
                        ServiceLocator.Current.GetInstance<OAuth2SynchronizingUserService>().SynchronizeAsync(ctx.AuthenticationTicket.Identity);

                        return Task.FromResult(0);
                    },
                }
            });
            //Remap login
            app.Map(LoginUrl, map =>
            {
                map.Run(ctx =>
                {
                    if (ctx.Authentication.User == null ||
                        !ctx.Authentication.User.Identity.IsAuthenticated)
                    {
                        ctx.Response.StatusCode = 401;
                    }
                    else
                    {
                        ctx.Response.Redirect("/");
                    }
                    return Task.FromResult(0);
                });
            });
            //Remap logout 
            app.Map(LogoutUrl, map =>
            {
                map.Run(ctx =>
                {
                    ctx.Authentication.SignOut();
                    return Task.FromResult(0);
                });
            });

            //Tell antiforgery to use the name claim
            AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.Name;
        }
    }
}

To be able to sync roles up from Identity Server when the user login, we need to replace the "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" with "role".

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using EPiServer.Async;
using EPiServer.Framework;
using EPiServer.Security;
using EPiServer.ServiceLocation;

namespace TestSite.Business.ServiceLocation
{
    [ServiceConfiguration(typeof(SynchronizingUserService))]
    public class OAuth2SynchronizingUserService : SynchronizingUserService
    {
        public override Task SynchronizeAsync(ClaimsIdentity identity)
        {
            Validator.ThrowIfNull("identity", identity);
            if (!identity.IsAuthenticated)
                throw new ArgumentException("The identity is not authenticated", nameof(identity));

            var name = identity.Name ?? identity.Claims.Single(c => c.Type == "aud").Value;
            var roles = GetRolesFromClaims(identity);
            return ServiceLocator.Current.GetInstance<TaskExecutor>().Start(() => SynchronizeUserAndRoles(name, roles));
        }

        private static List<string> GetRolesFromClaims(ClaimsIdentity identity)
        {
            return identity.Claims.Where(c => c.Type == "role").Where(c => !string.IsNullOrEmpty(c.Value)).Select(c => 
c.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
        }
    }
}

Web.config
You need to do some modification in web.config and remove membership provider .


    <authentication mode="None"/>

    <membership>
      <providers>
        <clear/>
      </providers>
    </membership>
    <roleManager enabled="false">
      <providers>
        <clear/>
      </providers>
    </roleManager>

<episerver.framework>
    <securityEntity>
      <providers>
        <add name="SynchronizingProvider" type ="EPiServer.Security.SynchronizingRolesSecurityEntityProvider, EPiServer"/>
      </providers>
    </securityEntity>
    <virtualRoles addClaims="true">
       //existing virtual roles
    </virtualRoles>


Administration of users
After you have got everything up and running with both episerver and IdentityServer then you need to go into the admin to create a user and add roles. These roles will be populated in Episerver, so you can add them as groups in the Episerver tree.



To be able to login to Episerver editor you need to add the name and role claim as minimum.




Happy coding!

How to do Identity and Access Management with Episerver

After working with Identity and Access Management (IAM) in a couple of large Episerver projects, I want to share some of the knowledge gained from this work. After Episerver upgraded their CMS platform to use OWIN you can pretty much replace the old membership provider with whatever IAM system you want. In this blog post I will introduce how to use OpenID Connect and OAuth2.0 through Identity Server 3 together with Episerver.

The big advantage of claims based identity is that the burden of authenticating users is moved from the application to an identity server or identity provider. It’s important to understand the difference between authentication and authorization.

The Analogy
Here is an analogy, let's say that you want to get into a nightclub.

First, you need to be authenticated. By issuing a driver's license from the department of Motor Vehicles (analogous to the Identity Server), the state confirms your identity and generates a license, which includes data about you. The data includes information such as your name, your birth-date, your picture and so on. The token that you have been issued proves your identity.

Next, you make your way to the club, with the license in hand. Once you arrive, the bouncer obtains your name from your license and makes an authorization decision. “Is your name on the guest list?” The guest list acts as an approved claim in the system you want to access, which simply lists the people who are authorized to enter. If your name appears, then you are allowed access into the club.

In this rather simplified analogy, there is a clear separation between the entity that performs authentication (the state government), and the entity that performs authorized (the club bouncer).

Different protocols
Today the most common authentication protocols are OpenID Connect, WS-Federation and SAML2P. Where SAML2P are probably the most common protocol. 

Microsoft Corporation has invested heavily into incorporating WS-Federation into its products. The most popular claim type for WS-Federation, ironically, is a SAML 1.1 Assertion. This leads people to think that WS-Federation and SAML can talk to each other. So when people talk about supporting SAML they really talking about support SAML claims inside WS-Federation.

OpenID Connect is only a couple years old, but is considered to be the future, since it provides the most possibilities for modern applications. It was also designed to be more usable by native and mobile applications, and to be WebAPI friendly.

Architecture scenario


Through e.g. a WebAPI you could get more profile data to enrich the information about the logged in user, instead of putting all kind of information into claims.

IdentityServer3
In this scenario, one of the key components is the use of IdentityServer3 to replace the authentication in Episerver or any website. The IdentityServer3 is a popular open source security token service framework written in .NET, that implements the OpenID Connect and OAuth2 protocols. It is used to authenticate users via single-sign-on and to secure WebAPIs. It can be used stand-alone or in conjunction with other identity providers, such as Google, Facebook, Azure AD, ADFS and others.

IdentityServer3 supports different OpenId Connect flows that can be used in to communication between different types of clients.

If you are going to have some form of communication between two systems that doesn’t involve human, e.g. web API’s, then you should look into using "client credentials" flow.

Another one is the "Hybrid" flow. To avoid the users to reauthenticate every time they want to access a restricted area on the website you can use this flow, which contains the refresh token that’s used to obtain and renewed the identity token. I have written another blog post on how to connect Episerver and IdentityServer3 based on the OpenID Connect/OAuth2.0 protocol using the “Hybrid” flow. You can check that here:
http://sveinaandahl.blogspot.no/2016/04/how-to-setup-oauth20-integration.html

In some cases, where you will need to handle login with legacy systems it’s also possible to handle login screen on the website with the Resource Owner Flow, but this is usually not the recommended way. Depends on the goal and what kind of constrains you have.

For User Repository, IdentityServer3 supports both ASP.NET Identity 2.0 and MembershipReboot from Brock Allan. By using MembershipReboot you also have the ability to use nosql databases, like RavenDb and MongoDb.

I have done migration for users from Membership provider, which is the default authentication system in Episerver, to ASP.NET Identity 2.0 and had no problems to validate old passwords afterwards. You can read about that here:
http://sveinaandahl.blogspot.no/2016/03/how-to-validate-old-passwords-when.html

My experience is that ASP.NET Identity 2.0 need some “love” to perform just as good as the MembershipReboot do.  And that ASP.NET Identity 2.0 got less features then MembershipReboot. But both choices are reasonable.

If a company has several brands, each with its own website, they might wish to customize the login screen for each brand. This is possible to do, to some degree with Identity Server. The screen dump below shows the login screen looks like default, customized with Norwegian language.

Extensibility and customization
In some cases, you e.g. want to be able to override the login process to validate other username than email, but also use phone number, member number and so on. Or maybe you want to validate if users exist and then populate claims from the CRM system during login process. Could also happened you want to be able verify that the user exists in the CRM system, before the user are allowed to register.

Since IdentityServer3 is designed for extensibility and customization and allows applications to satisfy their custom security requirements you will be able to do that. But you should always be very careful how your stitching this together to avoid security breach in your system.

Scalability
The IdentityServer3 is very scalable. The application is built in a way that setting it up in a load balanced environment is quite easy. Performance challenges is usually depended on what choices you have made when it comes to user repository and if you have extended and customized the login process in good way.

Links
For those who use Xamarin to build mobile applications on iOS or Android. Here is an example how to integrate Xamarin with Identity Server: 

If you are interested in finding out more about IdentityServer3 it’s a good start to begin here: 

For those who are interested, then Identity Server will also be supported for ASP.NET 5 when it’s launched.

Monday, March 14, 2016

How to validate old passwords when migrating from Membership provider to ASP.NET Identity in Episerver

Wouldn’t be nice to be able to validate the old passwords when you migrate from Membership provider to ASP.NET Identity? This was one of my headaches in an earlier project, but here is the solution.

Prerequisite 
The example in this post are based on the code from here: http://sveinaandahl.blogspot.no/2015/08/how-to-integrate-aspnet-identity-with.html

Note!
One of the things you should check out first in the original Episerver solution's web.config. Here you will find the hash algorithm type that’s used in the Membership provider. In the example below you can see «HMACSHA512» is used. 

From Web.config:

<membership defaultProvider="SqlServerMembershipProvider" userIsOnlineTimeWindow="10" hashAlgorithmType="HMACSHA512">

The whole clue when you migrate the passwords is to put the hashed password together with the password format, that’s always 1, and the password salt. Then you put it into the Hashed Password field in the new database.

e.g.: aspnet_Membership.Password+'|'+ 1 +'|'+aspnet_Membership.PasswordSalt

In the User Manager code you override the PasswordHasher function. When you get a user that has a password with this type of password format use the original way to encrypt the passwords in Membership provider, or else you can use the base version of PasswordHasher. Use the same hash algorithm type that you used to create in the original solution.

IdentityConfig.cs
Override the Password hasher function.

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)
        {
            PasswordHasher = new MyPasswordHasher();
        }
 

MyPasswordHasher.cs
Add a new class called MyPasswordHasher.cs

using System;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNet.Identity;
 
namespace TestSite
{
    public class MyPasswordHasher : PasswordHasher
    {
        public override string HashPassword(string password)
        {          
            return base.HashPassword(password);
        }
 
        public override PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword)
        {
            var passwordProperties = hashedPassword.Split('|');
            if (passwordProperties.Length != 3)
            {
                return base.VerifyHashedPassword(hashedPassword, providedPassword);
            }
            else
            {
                var passwordHash = passwordProperties[0];
                const int passwordformat = 1;
                var salt = passwordProperties[2];
                if (String.Equals(EncryptPassword(providedPassword, passwordformat, salt), passwordHash, StringComparison.CurrentCultureIgnoreCase))
                {
                    return PasswordVerificationResult.SuccessRehashNeeded;
                }
                else
                {
                    return PasswordVerificationResult.Failed;
                }
            }
        }
 
        private string EncryptPassword(string pass, int passwordFormat, string salt)
        {
            if (passwordFormat == 0) 
                return pass;
 
            byte[] bIn = Encoding.Unicode.GetBytes(pass);
            byte[] bSalt = Convert.FromBase64String(salt);
            byte[] bRet = null;
 
            if (passwordFormat == 1)
            {  
                HashAlgorithm hm = HashAlgorithm.Create("HMACSHA512"); 
 
                if (hm is KeyedHashAlgorithm)
                {
                    KeyedHashAlgorithm kha = (KeyedHashAlgorithm)hm;
                    if (kha.Key.Length == bSalt.Length)
                    {
                        kha.Key = bSalt;
                    }
                    else if (kha.Key.Length < bSalt.Length)
                    {
                        byte[] bKey = new byte[kha.Key.Length];
                        Buffer.BlockCopy(bSalt, 0, bKey, 0, bKey.Length);
                        kha.Key = bKey;
                    }
                    else
                    {
                        byte[] bKey = new byte[kha.Key.Length];
                        for (int iter = 0; iter < bKey.Length; )
                        {
                            int len = Math.Min(bSalt.Length, bKey.Length - iter);
                            Buffer.BlockCopy(bSalt, 0, bKey, iter, len);
                            iter += len;
                        }
                        kha.Key = bKey;
                    }
                    bRet = kha.ComputeHash(bIn);
                }
                else
                {
                    byte[] bAll = new byte[bSalt.Length + bIn.Length];
                    Buffer.BlockCopy(bSalt, 0, bAll, 0, bSalt.Length);
                    Buffer.BlockCopy(bIn, 0, bAll, bSalt.Length, bIn.Length);
                    bRet = hm.ComputeHash(bAll);
                }
            }
            return Convert.ToBase64String(bRet);
        }
 
    }
}

Migration of the database.
Since the database is created by the Entity Framework the first time you run the Episerver application with ASP.NET Identity, I recommend you not to try to use any create tables in the migration script. Since this often end up being a corrupt database for the Entity Framework.

The simple version would be just to do inserts on a existing database. The migration script could look something like the script below. Notice how the password format is set together.

The script below is not well tested, but it gives you an idea how it could work.

INSERT INTO AspNetUsers(Id,Email,EmailConfirmed,PasswordHash,SecurityStamp,
PhoneNumber,PhoneNumberConfirmed,TwoFactorEnabled,LockoutEndDateUtc,LockoutEnabled,
AccessFailedCount,UserName)
SELECT aspnet_Users.UserId,aspnet_Membership.Email,'true',
(aspnet_Membership.Password+'|'+CAST(aspnet_Membership.PasswordFormat as varchar)+'|'+aspnet_Membership.PasswordSalt),
NewID(),NULL,'false','true',aspnet_Membership.LastLockoutDate,'true','0',aspnet_Users.UserName
FROM aspnet_Users
LEFT OUTER JOIN aspnet_Membership ON aspnet_Membership.ApplicationId = aspnet_Users.ApplicationId 
AND aspnet_Users.UserId = aspnet_Membership.UserId;


TIPS
If you are going to run this script several times without getting duplicates you should probably look into using cursor scripts in sql. This way you would avoid inserting a user that's already migrated.

Have fun!

Wednesday, January 13, 2016

Tips for installing Episerver standard search with SSL

I wrote a blog post on how to setup Episerver Standard Search more than three years ago. After getting some questions about how use of SSL together with Episerver standard search, I thought I should share some useful tips here. I noticed that several people have written about this topic earlier, so this is more like an add on to this.


First of all, you should get the solution up and running without SSL. You will find a guide how to do that from this blog:


There are a two elements you will need to add to the web.config, when you want to add ssl support to your solution. Changes are marked with yellow background.

Web.config

 <system.serviceModel>
    <serviceHostingEnvironment multipleSiteBindingsEnabled="true" />
    <bindings>
      <webHttpBinding>
        <binding name="IndexingServiceCustomBinding" maxBufferPoolSize="1073741824" maxReceivedMessageSize="2147483647" maxBufferSize="2147483647">
           <security mode="Transport">
              <transport clientCredentialType="None"></transport>
           </security>
          <readerQuotas maxStringContentLength="10000000" />
        </binding>
      </webHttpBinding>
    </bindings>
  </system.serviceModel>



  <episerver.search active="true">
    <namedIndexingServices defaultService="serviceName">
      <services>
        <add name="serviceName" baseUri="https://DNS-name/IndexingService/IndexingService.svc" accessKey="local" />
      </services>
    </namedIndexingServices>
    <searchResultFilter defaultInclude="true">
      <providers />
    </searchResultFilter>
  </episerver.search>


IIS
In IIS you will need to add a https binding and perhaps remove the http. :-)


If everything is working fine you should get up a web service in your browser from this url (including the https): https://YourDnsName/IndexingService/IndexingService.svc

If not, you should read on. :-)

TIPS! «HTTP Activation» is a feature that is often forgot. So it's always smart to check if that's activated for the asp.net version you use in the project.

My experience is that it’s usually two things with the setup that’s not correct, when using SSL together with standard search. One is the lack of knowledge about certificates and how to install them, and second is how to setup the IIS correct.

Different certificates
The most used certificate types are «Domain validated certificates» and «Extended validation certificates». The use of EV certificate can be easily spotted by that the url field in the browser get an «green bar» in the beginning. More and more sites has started to use these EV certificates.They usually cost little more and takes longer to order because of the validation process.

Extended Validation Certificate
EV certificates use the same encryption as domain validated certificates: The increase in security is due to the identity validation process, which is indicated inside the certificated by the policy identifier.

Ref: https://en.wikipedia.org/wiki/Extended_Validation_Certificate


Production environment 
Before IIS8, there was an problem to have more than one certificate per server, since most server have only one IP address. Often this could be solve by using a SAN certificate that contain several domains.

With Windows 2012 (IIS8) came a new feature called Server Name Identification (SNI), which made it possible to use several certificate on same IP.

You can find more information on how to install this things here:

Single Certificate and Multiple Certificates Using SNI (IIS8)
https://www.digicert.com/ssl-certificate-installation-microsoft-iis-8.htm




Development environment
In development we usually just create a self-signed certificate. When you create this in IIS they are usually «Issued To» your computer name. What you write in as Friendly name doesn’t really mater.
So, if you want to avoid to getting a certificate error when you run your site in the browser you should use your machine name as localhost name.

TIPS! If you only are using one certificate you put the certificate into «Personal». If you are going to use SNI, you must add it to «Web Hosting».



How to access the certificate 
If you get access denied in the log file, here is a way to fix that. After you have create the certificate to the Personal folder, then type "MMC" in Windows search and open it up.(got an icon of a red tool box)

Then goto FILE -> Add/Remove Snap-in. -> Select Certificates -> click Add-> Select Computer account -> Local Computer -> Finished -> Ok.

Then open up Certificates -> Personal. Here you will find the certificate you have created or installed earlier.



Add Permissions to the certificate
Right click on the certificate(marked blue on picture above) -> All tasks -> Manage Private Keys -> Add "Network Service" user -> Remove «Full control» (only read is necessary) -> OK


IIS
Now add «Network Service» to the Application Pool to the site you use in IIS for the site with SSL. This will give the App pool identity access to both certificate and the site.



Happy configuring! :-)


Friday, December 18, 2015

How to improve performance with ASP.NET Identity and Episerver

I have written a few blog posts now about how to implement ASP.NET Identity in Episerver. If you use the ASP.NET Identity data model out of the box, you could run into some performance issues with large quantity. With a few tricks you could optimize the data model and improve the performance a lot. I have tested this changes with a database that contains more than 400 thousand users. The performance improved with more than 10 times.

When it comes to both performance and security it’s important that the database isn’t running on the same server as the CMS.

Important note! If you already got a database model up and running and apply this changes you would need to do a migration to new database model. Here is some reference on how to fix that if you run into this problem: 


Prerequisite 

The example in this post are based on the code from here: http://sveinaandahl.blogspot.no/2015/08/how-to-integrate-aspnet-identity-with.html

Database model

It was several elements I found during the analyze that could improve the performance, but here are a few easy fixes.

  • It’s important that Datatypes in the database isn’t using  MAX, e.g. NVARCHAR<MAX>. It’s actually better to set a large number. SQL will not index datatype the contains the datatype MAX.
  • When this is fixed. You can add index on selected tables. By doing this the request dropped from 200 milliseconds to 30. 
  • Another effect by adding indexs is that the SQL server will be steady, even if the CMS frontend server gets a lot of request.

Most of this changes can be done through Entity framework. All the changes you want to apply to the data model can implemented in the OnModelCreating function.  Here you can change the datatype and add index types that will be used a lot.


Update Models/Account/ApplicationUser.cs with this code


using System.ComponentModel.DataAnnotations.Schema;
using System.Data.Entity;
using System.Data.Entity.Infrastructure.Annotations;
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 MyClaims : IdentityUserClaim
    {
    }
 
    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext()
            : base("EcfSqlConnection", throwIfV1Schema: false)
        {
            Database.SetInitializer<ApplicationDbContext>(new DropCreateDatabaseIfModelChanges<ApplicationDbContext>());
        }
 
        public static ApplicationDbContext Create()
        {
            return new ApplicationDbContext();
        }
 
        protected override void OnModelCreating(DbModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
 
            //Shorten length on existing datatypes => improve performance
            modelBuilder.Entity<ApplicationUser>().Property(u => u.PhoneNumber).HasMaxLength(20);
            modelBuilder.Entity<ApplicationUser>().Property(u => u.PasswordHash).HasMaxLength(1024);
            modelBuilder.Entity<ApplicationUser>().Property(u => u.SecurityStamp).HasMaxLength(1024);
            modelBuilder.Entity<MyClaims>().Property(u => u.ClaimType).HasMaxLength(512);
            modelBuilder.Entity<MyClaims>().Property(u => u.ClaimValue).HasMaxLength(512);
 
            //Indexing important datatypes. => improve performance
            modelBuilder.Entity<ApplicationUser>().Property(u => u.Email).HasColumnAnnotation("Index", new IndexAnnotation(new IndexAttribute()));
        }
    }
}

Happy coding!

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