Angular SPA基於Ocelot API網關與IdentityServer4的身份認證與授權(一)


好吧,這個題目我也想了很久,不知道如何用最簡單的幾個字來概括這篇文章,原本打算取名《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網關同一協調,以從業務層面為前端提供各種服務。大致可以用下圖表示:

image

在這個結構中,我沒有將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的依賴包都加入項目,並且自動完成基礎代碼的搭建。

image

然后,通過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

效果如下:

image

打開SQL Server Management Studio,看到數據表都已成功創建:

image

由於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 https://localhost:7890/api/account/register-role \
  -d '{"name":"admin","description":"Administrator"}' \
  -H 'Content-Type:application/json' --insecure
curl -X POST https://localhost:7890/api/account/register-account \
  -d '{"userName":"daxnet","password":"P@ssw0rd123","displayName":"Sunny Chen","email":"daxnet@163.com","roleNames":["admin"]}' \
  -H 'Content-Type:application/json' --insecure
curl -X POST https://localhost:7890/api/account/register-account \
  -d '{"userName":"acqy","password":"P@ssw0rd123","displayName":"Qingyang Chen","email":"qychen@163.com"}' \
  -H 'Content-Type:application/json' --insecure

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

使用瀏覽器訪問https://localhost:7890,點擊主頁的鏈接進入登錄界面,用已創建的用戶名和密碼登錄,可以看到如下的界面,表示Identity Service的開發基本完成:

image

小結

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

源代碼

訪問以下Github地址以獲取源代碼:

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM