配套源碼:https://gitee.com/jardeng/IdentitySolution
本篇將創建使用[Code-授權碼]授權模式的客戶端,來對受保護的API資源進行訪問。
1、接上一篇項目,因為之前創建IdentityServer認證服務器沒有使用IdentityServer4提供的模板,在Code授權碼模式就沒有進行登錄、授權的界面,所以重新創建一下IdentityServer項目。
重新使用IdentityServer4模板 - is4inmem創建項目。
將之前IdentityServer認證服務器Config.cs復制到新建的IdentityServer服務器即可,最后的IdentityServer認證服務器項目結構為:
然后在IdentityServer項目Config.cs中添加一個返回身份資源的方法
然后在IdentityServer項目Config.cs中添加一個客戶端
注意:localhost:6001指的是我們將要創建的MVC客戶端的項目地址,並非IdentityServer認證服務器的地址

/// 授權碼模式(Code) /// 適用於保密客戶端(Confidential Client),比如ASP.NET MVC等服務器端渲染的Web應用 new Client { ClientId = "mvc client", ClientName = "ASP.NET Core MVC Client", AllowedGrantTypes = GrantTypes.Code, ClientSecrets = { new Secret("mvc secret".Sha256()) }, RedirectUris = { "http://localhost:6001/signin-oidc" }, FrontChannelLogoutUri = "http://localhost:6001/signout-oidc", PostLogoutRedirectUris = { "http://localhost:6001/signout-callback-oidc" }, AlwaysIncludeUserClaimsInIdToken = true, AllowOfflineAccess = true, AllowedScopes = { "api1", IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile, IdentityServerConstants.StandardScopes.Email, IdentityServerConstants.StandardScopes.Address, IdentityServerConstants.StandardScopes.Phone } }
其中,RedirectUris的signin-oidc / FrontChannelLogoutUri的signout-oidc / PostLogoutRedirectUris的signout-callback-oidc,都是固定的地址寫法。
完整的Config.cs代碼:

