MusicStore - Part4 - External Login

Let’s enable the external login, so that Microsoft and LinkedIn users don’t need to sign up in Music Store.

  1. Microsoft

    • Create an app in Microsoft via https://apps.dev.microsoft.com/
      • Fill in Name
      • Generate New Password Generate New Password

      • Add Platform Add Microsoft App

      NOTE: Redirect URLs need to be consistent with CallbackPath in Startup.cs, otherwise, you will get an error:
      The provided value for the input parameter 'redirect_uri' is not valid

    • Update IdentityServer4 to authenticate Microsoft users

      • Install NuGet Package - Microsoft.AspNetCore.Authentication.MicrosoftAccount
      • Add Microsoft Authentication
      // Startup.cs
      public void ConfigureServices(IServiceCollection services)
      {
          ......
              
          services.AddAuthentication()
            .AddMicrosoftAccount(options =>
            {
              options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
      
              options.ClientId = Configuration["Microsoft:ApplicationId"];
              options.ClientSecret = Configuration["Microsoft:Password"];
      
              options.CallbackPath = new PathString("/microsoft");
              options.SaveTokens = true;
            });
          ......
      }
      
      public void Configure(IApplicationBuilder app)
      {
          ......
          app.UseAuthentication();
          ......
      }
      
      • Store Application Id and Password from #1.1 to appsettings.Development.json
      {
        ......
        "Microsoft": {
          "ApplicationId": "0d1c72bb-ec52-4a0c-a582-4ad79f9a9c5a",
          "Password": "vnssBKZWR237*ujtZW44@*|"
        }
        ......
      }
      
  2. LinkedIn

    NOTE: LinkedIn upgraded their API recently, it won't carry back user email, a key field in ApplicationUser, so LinkedIn user could login.

    • Create an app in LinkedIn via https://www.linkedin.com/developers/apps

      • Fill in App name, Company name, App description, Logo, and Business email Add LinkedIn App
    • Update IdentityServer4 to authenticate LinkedIn users

      • Install NuGet Package - AspNet.Security.OAuth.LinkedIn
      • Add LinkedIn Authentication

        public void ConfigureServices(IServiceCollection services)
        {
            ......
                  
            services.AddAuthentication()
              .AddLinkedIn(options =>
              {
                options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
        
                options.ClientId = Configuration["LinkedIn:ClientId"];
                options.ClientSecret = Configuration["LinkedIn:ClientSecret"];
        
                options.CallbackPath = new PathString("/linkedin");
                options.SaveTokens = true;
              });
            ......
        }
        
      • Store Client Id and Client Secret from #2.1 to appsettings.Development.json

        {
          ......
          "LinkedIn": {
            "ClientId": "816crn5hi7uoxx",
            "ClientSecret": "lyV15uviOnli9tQr"
          }
          ......
        }
        
  3. Authenticate users from external OAuth 2.0 providers

    • Replace TestUserStore with AspNetIdentity UserManager and SignInManager

      // ExternalController.cs
      private readonly UserManager<ApplicationUser> _userManager;
      private readonly SignInManager<ApplicationUser> _signInManager;
      public ExternalController(
        IIdentityServerInteractionService interaction,
        IClientStore clientStore,
        IEventService events,
        UserManager<ApplicationUser> userManager,
        SignInManager<ApplicationUser> signInManager
      )
      {
        _interaction = interaction;
        _clientStore = clientStore;
        _events = events;
      
        _userManager = userManager;
        _signInManager = signInManager;
      }
      
    • External login callback

        // ExternalController.cs
        [HttpGet]
        public async Task<IActionResult> Callback()
        {
          // read external identity from the temporary cookie
          var result = await HttpContext.AuthenticateAsync(IdentityServer4.IdentityServerConstants.ExternalCookieAuthenticationScheme);
          if (result?.Succeeded != true)
          {
            throw new Exception("External authentication error");
          }
      
          var extPrincipal = result.Principal;
          var expProperties = result.Properties;
          var claims = extPrincipal.Claims.ToList();
      
          var userIdClaim = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Subject);
          if (userIdClaim == null)
          {
            userIdClaim = claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier);
          }
          if (userIdClaim == null)
          {
            throw new Exception("Unknown userid");
          }
      
          claims.Remove(userIdClaim);
          var provider = expProperties.Items["scheme"];
          var userId = userIdClaim.Value;
      
          var user = await _userManager.FindByLoginAsync(provider, userId);
          if (user == null)
          {
                        // Register if user doesn't exist
            var candidateId = Guid.NewGuid();
            user = new ApplicationUser();
            var emailClaim = claims.FirstOrDefault(x => x.Type == ClaimTypes.Email);
            if (emailClaim != null)
            {
              user.UserName = emailClaim.Value;
              user.Email = emailClaim.Value;
              user.EmailConfirmed = true;
            }
            var firstNameClaim = claims.FirstOrDefault(x => x.Type == ClaimTypes.GivenName);
            if (firstNameClaim != null)
            {
              user.FirstName = firstNameClaim.Value;
            }
            var lastNameClaim = claims.FirstOrDefault(x => x.Type == ClaimTypes.Surname);
            if (lastNameClaim != null)
            {
              user.LastName = lastNameClaim.Value;
            }
            user.UserType = EnumUserType.Audience;
            var createResult = await _userManager.CreateAsync(user);
            if (createResult.Succeeded)
            {
              var loginResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(provider, userId, provider));
              if (loginResult.Succeeded)
              {
                // Update Tokens to AspNet Identity
                var externalLoginInfo = new ExternalLoginInfo(extPrincipal, provider, userId, provider)
                {
                  AuthenticationTokens = expProperties.GetTokens()
                };
                await _signInManager.UpdateExternalAuthenticationTokensAsync(externalLoginInfo);
              }
            }
          }
          ......
        }
      

      External Login

  4. Display user information in WebUI

    • Update welcome message in nav-menu component

      <pre class="wp-block-code"><code>// nav-menu.component.html
      <div class='main-nav'>
        <div class='navbar navbar-inverse'>
          <div class='navbar-header'>
        ......
        <a class='navbar-brand' [routerLink]='["/"]'>Welcome  </a>
          </div>
          ......
        </div>
      </div>
      
      // nav-menu.component.ts
      import { Component } from '@angular/core';
      import { UserProfileService } from '../userprofile/userprofile.service';
      
      @Component({
        selector: 'app-nav-menu',
        templateUrl: './nav-menu.component.html',
        styleUrls: ['./nav-menu.component.css']
      })
      export class NavMenuComponent {
        isExpanded = false;
        firstName: string = '';
        lastName: string = '';
        constructor(
          private _userProfileService: UserProfileService
        ) {
          var that = this;
          this._userProfileService.identityClaimsReady.subscribe(function (claims) {
            if (claims) {
              that.firstName = claims["FirstName"];
              that.lastName = claims["LastName"];
            }
          });
        }
        ......
      }
      
    • Send claims to subscribers

      // app.component.ts
      ......
      import { UserProfileService } from './userprofile/userprofile.service';
      
      @Component({
        selector: 'app-root',
        templateUrl: './app.component.html',
        styleUrls: ['./app.component.css']
      })
      export class AppComponent implements OnInit {
        title = 'app';
        constructor(
          @Inject(PLATFORM_ID) private platformId: Object,
          private _oauthService: OAuthService,
          private _userProfileService: UserProfileService
        ) {
          ......
        }
        /**
          * On init
          */
        ngOnInit(): void {
          if (isPlatformBrowser(this.platformId)) {
            this._oauthService.loadDiscoveryDocumentAndTryLogin().then(_ => {
              if (!this._oauthService.hasValidIdToken() || !this._oauthService.hasValidAccessToken()) {
                this._oauthService.initImplicitFlow();
              } else {
                this._userProfileService.onIdentityClaimsReadyChanged(this._oauthService.getIdentityClaims());
              }
            });
          }
        }
      }
      
    • Add userprofile.service.ts to observe claim changes

      import { Injectable } from '@angular/core';
      import { Subject } from 'rxjs';
      
      @Injectable()
      export class UserProfileService {
        identityClaimsReady: Subject<any> = new Subject();
      
        onIdentityClaimsReadyChanged(claims): void {
          this.identityClaimsReady.next(claims);
        }
      }
      
  5. Include FirstName and LastName in Claims from IdentityServer4

     // ProfileService.cs
     using IdentityServer4.Models;
     using IdentityServer4.Services;
     using JayCoder.MusicStore.Core.Domain.SQLEntities;
     using Microsoft.AspNetCore.Identity;
     using System.Collections.Generic;
     using System.Security.Claims;
     using System.Threading.Tasks;
    
     namespace JayCoder.MusicStore.Projects.IdentityServer.Profile
     {
         public class ProfileService : IProfileService
         {
             protected UserManager<ApplicationUser> _userManager;
    
             public ProfileService(UserManager<ApplicationUser> userManager)
             {
                 _userManager = userManager;
             }
    
             public Task GetProfileDataAsync(ProfileDataRequestContext context)
             {
                 var user = _userManager.GetUserAsync(context.Subject).Result;
                 if (user != null)
                 {
                     var claims = new List<Claim>();
                     claims.Add(new Claim("FirstName", string.IsNullOrEmpty(user.FirstName) ? string.Empty : user.FirstName));
                     claims.Add(new Claim("LastName", string.IsNullOrEmpty(user.LastName) ? string.Empty : user.LastName));
                     context.IssuedClaims.AddRange(claims);
                 }
                 return Task.FromResult(0);
             }
             ......
         }
     }
    

    NOTE: In order to access user claims from Angular, AlwaysIncludeUserClaimsInIdToken needs to be true when you register Client in IdentityServer4.

  6. Test

    Login from my Microsoft account and be able to access values API Test Microsoft Account Login

