Angular SPA基於Ocelot API閘道器與IdentityServer4的身份認證與授權(一)

語言: CN / TW / HK

好吧,這個題目我也想了很久,不知道如何用最簡單的幾個字來概括這篇文章,原本打算取名《Angular單頁面應用基於Ocelot API閘道器與IdentityServer4+ASP.NET Identity實現身份認證與授權》,然而如你所見,這樣的名字實在是太長了。所以,我不得不縮寫“單頁面應用”幾個字,然後去掉ASP.NET Identity的描述,最後形成目前的標題。

不過,這也就意味著這篇文章會涵蓋很多內容和技術,我會利用這些技術來走通一個完整的流程,這個流程也代表著在微服務架構中單點登入的一種實現模式。在此過程中,我們會使用到如下技術或框架:

  • Angular 8
  • Ocelot API Gateway
  • IdentityServer4
  • ASP.NET Identity
  • Entity Framework Core
  • SQL Server

本文假設讀者具有上述技術框架的基礎知識。由於內容比較多,我還是將這篇文章分幾個部分進行講解和討論。

場景描述

在微服務架構下的一種比較流行的設計,就是基於前後端分離,前端只做呈現和使用者操作流的管理,後端服務由API網關同一協調,以從業務層面為前端提供各種服務。大致可以用下圖表示:

在這個結構中,我沒有將Identity Service放在API Gateway後端,因為考慮到Identity Service本身並沒有承擔任何業務功能。從它所能提供的端點(Endpoint)的角度,它也需要做負載均衡、熔斷等保護,但我們暫時不討論這些內容。

流程上其實也比較簡單,在上圖的數字標識中:

  1. Client向Identity Service傳送認證請求,通常可以是使用者名稱密碼
  2. 如果驗證通過,Identity Service會向Client返回認證的Token
  3. Client使用Token向API Gateway傳送API呼叫請求
  4. API Gateway將Client傳送過來的Token傳送給Identity Service,以驗證Token的有效性
  5. 如果驗證成功,Identity Service會告知API Gateway認證成功
  6. API Gateway轉發Client的請求到後端API Service
  7. API Service將結果返回給API Gateway
  8. API Gateway將API Service返回的結果轉發到Client

只是在這些步驟中,我們有很多技術選擇,比如Identity Service的實現方式、認證方式等等。接下來,我就在ASP.NET Core的基礎上使用IdentityServer4、Entity Framework Core和Ocelot來完成這一流程。在完成整個流程的演練之前,需要確保機器滿足以下條件:

  • 安裝Visual Studio 2019 Community Edition。使用Visual Studio Code也是可以的,根據自己的需要選擇
  • 安裝Visual Studio Code
  • 安裝Angular 8

IdentityServer4結合ASP.NET Identity實現Identity Service

建立新專案

首先第一步就是實現Identity Service。在Visual Studio 2019 Community Edition中,新建一個ASP.NET Core Web Application,模板選擇Web Application (Model-View-Controller),然後點選Authentication下的Change按鈕,再選擇Individual User Accounts選項,以便將ASP.NET Identity的依賴包都加入專案,並且自動完成基礎程式碼的搭建。

然後,通過NuGet新增IdentityServer4.AspNetIdentity以及IdentityServer4.EntityFramework的引用,IdentityServer4也隨之會被新增進來。接下來,在該專案的目錄下,執行以下命令安裝IdentityServer4的模板,並將IdentityServer4的GUI加入到當前專案:

dotnet new -i identityserver4.templates
dotnet new is4ui --force

然後調整一下專案結構,將原本的Controllers目錄刪除,同時刪除Models目錄下的ErrorViewModel類,然後將Quickstart目錄重新命名為Controllers,編譯程式碼,程式碼應該可以編譯通過,接下來就是實現我們自己的Identity。

定製Identity Service

為了能夠展現一個標準的應用場景,我自己定義了User和Role物件,它們分別繼承於IdentityUser和IdentityRole類:

public class AppUser : IdentityUser
{
    public string DisplayName { get; set; }
}

public class AppRole : IdentityRole
{
    public string Description { get; set; }
}

當然,Data目錄下的ApplicationDbContext也要做相應調整,它應該繼承於IdentityDbContext<AppUser, AppRole, string>類,這是因為我們使用了自定義的IdentityUser和IdentityRole的實現:

public class ApplicationDbContext : IdentityDbContext<AppUser, AppRole, string>
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }
}

之後修改Startup.cs裡的ConfigureServices方法,通過呼叫AddIdentity、AddIdentityServer以及AddDbContext,將ASP.NET Identity、IdentityServer4以及儲存認證資料所使用的Entity Framework Core的依賴全部註冊進來。為了測試方便,目前我們還是使用Developer Signing Credential,對於Identity Resource、API Resource以及Clients,我們也是暫時先寫死(hard code):

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<ApplicationDbContext>(options =>
        options.UseSqlServer(
            Configuration.GetConnectionString("DefaultConnection")));
    services.AddIdentity<AppUser, AppRole>()
        .AddEntityFrameworkStores<ApplicationDbContext>()
        .AddDefaultTokenProviders();
    services.AddIdentityServer().AddDeveloperSigningCredential()
      .AddOperationalStore(options =>
      {
          options.ConfigureDbContext = builder => builder.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"),
              sqlServerDbContextOptionsBuilder =>
              sqlServerDbContextOptionsBuilder.MigrationsAssembly(typeof(Startup).Assembly.GetName().Name));
          options.EnableTokenCleanup = true;
          options.TokenCleanupInterval = 30; // interval in seconds
      })
      .AddInMemoryIdentityResources(Config.GetIdentityResources())
      .AddInMemoryApiResources(Config.GetApiResources())
      .AddInMemoryClients(Config.GetClients())
      .AddAspNetIdentity<AppUser>();

    services.AddCors(options => options.AddPolicy("AllowAll", p => p.AllowAnyOrigin()
       .AllowAnyMethod()
       .AllowAnyHeader()));

    services.AddControllersWithViews();
    services.AddRazorPages();
    services.AddControllers();
}

然後,調整Configure方法的實現,將IdentityServer加入進來,同時配置CORS使得站點能夠被跨域訪問:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseDatabaseErrorPage();
    }
    else
    {
        app.UseExceptionHandler("/Home/Error");
        app.UseHsts();
    }

    app.UseCors("AllowAll");
    app.UseHttpsRedirection();
    app.UseStaticFiles();

    app.UseRouting();
    app.UseIdentityServer();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller=Home}/{action=Index}/{id?}");
        endpoints.MapRazorPages();
    });
}

完成這部分程式碼調整後,編譯是通不過的,因為我們還沒有定義IdentityServer4的IdentityResource、API Resource和Clients。在專案中新建一個Config類,程式碼如下:

public static class Config
{
    public static IEnumerable<IdentityResource> GetIdentityResources() => 
        new IdentityResource[]
        {
            new IdentityResources.OpenId(),
            new IdentityResources.Email(),
            new IdentityResources.Profile()
        };

    public static IEnumerable<ApiResource> GetApiResources() =>
        new[]
        {
            new ApiResource("api.weather", "Weather API")
            {
                Scopes =
                {
                    new Scope("api.weather.full_access", "Full access to Weather API")
                },
                UserClaims =
                {
                    ClaimTypes.NameIdentifier,
                    ClaimTypes.Name,
                    ClaimTypes.Email,
                    ClaimTypes.Role
                }
            }
        };

    public static IEnumerable<Client> GetClients() =>
        new[]
        {
            new Client
            {
                RequireConsent = false,
                ClientId = "angular",
                ClientName = "Angular SPA",
                AllowedGrantTypes = GrantTypes.Implicit,
                AllowedScopes = { "openid", "profile", "email", "api.weather.full_access" },
                RedirectUris = {"http://localhost:4200/auth-callback"},
                PostLogoutRedirectUris = {"http://localhost:4200/"},
                AllowedCorsOrigins = {"http://localhost:4200"},
                AllowAccessTokensViaBrowser = true,
                AccessTokenLifetime = 3600
            },
            new Client
            {
                ClientId = "webapi",
                AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                ClientSecrets =
                {
                    new Secret("mysecret".Sha256())
                },
                AlwaysSendClientClaims = true,
                AllowedScopes = { "api.weather.full_access" }
            }
        };
}

大致說明一下上面的程式碼。通俗地講,IdentityResource是指允許應用程式訪問使用者的哪些身份認證資源,比如,使用者的電子郵件或者其它使用者賬戶資訊,在Open ID Connect規範中,這些資訊會被轉換成Claims,儲存在User Identity的物件裡;ApiResource用來指定被IdentityServer4所保護的資源,比如這裡新建了一個ApiResource,用來保護Weather API,它定義了自己的Scope和UserClaims。Scope其實是一種關聯關係,它關聯著Client與ApiResource,用來表示什麼樣的Client對於什麼樣的ApiResource具有怎樣的訪問許可權,比如在這裡,我定義了兩個Client:angular和webapi,它們對Weather API都可以訪問;UserClaims定義了當認證通過之後,IdentityServer4應該向請求方返回哪些Claim。至於Client,就比較容易理解了,它定義了客戶端能夠以哪幾種方式來向IdentityServer4提交請求。

至此,我們的原始碼就可以編譯通過了,成功編譯之後,還需要使用Entity Framework Core所提供的命令列工具或者Powershell Cmdlet來初始化資料庫。我這裡選擇使用Visual Studio 2019 Community中的Package Manager Console,在執行資料庫更新之前,確保appsettings.json檔案裡設定了正確的SQL Server連線字串。當然,你也可以選擇使用其它型別的資料庫,只要對ConfigureServices方法做些相應的修改即可。在Package Manager Console中,依次執行下面的命令:

Add-Migration ModifiedUserAndRole -Context ApplicationDbContext
Add-Migration ModifiedUserAndRole –Context PersistedGrantDbContext
Update-Database -Context ApplicationDbContext
Update-Database -Context PersistedGrantDbContext

效果如下:

開啟SQL Server Management Studio,看到資料表都已成功建立:

由於IdentityServer4的模板所產生的程式碼使用的是mock user,也就是IdentityServer4裡預設的TestUser,因此,相關部分的程式碼需要被替換掉,最主要的部分就是AccountController的Login方法,將該方法中的相關程式碼替換為:

if (ModelState.IsValid)
{
    var user = await _userManager.FindByNameAsync(model.Username);

    if (user != null && await _userManager.CheckPasswordAsync(user, model.Password))
    {
        await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.DisplayName));

        // only set explicit expiration here if user chooses "remember me". 
        // otherwise we rely upon expiration configured in cookie middleware.
        AuthenticationProperties props = null;
        if (AccountOptions.AllowRememberLogin && model.RememberLogin)
        {
            props = new AuthenticationProperties
            {
                IsPersistent = true,
                ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration)
            };
        };

        // issue authentication cookie with subject ID and username
        await HttpContext.SignInAsync(user.Id, user.UserName, props);

        if (context != null)
        {
            if (await _clientStore.IsPkceClientAsync(context.ClientId))
            {
                // if the client is PKCE then we assume it's native, so this change in how to
                // return the response is for better UX for the end user.
                return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl });
            }

            // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
            return Redirect(model.ReturnUrl);
        }

        // request for a local page
        if (Url.IsLocalUrl(model.ReturnUrl))
        {
            return Redirect(model.ReturnUrl);
        }
        else if (string.IsNullOrEmpty(model.ReturnUrl))
        {
            return Redirect("~/");
        }
        else
        {
            // user might have clicked on a malicious link - should be logged
            throw new Exception("invalid return URL");
        }
    }

    await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials", clientId: context?.ClientId));
    ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
}

這樣才能通過注入的userManager和EntityFramework Core來訪問SQL Server,以完成登入邏輯。

新使用者註冊API

由IdentityServer4所提供的預設UI模板中沒有包括新使用者註冊的頁面,開發者可以根據自己的需要向Identity Service中增加View來提供註冊介面。不過為了快速演示,我打算先增加兩個API,然後使用curl來新建一些用於測試的角色(Role)和使用者(User)。下面的程式碼為客戶端提供了註冊角色和註冊使用者的API:

public class RegisterRoleRequestViewModel
{
    [Required]
    public string Name { get; set; }

    public string Description { get; set; }
}

public class RegisterRoleResponseViewModel
{
    public RegisterRoleResponseViewModel(AppRole role)
    {
        Id = role.Id;
        Name = role.Name;
        Description = role.Description;
    }

    public string Id { get; }

    public string Name { get; }

    public string Description { get; }
}

