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網關同一協調,以從業務層面為前端提供各種服務。大致可以用下圖表示:
在這個結構中,我沒有將Identity Service放在API Gateway後端,因為考慮到Identity Service本身並沒有承擔任何業務功能。從它所能提供的端點(Endpoint)的角度,它也需要做負載均衡、熔斷等保護,但我們暫時不討論這些內容。
流程上其實也比較簡單,在上圖的數字標識中:
- Client向Identity Service傳送認證請求,通常可以是使用者名稱密碼
- 如果驗證通過,Identity Service會向Client返回認證的Token
- Client使用Token向API Gateway傳送API呼叫請求
- API Gateway將Client傳送過來的Token傳送給Identity Service,以驗證Token的有效性
- 如果驗證成功,Identity Service會告知API Gateway認證成功
- API Gateway轉發Client的請求到後端API Service
- API Service將結果返回給API Gateway
- 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)
- Apache Kafka快速演練
- Saga體系結構模式:微服務架構下跨服務事務的實現
- 快速理解ASP.NET Core的認證與授權
- Visual Studio 2022新特性
- 徒手打造基於Spark的資料工廠(Data Factory):從設計到實現
- 何時使用領域驅動設計
- Angular SPA基於Ocelot API閘道器與IdentityServer4的身份認證與授權(三)
- Angular SPA基於Ocelot API閘道器與IdentityServer4的身份認證與授權(一)
- 在Ocelot中使用自定義的中介軟體(一)
- 基於Angular 8和Bootstrap 4實現動態主題切換
- ASP.NET Core 2.0 Web API專案升級到ASP.NET Core 3.0概要筆記