Sitecore - fieldNameTranslator null exception

After finishing the secondment to innovation team, I had to re-configure my local Sitecore environment and upgrade SXA from 1.3 to 1.7 which had been done by others when I was away.

After getting the latest code from VSTS and restoring the database, I got this welcome message:

Server Error in '/' Application. 

A route named 'MS_attributerouteWebApi' is already in the route collection. Route names must be unique. Parameter name: name Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code.

Exception Details: System.ArgumentException: A route named 'MS_attributerouteWebApi' is already in the route collection. Route names must be unique. Parameter name: name

Source Error:

An unhandled exception was generated during the execution of the current web request. Information regarding the origin and location of the exception can be identified using the exception stack trace below.

Stack Trace:

[ArgumentException: A route named 'MS_attributerouteWebApi' is already in the route collection. Route names must be unique. Parameter name: name] System.Web.Routing.RouteCollection.Add(String name, RouteBase item) +391
System.Web.Http.Routing.AttributeRoutingMapper.MapAttributeRoutes(HttpConfiguration configuration, IInlineConstraintResolver constraintResolver, IDirectRouteProvider directRouteProvider) +166
Sitecore.Services.Infrastructure.Web.Http.HttpConfigurationBuilder.MapHttpAttributeRoutes() +89 Sitecore.Services.Infrastructure.Web.Http.ServicesConfigurator.Configure(HttpConfiguration config, RouteCollection routes) +501
Sitecore.Services.Infrastructure.Sitecore.Pipelines.ServicesWebApiInitializer.Process(PipelineArgs args) +194 (Object , Object[] ) +74
Sitecore.Pipelines.CorePipeline.Run(PipelineArgs args) +469
Sitecore.Pipelines.DefaultCorePipelineManager.Run(String pipelineName, PipelineArgs args, String pipelineDomain) +22
Sitecore.Nexus.Web.HttpModule.Application_Start() +161
Sitecore.Nexus.Web.HttpModule.Init(HttpApplication app) +764
System.Web.HttpApplication.RegisterEventSubscriptionsWithIIS(IntPtr appContext, HttpContext context, MethodInfo[] handlers) +581
System.Web.HttpApplication.InitSpecial(HttpApplicationState state, MethodInfo[] handlers, IntPtr appContext, HttpContext context) +172
System.Web.HttpApplicationFactory.GetSpecialApplicationInstance(IntPtr appContext, HttpContext context) +418
System.Web.Hosting.PipelineRuntime.InitializeApplication(IntPtr appContext) +369

[HttpException (0x80004005): A route named 'MS_attributerouteWebApi' is already in the route collection. Route names must be unique. Parameter name: name]
System.Web.HttpRuntime.FirstRequestInit(HttpContext context) +534
System.Web.HttpRuntime.EnsureFirstRequestInit(HttpContext context) +111 System.Web.HttpRuntime.ProcessRequestNotificationPrivate(IIS7WorkerRequest wr, HttpContext context) +718

Luckily, I got WinMerge. After comparing the working version, I realized someone accidentally created a global.asax and overwrote the default one.

Then I managed to login by deleting that asax file. However, when I clicked on tree nodes in Content Editor, I got exception again:

Server Error in '/' Application.

Value cannot be null.
Parameter name: fieldNameTranslator
Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code. 

Exception Details: System.ArgumentNullException: Value cannot be null.
Parameter name: fieldNameTranslator

Source Error: 

An unhandled exception was generated during the execution of the current web request. Information regarding the origin and location of the exception can be identified using the exception stack trace below.

Stack Trace: 
[ArgumentNullException: Value cannot be null.
Parameter name: fieldNameTranslator]
   Sitecore.ContentSearch.Linq.Solr.SolrIndexParameters..ctor(IIndexValueFormatter valueFormatter, IFieldQueryTranslatorMap`1 fieldQueryTranslators, FieldNameTranslator fieldNameTranslator, IExecutionContext[] executionContexts, IFieldMapReaders fieldMap, Boolean convertQueryDatesToUtc) +328
   Sitecore.ContentSearch.SolrProvider.LinqToSolrIndex`1..ctor(SolrSearchContext context, IExecutionContext[] executionContexts) +188
   Sitecore.ContentSearch.SolrProvider.SolrSearchContext.GetQueryable(IExecutionContext[] executionContexts) +268
   Sitecore.Social.Search.SearchProvider.SearchItems(Expression`1 whereExpression, Func`2 selector) +164
   Sitecore.Social.Search.SearchProvider.GetMessagesByContainer(String container) +492
   Sitecore.Social.MessageBusinessManager.SearchMessagesByContainer(String container) +203
   Sitecore.Social.MessageBusinessManager.GetMessagesCount(String container) +16
   Sitecore.Social.Client.MessagePosting.Commands.SocialCenter.RunGetHeader(CommandContext context, String header) +342
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.FillParamsFromCommand(CommandContext commandContext, RibbonCommandParams ribbonCommandParams) +98
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.GetCommandParameters(Item controlItem, CommandContext commandContext) +79
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderLargeButton(HtmlTextWriter output, Item button, CommandContext commandContext) +78
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderButton(HtmlTextWriter output, Item button, CommandContext commandContext) +440
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderChunk(HtmlTextWriter output, Item chunk, CommandContext commandContext) +342
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderChunk(HtmlTextWriter output, Item chunk, CommandContext commandContext, Boolean isContextual, String id) +244
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderChunk(HtmlTextWriter output, Item chunk, CommandContext commandContext, Boolean isContextual) +161
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderChunks(HtmlTextWriter output, Item strip, CommandContext commandContext, Boolean isContextual) +445
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderStrips(HtmlTextWriter output, Item ribbon, Boolean isContextual, ListString visibleStripList) +1613
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.RenderStrips(HtmlTextWriter output, Item defaultRibbon, Item contextualRibbon, ListString visibleStripList) +162
   Sitecore.Web.UI.WebControls.Ribbons.Ribbon.Render(HtmlTextWriter output) +747
   System.Web.UI.Control.RenderControlInternal(HtmlTextWriter writer, ControlAdapter adapter) +79
   Sitecore.Web.HtmlUtil.RenderControl(Control ctl) +80
   Sitecore.Shell.Applications.ContentManager.ContentEditorForm.UpdateRibbon(Item folder, Boolean isCurrentItemChanged, Boolean showEditor) +502
   Sitecore.Shell.Applications.ContentManager.ContentEditorForm.Update() +582
   Sitecore.Shell.Applications.ContentManager.ContentEditorForm.OnPreRendered(EventArgs e) +205

[TargetInvocationException: Exception has been thrown by the target of an invocation.]
   System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor) +0
   System.Reflection.RuntimeMethodInfo.UnsafeInvokeInternal(Object obj, Object[] parameters, Object[] arguments) +128
   System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture) +142
   Sitecore.Reflection.ReflectionUtil.InvokeMethod(MethodInfo method, Object[] parameters, Object obj) +89
   Sitecore.Shell.Applications.ContentManager.ContentEditorPage.OnPreRender(EventArgs e) +143
   System.Web.UI.Control.PreRenderRecursiveInternal() +162
   System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint) +6875

And some warnings from crawling log:

18828 22:59:24 WARN  Failed to initialize 'sitecore_marketing_asset_index_master' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'sitecore_marketing_asset_index_web' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'sitecore_marketingdefinitions_master' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'sitecore_marketingdefinitions_web' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'sitecore_testing_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'sitecore_suggested_test_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'sitecore_fxm_master_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'sitecore_fxm_web_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'sitecore_list_index' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'social_messages_master' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE
18828 22:59:24 WARN  Failed to initialize 'social_messages_web' index. Registering the index for re-initialization once connection to SOLR becomes available ...
18828 22:59:24 WARN  DONE

It’s because I didn’t create those indexes yet:

  • sitecore_marketing_asset_index_master
  • sitecore_marketing_asset_index_web
  • ……

Finally, Sitecore started working after adding those indexes in Solr configuration, hooray!

MusicStore - Part3 - EF to SQL Server

In MusicStore - Part1 - Init, in memory resources and test users had been used for test purpose.

// Startup.cs in IdentityServer
public void ConfigureServices(IServiceCollection services)
{
    ......
    services.AddIdentityServer()
	.AddDeveloperSigningCredential()
	.AddInMemoryIdentityResources(Resources.GetIdentityResources())
	.AddInMemoryApiResources(Resources.GetApiResources())
	.AddInMemoryClients(new PortalClientFactory(Configuration).GetClients())
	.AddTestUsers(TestUsers.Users);
    ......
}

In real world, credentials always be stored in database. Thus, I will use SQL Server + Entity Framework to replace in memory resources.

  1. Create a new database called MusicStore_IdentityServer

     // appsettings.Development.json
     "ConnectionStrings": {
         "IdentityServer4Connection": "Data Source=.;Initial Catalog=MusicStore_IdentityServer;Integrated Security=False;User ID=musicstore_user;Password=P@ssword!@#$;"
     }
    
  2. Migrate resources to SQL Server

    • Run CMD and navigate to Projects > IdentityServer folder
    • Use EF Code First to migrate PersistedGrantDb schema to C# script

        dotnet ef migrations add InitialIdentityServerPersistedGrantDbMigration -c PersistedGrantDbContext -o Migrations/IdentityServer/PersistedGrantDb
      
    • Use EF Code First to migrate ConfigurationDb schema to C# script

        dotnet ef migrations add InitialIdentityServerConfigurationDbMigration -c ConfigurationDbContext -o Migrations/IdentityServer/ConfigurationDb
      
    • Import EF scripts to database in IdentityServer start-up
        // Startup.cs
        public void Configure(IApplicationBuilder app)
        {
            // Init Database using EF Code First
            InitializeDatabase(app);
            app.UseStaticFiles();
            app.UseIdentityServer();
            app.UseMvcWithDefaultRoute();
        }
      
        private void InitializeDatabase(IApplicationBuilder app)
        {
            using (var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope())
            {
                //PersistedGrant DB Context
                var persistedGrantContext = serviceScope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>();
                persistedGrantContext.Database.Migrate();
                //Configuration DB Context
                var configContext = serviceScope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
                configContext.Database.Migrate();
                if (!configContext.Clients.Any())
                {
                    var clientList = new PortalClientFactory(Configuration).GetClients();
                    if (clientList != null &amp;&amp; clientList.Any())
                    {
                        foreach (var client in clientList)
                        {
                            configContext.Clients.Add(client.ToEntity());
                        }
                        configContext.SaveChanges();
                    }
                }
      
                if (!configContext.IdentityResources.Any())
                {
                    foreach (var resource in Resources.GetIdentityResources())
                    {
                        configContext.IdentityResources.Add(resource.ToEntity());
                    }
                    configContext.SaveChanges();
                }
      
                if (!configContext.ApiResources.Any())
                {
                    foreach (var resource in Resources.GetApiResources())
                    {
                        configContext.ApiResources.Add(resource.ToEntity());
                    }
                    configContext.SaveChanges();
                }
            }
        }
      
    • Run IdentityServer project
    • Check if database tables are auto generated
    • Check if clients are imported correctly

      SQL Select Clients

  3. Integrate ASP.NET Identity
    • Create a new .NET Standard project under Core folder called Domain
    • Create a new class “ApplicationUser” to inherit from IdentityUser
        public class ApplicationUser : IdentityUser
        {
            public string FirstName { get; set; }
            public string LastName { get; set; }
            public DateTime DOB { get; set; }
            public int Gender { get; set; }
            public string Avatar { get; set; }
            public int Language { get; set; }
            public bool Enabled { get; set; }
            public EnumUserType UserType { get; set; }
        }
      
    • Create a new .NET Standard project under Foundations folder called SQLServer
    • Create a new class “AspNetIdentityDbContext” to inherit from IdentityDbContext

        public class AspNetIdentityDbContext : IdentityDbContext<ApplicationUser>
        {
            ......
        }
      
    • Run CMD and navigate to Foundations > SQLServer
    • Use EF Code First to migrate AspNetIdentityDbContext schema to C# script

        dotnet ef migrations add InitialAspNetIdentityDbMigration -c AspNetIdentityDbContext -o Migrations/AspNetIdentityDb -s ../../Projects/IdentityServer
      

      NOTE: As SQLServer is not a executable project, it needs to refer to a startup project (--startup-project or -s)

    • Import EF scripts for AspNet Identity

      Append this code snip to InitializeDatabase method

        // AspNet Identity DB Context
        var idContext = serviceScope.ServiceProvider.GetRequiredService<AspNetIdentityDbContext>();
        if (!idContext.Users.Any())
        {
            var userManager = serviceScope.ServiceProvider.GetService<UserManager<ApplicationUser>>();
            foreach (var user in Resources.GetApplicationUsers())
            {
                var result1 = userManager.CreateAsync(user , "Test123!").Result;
            }
        }
        idContext.Database.Migrate();
      
    • Run IdentityServer project
    • Check if database tables and fields are auto generated

      ASP.NET Identity

  4. Replace in memory resources

    • Init DbContext ConnectionString

        //Startup.cs
        public void ConfigureServices(IServiceCollection services)
        {
                ......
            var connectionString = Configuration.GetConnectionString("IdentityServer4Connection");
            // Init DbContext ConnectionString
            var aspnetIdentityAssembly = typeof(AspNetIdentityDbContext).GetTypeInfo().Assembly.GetName().Name;
            services.AddDbContext<AspNetIdentityDbContext>(options =>
                options.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(aspnetIdentityAssembly))
            );
                ......
        }
      
    • Authenticate via AspNet Identity

        //Startup.cs
        public void ConfigureServices(IServiceCollection services)
        {
            ......
            // Apply AspNetIdentity as default token provider
            // UserManager can be used to manage users
            services.AddIdentity<ApplicationUser, IdentityRole>(options =>
            {
                // Password settings
                options.Password.RequireDigit = true;
                options.Password.RequiredLength = 8;
                options.Password.RequireLowercase = true;
                options.Password.RequireNonAlphanumeric = true;
                options.Password.RequireUppercase = true;
                // User settings
                options.User.RequireUniqueEmail = true;
            })
            .AddEntityFrameworkStores<AspNetIdentityDbContext>()
            .AddDefaultTokenProviders();
        }
      
    • Load resources from SQL Server

      
        //Startup.cs
        public void ConfigureServices(IServiceCollection services)
        {
            ......
            // Load resources from DB
            var identityServerAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;
            var identityServer = services
                .AddIdentityServer()
                .AddAspNetIdentity<ApplicationUser>()
                // this adds the config data from DB (clients, resources, CORS)
                .AddConfigurationStore(options =>
                {
                    options.ConfigureDbContext = builder =>
                        builder.UseSqlServer(connectionString,
                            sql => sql.MigrationsAssembly(identityServerAssembly));
                })
                // this adds the operational data from DB (codes, tokens, consents)
                .AddOperationalStore(options =>
                {
                    options.ConfigureDbContext = builder =>
                        builder.UseSqlServer(connectionString,
                            sql => sql.MigrationsAssembly(identityServerAssembly));
                    // this enables automatic token cleanup. this is optional.
                    options.EnableTokenCleanup = true;
                    // options.TokenCleanupInterval = 15; // interval in seconds. 15 seconds useful for debugging
                });
                ......
        }
      

      NOTE: There are 2 migrations from 2 projects - IdentityServer and SQLServer, in order to set the connection string, the assembly name needs to be specified as the second parameter to identify where the context comes from. For example:

        services.AddDbContext<AspNetIdentityDbContext>(options =>
            options.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(aspnetIdentityAssembly))
        );
      
  5. Test

    Use either bob / Test123$ or alice / Test123$ to login, new password “Test123$” has been applied because of the password settings in #4