public class RegisterUserRequestViewModel
{
    [Required]
    [StringLength(50, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 2)]
    [Display(Name = "DisplayName")]
    public string DisplayName { get; set; }

    public string Email { get; set; }

    [Required]
    [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
    [DataType(DataType.Password)]
    [Display(Name = "Password")]
    public string Password { get; set; }

    [Required]
    [StringLength(20)]
    [Display(Name = "UserName")]
    public string UserName { get; set; }

    public List<string> RoleNames { get; set; }
}

public class RegisterUserResponseViewModel
{
    public string Id { get; set; }
    public string UserName { get; set; }
    public string DisplayName { get; set; }
    public string Email { get; set; }

    public RegisterUserResponseViewModel(AppUser user)
    {
        Id = user.Id;
        UserName = user.UserName;
        DisplayName = user.DisplayName;
        Email = user.Email;
    }
}

// Controllers\Account\AccountController.cs
[HttpPost]
[Route("api/[controller]/register-account")]
public async Task<IActionResult> RegisterAccount([FromBody] RegisterUserRequestViewModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var user = new AppUser { UserName = model.UserName, DisplayName = model.DisplayName, Email = model.Email };
    

    var result = await _userManager.CreateAsync(user, model.Password);

    if (!result.Succeeded) return BadRequest(result.Errors);

    await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.NameIdentifier, user.UserName));
    await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Name, user.DisplayName));
    await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Email, user.Email));

    if (model.RoleNames?.Count > 0)
    {
        var validRoleNames = new List<string>();
        foreach(var roleName in model.RoleNames)
        {
            var trimmedRoleName = roleName.Trim();
            if (await _roleManager.RoleExistsAsync(trimmedRoleName))
            {
                validRoleNames.Add(trimmedRoleName);
                await _userManager.AddToRoleAsync(user, trimmedRoleName);
            }
        }

        await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Role, string.Join(',', validRoleNames)));
    }

    return Ok(new RegisterUserResponseViewModel(user));
}

// Controllers\Account\AccountController.cs
[HttpPost]
[Route("api/[controller]/register-role")]
public async Task<IActionResult> RegisterRole([FromBody] RegisterRoleRequestViewModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var appRole = new AppRole { Name = model.Name, Description = model.Description };
    var result = await _roleManager.CreateAsync(appRole);
    if (!result.Succeeded) return BadRequest(result.Errors);

    return Ok(new RegisterRoleResponseViewModel(appRole));
}

在上面的程式碼中,值得關注的就是register-account API中的幾行AddClaimAsync呼叫,我們將一些使用者資訊資料加入到User Identity的Claims中,比如,將使用者的角色資訊,通過逗號分隔的字串儲存為Claim,在後續進行使用者授權的時候,會用到這些資料。

建立一些基礎資料

執行我們已經搭建好的Identity Service,然後使用下面的curl命令建立一些基礎資料:

curl -X POST http://localhost:7890/api/account/register-role \
  -d '{"name":"admin","description":"Administrator"}' \
  -H 'Content-Type:application/json' --insecure
curl -X POST http://localhost:7890/api/account/register-account \
  -d '{"userName":"daxnet","password":"[email protected]","displayName":"Sunny Chen","email":"[email protected]","roleNames":["admin"]}' \
  -H 'Content-Type:application/json' --insecure
curl -X POST http://localhost:7890/api/account/register-account \
  -d '{"userName":"acqy","password":"[email protected]","displayName":"Qingyang Chen","email":"[email protected]"}' \
  -H 'Content-Type:application/json' --insecure

完成這些命令後,系統中會建立一個admin的角色,並且會建立daxnet和acqy兩個使用者,daxnet具有admin角色,而acqy則沒有該角色。

使用瀏覽器訪問 http://localhost:7890 ,點選主頁的連結進入登入介面,用已建立的使用者名稱和密碼登入,可以看到如下的介面,表示Identity Service的開發基本完成:

小結

一篇文章實在是寫不完,今天就暫且告一段落吧,下一講我將介紹Weather API和基於Ocelot的API閘道器,整合Identity Service進行身份認證。

原始碼

訪問以下Github地址以獲取原始碼:

http://github.com/daxnet/identity-demo

(總訪問量:10;當日訪問量:10)