using IdentityModel; using IdentityServer4; using IdentityServer4.Models; using IdentityServer4.Test; using System.Collections.Generic; using System.Security.Claims; namespace IdentityServer { /// <summary> /// IdentityServer資源和客戶端配置文件 /// </summary> public static class Config { /// <summary> /// 身份資源集合 /// </summary> public static IEnumerable<IdentityResource> Ids => new IdentityResource[] { new IdentityResources.OpenId(), new IdentityResources.Profile(), new IdentityResources.Email(), new IdentityResources.Address(), new IdentityResources.Phone() }; /// <summary> /// API資源集合 /// 如果您將在生產環境中使用此功能,那么給您的API取一個邏輯名稱就很重要。 /// 開發人員將使用它通過身份服務器連接到您的api。 /// 它應該以簡單的方式向開發人員和用戶描述您的api。 /// </summary> public static IEnumerable<ApiResource> Apis => new List<ApiResource> { new ApiResource("api1", "My API") }; /// <summary> /// 客戶端集合 /// </summary> public static IEnumerable<Client> Clients => new Client[] { /// 客戶端模式(Client Credentials) /// 可以將ClientId和ClientSecret視為應用程序本身的登錄名和密碼。 /// 它將您的應用程序標識到身份服務器,以便它知道哪個應用程序正在嘗試與其連接。 new Client { //客戶端標識 ClientId = "client", //沒有交互用戶,使用clientid/secret進行身份驗證,適用於和用戶無關,機器與機器之間直接交互訪問資源的場景。 AllowedGrantTypes = GrantTypes.ClientCredentials, //認證密鑰 ClientSecrets = { new Secret("secret".Sha256()) }, //客戶端有權訪問的作用域 AllowedScopes = { "api1" } }, /// 資源所有者密碼憑證(ResourceOwnerPassword) /// Resource Owner其實就是User,所以可以直譯為用戶名密碼模式。 /// 密碼模式相較於客戶端憑證模式,多了一個參與者,就是User。 /// 通過User的用戶名和密碼向Identity Server申請訪問令牌。 new Client { ClientId = "client1", AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, ClientSecrets = { new Secret("secret".Sha256()) }, AllowedScopes = { "api1" } }, /// 授權碼模式(Code) /// 適用於保密客戶端(Confidential Client),比如ASP.NET MVC等服務器端渲染的Web應用 new Client { ClientId = "mvc client", ClientName = "ASP.NET Core MVC Client", AllowedGrantTypes = GrantTypes.Code, ClientSecrets = { new Secret("mvc secret".Sha256()) }, RedirectUris = { "http://localhost:6001/signin-oidc" }, FrontChannelLogoutUri = "http://localhost:6001/signout-oidc", PostLogoutRedirectUris = { "http://localhost:6001/signout-callback-oidc" }, AlwaysIncludeUserClaimsInIdToken = true, AllowOfflineAccess = true, AllowedScopes = { "api1", IdentityServerConstants.StandardScopes.OpenId, IdentityServerConstants.StandardScopes.Profile, IdentityServerConstants.StandardScopes.Email, IdentityServerConstants.StandardScopes.Address, IdentityServerConstants.StandardScopes.Phone } } }; /// <summary> /// 用戶集合 /// </summary> public static List<TestUser> Users => new List<TestUser> { new TestUser{SubjectId = "818727", Username = "alice", Password = "alice", Claims = { new Claim(JwtClaimTypes.Name, "Alice Smith"), new Claim(JwtClaimTypes.GivenName, "Alice"), new Claim(JwtClaimTypes.FamilyName, "Smith"), new Claim(JwtClaimTypes.Email, "AliceSmith@email.com"), new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), new Claim(JwtClaimTypes.WebSite, "http://alice.com"), new Claim(JwtClaimTypes.Address, @"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', 'postal_code': 69118, 'country': 'Germany' }", IdentityServer4.IdentityServerConstants.ClaimValueTypes.Json) } }, new TestUser{SubjectId = "88421113", Username = "bob", Password = "bob", Claims = { new Claim(JwtClaimTypes.Name, "Bob Smith"), new Claim(JwtClaimTypes.GivenName, "Bob"), new Claim(JwtClaimTypes.FamilyName, "Smith"), new Claim(JwtClaimTypes.Email, "BobSmith@email.com"), new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), new Claim(JwtClaimTypes.WebSite, "http://bob.com"), new Claim(JwtClaimTypes.Address, @"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', 'postal_code': 69118, 'country': 'Germany' }", IdentityServer4.IdentityServerConstants.ClaimValueTypes.Json), new Claim("location", "somewhere") } } }; } }
2、創建一個名為 CodeMvcApp 的ASP.NET Core MVC客戶端應用。
選擇Web 應用程序(模型視圖控制器)模板
創建完成后的項目截圖
3、添加nuget包:IdentityServer4、IdentityModel、System.IdentityModel.Tokens.Jwt
4、配置MVC客戶端
> Config.cs的ConfigureServices方法:
public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews(); JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => { options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.Authority = "http://localhost:5000"; options.RequireHttpsMetadata = false; options.ClientId = "mvc client"; options.ClientSecret = "mvc secret"; options.SaveTokens = true; options.ResponseType = "code"; options.Scope.Clear(); options.Scope.Add("api1"); options.Scope.Add(OidcConstants.StandardScopes.OpenId); options.Scope.Add(OidcConstants.StandardScopes.Profile); options.Scope.Add(OidcConstants.StandardScopes.Email); options.Scope.Add(OidcConstants.StandardScopes.Phone); options.Scope.Add(OidcConstants.StandardScopes.Address); options.Scope.Add(OidcConstants.StandardScopes.OfflineAccess); }); }
> Config.cs的Configure方法:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Home/Error"); } app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); }); }
給HomeController控制器加上[Authorize]特性
IdentityServer認證服務器需要在開發環境才能出現首頁,所以另外打開這個項目並啟動。
再啟動CodeMvcApp項目
我們看到MVC客戶端默認跳轉到了localhost:5000(IdentityServer認證服務器)的登錄頁(Account/Login),因為MVC客戶端默認啟動的是Home/Index,且Home控制器已被標記Authorize特性,需要登錄才能訪問
使用 alice / alice 進行登錄,進入到了IdentityServer認證服務器的授權頁面(consent),點擊Yes, Allow
進入到了MVC客戶端首頁
我們打開IdentityServer認證服務器地址:http://localhost:5000
可以看到IdentityServer認證服務器顯示了當前的登錄用戶,此時點擊用戶名可以顯示出Logout登出按鈕,點擊登出即可完成注銷登錄
5、獲取accecc_token並訪問受保護API資源,修改HomeController的Index方法
using System; using System.Diagnostics; using System.Net.Http; using System.Threading.Tasks; using CodeMvcApp.Models; using IdentityModel.Client; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Protocols.OpenIdConnect; public async Task<IActionResult> Index() { HttpClient client = new HttpClient(); DiscoveryDocumentResponse disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000/"); if (disco.IsError) { throw new Exception(disco.Error); } string accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken); client.SetBearerToken(accessToken); HttpResponseMessage response = await client.GetAsync("http://localhost:6000/WeatherForecast"); if (!response.IsSuccessStatusCode) { throw new Exception(response.ReasonPhrase); } string content = await response.Content.ReadAsStringAsync(); return View("Index", content); }
修改Index.cshtml來顯示訪問API的結果
@{ ViewData["Title"] = "Home Page"; } @model string <div class="text-center"> <h1 class="display-4">Welcome</h1> <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p> </div> <h2>Api Resource Result:</h2> <p>@Model</p>
修改一下HomeController的Privacy方法
public async Task<IActionResult> Privacy() { string accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken); string idToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.IdToken); string refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken); //code只能使用一次,所以獲取不到 //string code = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.Code); ViewData["accessToken"] = accessToken; ViewData["idToken"] = idToken; ViewData["refreshToken"] = refreshToken; return View(); }
修改Privacy.cshtml來顯示token相關信息和用戶身份聲明相關信息
@{ ViewData["Title"] = "Privacy Policy"; } <h1>@ViewData["Title"]</h1> <h2>Access Token:</h2> <p>@ViewData["accessToken"]</p> <h2>Id Token:</h2> <p>@ViewData["idToken"]</p> <h2>Refresh Token:</h2> <p>@ViewData["refreshToken"]</p> <h2>Claims:</h2> <dl> @foreach (var claim in User.Claims) { <dt>@claim.Type</dt> <dd>@claim.Value</dd> } </dl>
重新啟動MVC客戶端,成功獲取access_token,並使用access_tokem訪問受保護的API資源
> Home/Index
> Home/Policy
6、顯示登錄的用戶,並實現登出
修改Views/Shared/_Layout.cshtml,增加當前登錄用戶名稱和登出按鈕的顯示

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>@ViewData["Title"] - CodeMvcApp</title> <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> <link rel="stylesheet" href="~/css/site.css" /> </head> <body> <header> <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3"> <div class="container"> <a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">CodeMvcApp</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse"> <ul class="navbar-nav flex-grow-1" style="position: relative;"> <li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a> </li> <li class="nav-item"> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> </li> @if (User.Identity.IsAuthenticated) { <li class="nav-item" style="position: absolute; right: 0;"> <span>Welcome,@User.Claims.FirstOrDefault(x => x.Type.Equals("given_name")).Value</span> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Logout" style="display: inline-block;">Logout</a> </li> } </ul> </div> </div> </nav> </header> <div class="container"> <main role="main" class="pb-3"> @RenderBody() </main> </div> <footer class="border-top footer text-muted"> <div class="container"> © 2020 - CodeMvcApp - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> </div> </footer> <script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script src="~/js/site.js" asp-append-version="true"></script> @RenderSection("Scripts", required: false) </body> </html>
修改HomeController,增加Logout方法