MusicStore - Part2 - Link three parties

Register Client in IdentityServer

"Clients": [
    {
      "ClientId": "portal-client",
      "ClientName": "MusicStore Portal Client",
      "ClientUri": "http://localhost:30002",
      "RequireConsent": false,
      "AllowedGrantTypes": "implicit",
      "AllowAccessTokensViaBrowser": true,
      "RedirectUris": "http://localhost:30002",
      "PostLogoutRedirectUris": "http://localhost:30002/loggedout",
      "AllowedCorsOrigins": "http://localhost:30002",
      "AllowedScopes": "openid;profile;email;musicstore-api",
      "AlwaysIncludeUserClaimsInIdToken": true,
      "IdentityTokenLifetime": 3600,
      "AccessTokenLifetime": 3600
    }
  ]
  1. Create a new Client called “portal-client”

    • ClientUri => WebUI Uri
    • RequireConsent => false means disable consent dialog after login
    • AllowedGrantTypes => Grant type of SPA client needs to be implicit
    • RedirectUris => Redirect Uri after login, id_token will be appended
    • PostLogoutRedirectUris => Redirect Uri after logging out
    • AllowedCorsOrigins => WebUI Uri
    • AllowedScopes => API scopes can be consumed for this client
  2. Register musicstore-api

     // Configuration/Resources.cs
     public static IEnumerable<ApiResource> GetApiResources()
     {
         return new[]
         {
             ......
             new ApiResource("musicstore-api", "MusicStore API")
         };
     }
    

Authenticate WebAPI from IdentityServer

  1. Add Authentication from IdentityServer
  2. Add CORS to allow origins from WebUI

     // Startup.cs
     public void ConfigureServices(IServiceCollection services)
     {
       services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
             // Authenticated by IdentityServer
       services
         .AddAuthentication("Bearer")
         .AddIdentityServerAuthentication(options =>
         {
           options.Authority = Configuration.GetValue<string>("Authority");
           options.RequireHttpsMetadata = Configuration.GetValue<bool>("RequireHttpsMetadata");
           options.ApiName = Configuration.GetValue<string>("ApiName");
         });
             // CORS Policy
       string strOrigionList = Configuration.GetValue<string>("AllowedOrigions");
       if (!string.IsNullOrEmpty(strOrigionList))
       {
         services.AddCors(options =>
         {
           // this defines a CORS policy called "default"
           options.AddPolicy("default", policy =>
           {
             policy.WithOrigins(strOrigionList.Split(new string[] { ";" }, StringSplitOptions.RemoveEmptyEntries))
               .AllowAnyHeader()
               .AllowAnyMethod();
           });
         });
       }
     }
    
     // Startup.cs
     public void Configure(IApplicationBuilder app, IHostingEnvironment env)
     {
       if (env.IsDevelopment())
       {
         app.UseDeveloperExceptionPage();
       }
       app.UseCors("default");
       app.UseAuthentication();
    
       app.UseMvc();
     }
    
     // appsettings.Development.json
     {
       "Logging": {
         "LogLevel": {
           "Default": "Debug",
           "System": "Information",
           "Microsoft": "Information"
         }
       },
       // Authentication - IdentityServer
       "Authority": "http://localhost:30000",
       "RequireHttpsMetadata": false,
       "ApiName": "musicstore-api",
       // CORS - Allow WebUI
       "AllowedOrigions": "http://localhost:30002"
     }
    
  3. Disable anonymous access to values controller

     // ValuesController.cs
     [Authorize]
     [Route("api/[controller]")]
     [ApiController]
     public class ValuesController : ControllerBase
     {
         ......
     }
    
  4. Test from Postman
    • Select “TYPE” as “OAuth 2.0” in Authorization tab
    • Click “Get New Access Token”
    • Fill in the Client info and click “Request Token”

      Postman Get JWT Token

    • Username / Password - bob / bob or alice / alice
    • After getting the JWT token, attach it to Header and fire “GET” request to http://localhost:30001/api/values will return [“value1”, “value2”]

      Postman Get Values

