好吧,這個(gè)題目我也想了很久,不知道如何用最簡單的幾個(gè)字來概括這篇文章,原本打算取名《Angular單頁面應(yīng)用基于Ocelot API網(wǎng)關(guān)與IdentityServer4+ASP.NET Identity實(shí)現(xiàn)身份認(rèn)證與授權(quán)》,然而如你所見,這樣的名字實(shí)在是太長了。所以,我不得不縮寫“單頁面應(yīng)用”幾個(gè)字,然后去掉ASP.NET Identity的描述,最后形成目前的標(biāo)題。
不過,這也就意味著這篇文章會(huì)涵蓋很多內(nèi)容和技術(shù),我會(huì)利用這些技術(shù)來走通一個(gè)完整的流程,這個(gè)流程也代表著在微服務(wù)架構(gòu)中單點(diǎn)登錄的一種實(shí)現(xiàn)模式。在此過程中,我們會(huì)使用到如下技術(shù)或框架:
Angular 8
Ocelot API Gateway
IdentityServer4
ASP.NET Identity
Entity Framework Core
SQL Server
本文假設(shè)讀者具有上述技術(shù)框架的基礎(chǔ)知識(shí)。由于內(nèi)容比較多,我還是將這篇文章分幾個(gè)部分進(jìn)行講解和討論。
在微服務(wù)架構(gòu)下的一種比較流行的設(shè)計(jì),就是基于前后端分離,前端只做呈現(xiàn)和用戶操作流的管理,后端服務(wù)由API網(wǎng)關(guān)同一協(xié)調(diào),以從業(yè)務(wù)層面為前端提供各種服務(wù)。大致可以用下圖表示:
在這個(gè)結(jié)構(gòu)中,我沒有將Identity Service放在API Gateway后端,因?yàn)榭紤]到Identity Service本身并沒有承擔(dān)任何業(yè)務(wù)功能。從它所能提供的端點(diǎn)(Endpoint)的角度,它也需要做負(fù)載均衡、熔斷等保護(hù),但我們暫時(shí)不討論這些內(nèi)容。
流程上其實(shí)也比較簡單,在上圖的數(shù)字標(biāo)識(shí)中:
Client向Identity Service發(fā)送認(rèn)證請(qǐng)求,通常可以是用戶名密碼
如果驗(yàn)證通過,Identity Service會(huì)向Client返回認(rèn)證的Token
Client使用Token向API Gateway發(fā)送API調(diào)用請(qǐng)求
API Gateway將Client發(fā)送過來的Token發(fā)送給Identity Service,以驗(yàn)證Token的有效性
如果驗(yàn)證成功,Identity Service會(huì)告知API Gateway認(rèn)證成功
API Gateway轉(zhuǎn)發(fā)Client的請(qǐng)求到后端API Service
API Service將結(jié)果返回給API Gateway
API Gateway將API Service返回的結(jié)果轉(zhuǎn)發(fā)到Client
只是在這些步驟中,我們有很多技術(shù)選擇,比如Identity Service的實(shí)現(xiàn)方式、認(rèn)證方式等等。接下來,我就在ASP.NET Core的基礎(chǔ)上使用IdentityServer4、Entity Framework Core和Ocelot來完成這一流程。在完成整個(gè)流程的演練之前,需要確保機(jī)器滿足以下條件:
安裝Visual Studio 2019 Community Edition。使用Visual Studio Code也是可以的,根據(jù)自己的需要選擇
安裝Visual Studio Code
安裝Angular 8
首先第一步就是實(shí)現(xiàn)Identity Service。在Visual Studio 2019 Community Edition中,新建一個(gè)ASP.NET Core Web Application,模板選擇Web Application (Model-View-Controller),然后點(diǎn)擊Authentication下的Change按鈕,再選擇Individual User Accounts選項(xiàng),以便將ASP.NET Identity的依賴包都加入項(xiàng)目,并且自動(dòng)完成基礎(chǔ)代碼的搭建。
然后,通過NuGet添加IdentityServer4.AspNetIdentity以及IdentityServer4.EntityFramework的引用,IdentityServer4也隨之會(huì)被添加進(jìn)來。接下來,在該項(xiàng)目的目錄下,執(zhí)行以下命令安裝IdentityServer4的模板,并將IdentityServer4的GUI加入到當(dāng)前項(xiàng)目:
dotnet new -i identityserver4.templatesdotnet new is4ui --force
然后調(diào)整一下項(xiàng)目結(jié)構(gòu),將原本的Controllers目錄刪除,同時(shí)刪除Models目錄下的ErrorViewModel類,然后將Quickstart目錄重命名為Controllers,編譯代碼,代碼應(yīng)該可以編譯通過,接下來就是實(shí)現(xiàn)我們自己的Identity。
為了能夠展現(xiàn)一個(gè)標(biāo)準(zhǔn)的應(yīng)用場景,我自己定義了User和Role對(duì)象,它們分別繼承于IdentityUser和IdentityRole類:
public class AppUser : IdentityUser{ public string DisplayName { get; set; }}public class AppRole : IdentityRole{ public string Description { get; set; }}
當(dāng)然,Data目錄下的ApplicationDbContext也要做相應(yīng)調(diào)整,它應(yīng)該繼承于IdentityDbContext<AppUser, AppRole, string>類,這是因?yàn)槲覀兪褂昧俗远x的IdentityUser和IdentityRole的實(shí)現(xiàn):
public class ApplicationDbContext : IdentityDbContext<AppUser, AppRole, string>{ public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }}
之后修改Startup.cs里的ConfigureServices方法,通過調(diào)用AddIdentity、AddIdentityServer以及AddDbContext,將ASP.NET Identity、IdentityServer4以及存儲(chǔ)認(rèn)證數(shù)據(jù)所使用的Entity Framework Core的依賴全部注冊(cè)進(jìn)來。為了測試方便,目前我們還是使用Developer Signing Credential,對(duì)于Identity Resource、API Resource以及Clients,我們也是暫時(shí)先寫死(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();}
然后,調(diào)整Configure方法的實(shí)現(xiàn),將IdentityServer加入進(jìn)來,同時(shí)配置CORS使得站點(diǎn)能夠被跨域訪問:
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(); });}
完成這部分代碼調(diào)整后,編譯是通不過的,因?yàn)槲覀冞€沒有定義IdentityServer4的IdentityResource、API Resource和Clients。在項(xiàng)目中新建一個(gè)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是指允許應(yīng)用程序訪問用戶的哪些身份認(rèn)證資源,比如,用戶的電子郵件或者其它用戶賬戶信息,在Open ID Connect規(guī)范中,這些信息會(huì)被轉(zhuǎn)換成Claims,保存在User Identity的對(duì)象里;ApiResource用來指定被IdentityServer4所保護(hù)的資源,比如這里新建了一個(gè)ApiResource,用來保護(hù)Weather API,它定義了自己的Scope和UserClaims。Scope其實(shí)是一種關(guān)聯(lián)關(guān)系,它關(guān)聯(lián)著Client與ApiResource,用來表示什么樣的Client對(duì)于什么樣的ApiResource具有怎樣的訪問權(quán)限,比如在這里,我定義了兩個(gè)Client:angular和webapi,它們對(duì)Weather API都可以訪問;UserClaims定義了當(dāng)認(rèn)證通過之后,IdentityServer4應(yīng)該向請(qǐng)求方返回哪些Claim。至于Client,就比較容易理解了,它定義了客戶端能夠以哪幾種方式來向IdentityServer4提交請(qǐng)求。
至此,我們的源代碼就可以編譯通過了,成功編譯之后,還需要使用Entity Framework Core所提供的命令行工具或者Powershell Cmdlet來初始化數(shù)據(jù)庫。我這里選擇使用Visual Studio 2019 Community中的Package Manager Console,在執(zhí)行數(shù)據(jù)庫更新之前,確保appsettings.json文件里設(shè)置了正確的SQL Server連接字符串。當(dāng)然,你也可以選擇使用其它類型的數(shù)據(jù)庫,只要對(duì)ConfigureServices方法做些相應(yīng)的修改即可。在Package Manager Console中,依次執(zhí)行下面的命令:
Add-Migration ModifiedUserAndRole -Context ApplicationDbContextAdd-Migration ModifiedUserAndRole –Context PersistedGrantDbContextUpdate-Database -Context ApplicationDbContextUpdate-Database -Context PersistedGrantDbContext
效果如下:
打開SQL Server Management Studio,看到數(shù)據(jù)表都已成功創(chuàng)建:
由于IdentityServer4的模板所產(chǎn)生的代碼使用的是mock user,也就是IdentityServer4里默認(rèn)的TestUser,因此,相關(guān)部分的代碼需要被替換掉,最主要的部分就是AccountController的Login方法,將該方法中的相關(guān)代碼替換為:
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,以完成登錄邏輯。
由IdentityServer4所提供的默認(rèn)UI模板中沒有包括新用戶注冊(cè)的頁面,開發(fā)者可以根據(jù)自己的需要向Identity Service中增加View來提供注冊(cè)界面。不過為了快速演示,我打算先增加兩個(gè)API,然后使用curl來新建一些用于測試的角色(Role)和用戶(User)。下面的代碼為客戶端提供了注冊(cè)角色和注冊(cè)用戶的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));}
在上面的代碼中,值得關(guān)注的就是register-account API中的幾行AddClaimAsync調(diào)用,我們將一些用戶信息數(shù)據(jù)加入到User Identity的Claims中,比如,將用戶的角色信息,通過逗號(hào)分隔的字符串保存為Claim,在后續(xù)進(jìn)行用戶授權(quán)的時(shí)候,會(huì)用到這些數(shù)據(jù)。
運(yùn)行我們已經(jīng)搭建好的Identity Service,然后使用下面的curl命令創(chuàng)建一些基礎(chǔ)數(shù)據(jù):
curl -X POST https://localhost:7890/api/account/register-role -d '{"name":"admin","description":"Administrator"}' -H 'Content-Type:application/json' --insecurecurl -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' --insecurecurl -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
完成這些命令后,系統(tǒng)中會(huì)創(chuàng)建一個(gè)admin的角色,并且會(huì)創(chuàng)建daxnet和acqy兩個(gè)用戶,daxnet具有admin角色,而acqy則沒有該角色。
使用瀏覽器訪問https://localhost:7890,點(diǎn)擊主頁的鏈接進(jìn)入登錄界面,用已創(chuàng)建的用戶名和密碼登錄,可以看到如下的界面,表示Identity Service的開發(fā)基本完成:
一篇文章實(shí)在是寫不完,今天就暫且告一段落吧,下一講我將介紹Weather API和基于Ocelot的API網(wǎng)關(guān),整合Identity Service進(jìn)行身份認(rèn)證。
訪問以下Github地址以獲取源代碼:
聯(lián)系客服