using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; public async Task Logout() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme); }
重新運行項目,導航欄右側就顯示了當前用戶名和登出按鈕
點擊Logout登出,跳轉到了IdentityServer認證服務器的登出頁面(Account/Logout),此時已經登出了,但是界面停在了IdentityServer的注銷成功頁面
點擊“here”,可以跳轉到MVC客戶端,但是不是很友好
此時我們打開IdentityServer認證服務器地址:http://localhost:5000,看到IdentityServer認證服務器的用戶已經顯示被注銷
然后來解決上面不友好的問題,修改IdentityServer服務器,打開Quickstart/Account/AccountOptions.cs,將AutomaticRedirectAfterSignOut設置為true,即登出后自動跳轉
修改完成后重啟IdentityServer認證服務器,再重啟MVC客戶端即可解決。
7、為MVC客戶端刷新Token
在IdentityServer認證服務器Config.cs中MVC客戶端做下修改,加上訪問令牌的過期時間(或者叫生存期)
在API項目WebApplication1的Startup.cs/ConfigureServices/AddJwtBearer的options中添加兩個參數
因為Jwt驗證token時間偏移默認為5分鍾,會出現token過期了還能訪問Api資源的問題,只有到了驗證token的時間偏移,才會禁止訪問Api
所以我們設置為1分鍾偏移,並啟用必須設置token的過期時間選項,以防止token過期了還能對Api進行訪問
但是還是會存在一個時間差,比如token已經過期了,API驗證token時間還沒有到,這個沒有什么辦法,API可以把驗證token的時間設置更短一些,但是也會消耗過多的資源,所以根據實際情況來設置