Consume WebAPI from WebUI

  1. Install OIDC to bridge WebUI and IdentityServer

     npm install angular-oauth2-oidc@^3 --save
    

    NOTE: As our WebUI utilized Angular 5.2, it could not be compatible with the latest oidc (version 4), otherwise, it will throw this error:
    ERROR TypeError: Object(…) is not a function

  2. Apply OIDC

    • Update AppModule to include OAuthModule
      @NgModule({
        declarations: [
          ......
        ],
        imports: [
          ......
          OAuthModule.forRoot()
        ],
        providers: [
          {
            provide: HTTP_INTERCEPTORS,
            useClass: TokenInterceptor,
            multi: true,
          }
        ]
        bootstrap: [AppComponent]
      })
      export class AppModule { }
      
    • Add AuthConfig

      export const authConfig: AuthConfig = {
        // Url of the Identity Provider
        issuer: environment.identityServerUrl,
      
        // URL of the SPA to redirect the user to after login
        redirectUri: window.location.origin,
      
        // The SPA's id. The SPA is registerd with this id at the auth-server
        clientId: 'portal-client',
      
        // set the scope for the permissions the client should request
        // The first three are defined by OIDC. The 4th is a usecase-specific one
        scope: 'openid profile email ' + environment.apiScope,
        postLogoutRedirectUri: environment.identityServerUrl + '/account/login'
      }
      

      NOTE: AuthConfig needs to be consistent with the client registered in IdentityServer e.g. clientId, scope.

    • Environment configuration
      // environment.ts
      export const environment = {
        production: false,
      
        apiScope: "musicstore-api",
        apiUrl: 'http://localhost:30001/api/',
        identityServerUrl: 'http://localhost:30000'
      };
      
      // environment.prod.ts
      export const environment = {
        production: true,
      
        apiScope: "musicstore-api",
        apiUrl: 'http://api.jaycoder.net/api/',
        identityServerUrl: 'https://id.jaycoder.net'
      };
      

      NOTE: environment.ts has the configuration for development environment, while environment.prod.ts is for production, environment.ts will be replaced by environment.prod.ts if env argument is prod at build stage.

      ng build --env=prod
      
    • Initialize OAuthService in AppComponent

      Token will be validated in ngOnInit method, if it’s expired, initImplicitFlow will redirect to IdentityServer login to reissue a new one.

      export class AppComponent {
        title = 'app';
        constructor(
          @Inject(PLATFORM_ID) private platformId: Object,
          private _oauthService: OAuthService
        ) {
          if (isPlatformBrowser(this.platformId)) {
            this._oauthService.configure(authConfig);
            this._oauthService.tokenValidationHandler = new JwksValidationHandler();
                  
            if (!environment.production) {
              this._oauthService.events.subscribe(e => {
                console.log("oauth/oidc event", e);
              });
            }
          }
        }
        /**
          * On init
          */
        ngOnInit(): void {
          if (isPlatformBrowser(this.platformId)) {
            this._oauthService.loadDiscoveryDocumentAndTryLogin().then(_ => {
              if (!this._oauthService.hasValidIdToken() || !this._oauthService.hasValidAccessToken()) {
                this._oauthService.initImplicitFlow();
              } else {
      
              }
            });
          }
        }
      }
      
  3. Add a new component “Sample” to call /api/values
      export class SampleComponent {
     public values: string[];
    
     constructor(http: HttpClient) {
       var valuesApiUrl = environment.apiUrl + 'values';
       http.get<string[]>(valuesApiUrl).subscribe(result => {
         this.values = result;
       }, error => console.error(error));
     }
      }
    
  4. Add TokenInterceptor to append Bearer Authorization in header
     export class TokenInterceptor implements HttpInterceptor {
    
         private _authService: OAuthService;
    
         // Would like to inject authService directly but it causes a cyclic dependency error
         // see https://github.com/angular/angular/issues/18224
         constructor(private _injector: Injector) { }
    
         intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
             if (this.getAuthService().hasValidAccessToken()) {
                 request = request.clone({
                     // Remove cache in IE
                     headers: request.headers.set('Cache-Control', 'no-cache')
                         .set('Pragma', 'no-cache')
                         .set('Expires', 'Sat, 01 Jan 1900 00:00:00 GMT')
                         .set('Authorization', 'Bearer ' + this.getAuthService().getAccessToken())
                 });
             }
             return next.handle(request);
         }
    
         getAuthService(): OAuthService {
             if (typeof this._authService === 'undefined') {
                 this._authService = this._injector.get(OAuthService);
             }
             return this._authService;
         }
     }
    

    NOTE: TokenInterceptor needs to be registered as a provider in AppModule.