public void ConfigureServices(IServiceCollection services) { services.AddControllers(); //將身份驗證服務添加到DI並配置Bearer為默認方案。 services.AddAuthentication("Bearer") .AddJwtBearer("Bearer", options => { //指定授權地址 options.Authority = "http://localhost:5000"; //獲取或設置元數據地址或權限是否需要HTTPS。默認值為true。這應該只在開發環境中禁用。 options.RequireHttpsMetadata = false; //獲取或設置任何接收到的OpenIdConnect令牌的訪問群體。 options.Audience = "api1"; //設置驗證時間時要應用的時鍾偏移,即token多久驗證一次,默認為5分鍾 options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(1); //指示令牌是否必須具有“過期”值 options.TokenValidationParameters.RequireExpirationTime = true; }); }
然后重新啟動IdentityServer認證服務器、Api資源項目WebApplication1、MVC客戶端,進入到MVC客戶端首頁后,等1分鍾之后再刷新
出現了錯誤,401 Unauthorized未授權,原因是access_token已過期
接下來實現刷新token,在HomeController定義一個方法RenewTokenAsync,用於刷新訪問令牌
/// <summary> /// 更新/刷新令牌 /// </summary> /// <returns>訪問令牌</returns> public async Task<string> RenewTokenAsync() { HttpClient client = new HttpClient(); DiscoveryDocumentResponse disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000/"); if (disco.IsError) { throw new Exception(disco.Error); } //獲取刷新令牌 string refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken); //根據刷新令牌重新獲取訪問令牌 TokenResponse tokenResponse = await client.RequestRefreshTokenAsync(new RefreshTokenRequest { Address = disco.TokenEndpoint, ClientId = "mvc client", ClientSecret = "mvc secret", Scope = "api1 openid profile email phone address", GrantType = OpenIdConnectGrantTypes.RefreshToken, RefreshToken = refreshToken }); if (tokenResponse.IsError) { throw new Exception(tokenResponse.Error); } else { //重新計算過期時間(當前時間+token的有效期秒) var expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(tokenResponse.ExpiresIn); //定義令牌集合,用於重新更新令牌 var tokens = new[] { //重新設置身份令牌 new AuthenticationToken { Name = OpenIdConnectParameterNames.IdToken, Value = tokenResponse.IdentityToken }, //重新設置訪問令牌 new AuthenticationToken { Name = OpenIdConnectParameterNames.AccessToken, Value = tokenResponse.AccessToken }, //重新設置刷新令牌 new AuthenticationToken { Name = OpenIdConnectParameterNames.RefreshToken, Value = tokenResponse.RefreshToken }, //重新設置過期時間 new AuthenticationToken { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) } }; //獲取身份認證的結果,包含當前的用戶標識聲明主體(Principal)+會話的其他狀態值(Properties) var currentAuthenticateResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); //將存儲的token都重新更新一遍(將新的tokens存起來) currentAuthenticateResult.Properties.StoreTokens(tokens); //將當前身份認證結果(用戶標識聲明主體+會話的其他狀態值)代入,重新執行登錄動作 await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, currentAuthenticateResult.Principal,//身份驗證的用戶標識的聲明主體 currentAuthenticateResult.Properties//身份驗證會話的其他狀態值 ); return tokenResponse.AccessToken; } }
在HomeController/Index方法中,增加判斷條件,調用API資源不成功時,判斷如果響應的結果狀態碼是401 Unauthorized未授權,則重新刷新令牌並重定向到當前Action,即Home/Index
重新運行MVC客戶端,即可查驗效果
當token過期,會重新獲取access_token並更新存儲的tokens,重定向到當前Action,即刷新,刷新時就重新調用了API資源,此時token是刷新后的token,就能正常的訪問API資源了
Over, Thanks!!!
【參考資料】
微軟MVP楊旭老師的IdentityServer4嗶哩嗶哩教學視頻:Identity Server 4 原理和實戰