Test

MusicStore Test Sample

MusicStore - Part1 - Init

Let’s start building a platform where musicians can sell their albums online.

Code has been uploaded to github https://github.com/jay-coder/MusicStore

What technologies are we going to use:

  • WebUI - Angular 5.2 SPA application hosted on .NET Core 2.1
  • WebAPI - .NET Core 2.1 API
  • IdentityServer4 - .NET Core 2.1 Web Application

Identity Server 4 is an OpenID Connect and OAuth 2.0 framework which can be used to manage tokens that being used between WebUI and WebAPI.

Here is the diagram of the solution: MusicStore Solution Diagram

  1. WebUI will ask user to login
  2. After login successfully, IdentityServer will issue JWT token back to WebUI
  3. WebUI will consume API resources with token attached in header
  4. WebAPI will talk to IdentityServer to validate token
  5. If token is valid, WebAPI will return JSON data back to WebUI

Setup WebUI project

MusicStore Angular App

In Properties > launchSettings.json, update the applicationUrl port to 30002

  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:30002",
      "sslPort": 0
    }
  }

Setup WebAPI project

MusicStore API App

In Properties > launchSettings.json, update the applicationUrl port to 30001

  "iisSettings": {
    "windowsAuthentication": false, 
    "anonymousAuthentication": true, 
    "iisExpress": {
      "applicationUrl": "http://localhost:30001",
      "sslPort": 0
    }
  },

Setup IdentityServer project

Clone IdentityServer from its official github:

git clone https://github.com/IdentityServer/IdentityServer4.Quickstart.UI

In Properties > launchSettings.json, update the applicationUrl port to 30000

  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:30000",
      "sslPort": 0
    }
  },

Why set port over 30000

When we deploy web applications to local Kubernetes in the future, we can only access pod by port-forward to NodePort, which only allows port 30000 to 32767. To make local dev URL consistent with Kubernetes, I would set URL port between 30000 and 32767.

kubectl port-forward musicstore-project-webui-65c8bcb489-lhjcr 30002:80

Run these projects

  1. WebUI - should see Hello, world! MusicStore Web UI

  2. WebAPI - should see the default values API MusicStore Web API

  3. IdentityServer - should see welcome page MusicStore Identity Server

    Click “here” link to login to IdentityServer, username and password can be:

    • bob / bob
    • alice / alice

    NOTE: I added test users instead of retrieving users from database as of now, I will add database support in MusicStore - Part3 - EF to SQL Server.

     public void ConfigureServices(IServiceCollection services)
     {
         services.AddMvc();
         services.AddIdentityServer()
             .AddDeveloperSigningCredential()
             .AddInMemoryIdentityResources(Resources.GetIdentityResources())
             .AddInMemoryApiResources(Resources.GetApiResources())
             .AddInMemoryClients(Clients.Get())
             .AddTestUsers(TestUsers.Users);
     }
    
  4. Enable Swagger to WebAPI

    Swagger is a great tool that can help developers test their Restful API quickly, for example, we can easily send Get, Post, Put, Delete request to Restful API like Postman.

    • Add Nuget Package (Swashbuckle.AspNetCore)
    • Use Swagger in Startup.cs
        // Startup.cs
        public void ConfigureServices(IServiceCollection services)
        {
          ......
          // Add Swagger
          services.AddSwaggerGen(c =>
          {
            c.SwaggerDoc("v1", new Info { Title = "MusicStore API", Version = "v1" });
          });
          ......
        }
      
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
          ......
          // Use Swagger
          app.UseSwagger();
          app.UseSwaggerUI(c =>
          {
            c.SwaggerEndpoint("/swagger/v1/swagger.json", "MusicStore API V1");
          });
        }
      
    • Change launch URL to swagger

        // launchSettings.json
        {
          "$schema": "http://json.schemastore.org/launchsettings.json",
          "iisSettings": {
            "windowsAuthentication": false, 
            "anonymousAuthentication": true, 
            "iisExpress": {
              "applicationUrl": "http://localhost:30001",
              "sslPort": 0
            }
          },
          "profiles": {
            "IIS Express": {
              "commandName": "IISExpress",
              "launchBrowser": true,
              "launchUrl": "swagger",
              "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Development"
              }
            },
            "WebAPI": {
              "commandName": "Project",
              "launchBrowser": true,
              "launchUrl": "swagger",
              "applicationUrl": "http://localhost:5001",
              "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Development"
              }
            }
          }
        }
      

    Swagger UI once configured correctly MusicStore Swagger UI