一. 模式探究
1.背景
在一些輸入受限的設備上,要完成用戶名和口令的輸入是非常困難的,設備授權模式,可讓用戶登錄到智能電視、 IoT 物聯網設備或打印機等輸入受限的設備。 若要啟用此流,設備會讓用戶在另一台設備上的瀏覽器中訪問一個網頁,以進行登錄。 用戶登錄后,設備可以獲取所需的訪問令牌和刷新令牌。
2.運行流程
圖一:

圖二:

大致流程:
1. 客戶端通過攜帶ClientId、ClientSecret,請求IDS4服務器,請求成功,返回:DeviceCode、VerificationUriComplete
2. 客戶端將url寫入二維碼,或者用瀏覽器直接打開,進入授權頁面。這期間,客戶端攜帶ClientId、ClientSecret、DeviceCode不斷輪詢請求IDS4服務器,看是否已經授權。
3. 上面的授權頁面,用戶輸入賬號、密碼,確認授權。
4. 客戶端通過輪詢得知已經授權,且拿到返回值 accessToken。
5. 客戶端攜帶accessToken,請求資源服務器。
參考:
微軟OAuth 2.0 設備代碼流:https://docs.microsoft.com/zh-cn/azure/active-directory/develop/v2-oauth2-device-code
百度Device授權模式:https://developer.baidu.com/wiki/index.php?title=docs/oauth/device
IDS4代碼參考:https://damienbod.com/2019/02/20/asp-net-core-oauth-device-flow-client-with-identityserver4/
二. 代碼實操與剖析
1. 項目准備
(1). IDS4_Server2: 授權認證服務器
(2). ResourceServer: 資源服務器
(3). WinformClient1:基於winform的客戶端 (.Net下的,非Core下)
2.搭建步驟
(一).IDS4_Server2
(1).通過Nuget安裝【IdentityServer4 4.0.2】程序集
(2).集成IDS4官方的UI頁面
進入ID4_Server2的根目錄,cdm模式下依次輸入下面指令,集成IDS4相關的UI頁面,發現新增或改變了【Quickstart】【Views】【wwwroot】三個文件夾
A.【dotnet new -i identityserver4.templates】
B.【dotnet new is4ui --force】 其中--force代表覆蓋的意思, 空項目可以直接輸入:【dotnet new is4ui】,不需要覆蓋。
PS. 有時候正值版本更新期間,上述指令下載下來的文件可能不是最新的,這個時候只需要手動去下載,然后把上述三個文件夾copy到項目里即可
(下載地址:https://github.com/IdentityServer/IdentityServer4.Quickstart.UI)
(3).創建配置類 Config1
A.配置api的范圍集合:ApiScope, 在4.x版本中必須配置
B.配置需要保護Api資源:ApiResource,每個resource后面都需要配置對應的Scope
C.配置可以訪問的客戶端資源:Client。重點配置設備流模式:GrantTypes.DeviceFlow。
D.配置可以訪問的用戶資源:TestUser
代碼分享:
public class Config1 { /// <summary> /// 聲明api的Scope(范圍)集合 /// IDS4 4.x版本必須寫的 /// </summary> /// <returns></returns> public static IEnumerable<ApiScope> GetApiScopes() { List<ApiScope> scopeList = new List<ApiScope>(); scopeList.Add(new ApiScope("ResourceServer")); return scopeList; } /// <summary> /// 定義需要保護的Api資源 /// </summary> /// <returns></returns> public static IEnumerable<ApiResource> GetApiResources() { List<ApiResource> resources = new List<ApiResource>(); //ApiResource第一個參數是ServiceName,第二個參數是描述 resources.Add(new ApiResource("ResourceServer", "ResourceServer服務需要保護哦") { Scopes = { "ResourceServer" } }); return resources; } /// <summary> /// 定義可以使用ID4 Server 客戶端資源 /// </summary> /// <returns></returns> public static IEnumerable<Client> GetClients() { List<Client> clients = new List<Client>() { new Client { ClientId = "client1",//客戶端ID AllowedGrantTypes = GrantTypes.DeviceFlow, //驗證類型:設備流模式 RequireConsent = true, //手動確認授權 ClientSecrets ={ new Secret("0001".Sha256())}, //密鑰和加密方式 AllowedScopes = { "ResourceServer" }, //允許訪問的api服務 AlwaysIncludeUserClaimsInIdToken=true } }; return clients; } /// <summary> /// 定義可以使用ID4的用戶資源 /// </summary> /// <returns></returns> public static List<TestUser> GetUsers() { var address = new { street_address = "One Hacker Way", locality = "Heidelberg", postal_code = 69118, country = "Germany" }; return new List<TestUser>() { new TestUser { SubjectId = "001", Username = "ypf1", //賬號 Password = "123456", //密碼 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, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json) } }, new TestUser { SubjectId = "002", Username = "ypf2", Password = "123456", 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, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json) } } }; } }
(4).在Startup類中注冊、啟用、修改路由
A.在ConfigureService中進行IDS4的注冊.
B.在Configure中啟用IDS4 app.UseIdentityServer();
C.路由,這里需要注意,不要和原Controllers里沖突即可,該項目中沒有Controllers文件夾,不要特別配置。
代碼分享:
public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews(); services.AddIdentityServer() .AddDeveloperSigningCredential() //生成Token簽名需要的公鑰和私鑰,存儲在bin下tempkey.rsa(生產場景要用真實證書,此處改為AddSigningCredential) .AddInMemoryApiScopes(Config1.GetApiScopes()) //存儲所有的scopes .AddInMemoryApiResources(Config1.GetApiResources()) //存儲需要保護api資源 .AddTestUsers(Config1.GetUsers()) //存儲用戶信息 .AddInMemoryClients(Config1.GetClients()); //存儲客戶端模式(即哪些客戶端可以用) } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Home/Error"); } app.UseStaticFiles(); app.UseRouting(); //啟用IDS4 app.UseIdentityServer(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); }); } }
(5).配置啟動端口,直接設置默認值: webBuilder.UseStartup<Startup>().UseUrls("http://127.0.0.1:7000");
(6).修改屬性方便調試:項目屬性→ 調試→應用URL(p),改為:http://127.0.0.1:7000 (把IISExpress和控制台啟動的方式都改了,方便調試)
圖:

(二).ResourceServer
(1).通過Nuget安裝 【IdentityServer4.AccessTokenValidation 3.0.1】
(2).在ConfigureService通過AddIdentityServerAuthentication連接ID4服務器,進行校驗,使用的是Bear認證方式。這里ApiName中的“ResourceServer”必須是ID4中GetApiResources中添加的。
特別注意:這個Authority要用127.0.0.1, 不用Localhost,因為我們獲取token的時候,使用的地址也是127.0.0.1,必須對應起來.
(3).在Config中添加認證中間件 app.UseAuthentication();
代碼分享:
public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { services.AddControllers(); //校驗AccessToken,從身份校驗中心(IDS4_Server2)進行校驗 services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme) //Bear模式 .AddIdentityServerAuthentication(options => { options.Authority = "http://127.0.0.1:7000"; // 1、授權中心地址 options.ApiName = "ResourceServer"; // 2、api名稱(項目具體名稱) options.RequireHttpsMetadata = false; // 3、https元數據,不需要 }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseRouting(); //認證中間件(服務於上ID4校驗,一定要放在UseAuthorization之前) app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } }
(4).新建一個GetMsg接口,並加上特性[Authorize]。
代碼分享:
[Route("api/[controller]/[action]")] [ApiController] public class HomeController : ControllerBase { /// <summary> /// 資源服務器的api /// </summary> /// <returns></returns> [Authorize] [HttpGet] public string GetMsg() { //快速獲取token的方式 string token = HttpContext.GetTokenAsync("access_token").Result; return $"ypf"; } }
(5).配置啟動端口,直接設置默認值: webBuilder.UseStartup<Startup>().UseUrls("http://127.0.0.1:7001");
(6).修改屬性方便調試:項目屬性→ 調試→應用URL(p),改為:http://127.0.0.1:7001 (把IISExpress和控制台啟動的方式都改了,方便調試)
圖:

(三).WinformClient1
(1).通過Nuget安裝【QRCoder 1.3.9】 【IdentityModel 4.3.0】
(2).請求IDS4服務器,拿到一個url,寫入二維碼,並顯示二維碼;客戶端此時在輪詢請求IDS4,看是否已經授權成功。
(3).正常應該用手機掃描二維碼,進行授權,這里為了方便演示, 用瀏覽器直接打開這個地址,代替手機掃描
eg:Process.Start(new ProcessStartInfo(deviceResponse.VerificationUriComplete) { UseShellExecute = true });
(4).授權成功后,客戶端拿着返回的accessToken,繼續請求api資源服務器,請求成功
代碼分享:
} private async void Form1_Load(object sender, EventArgs e) { var client = new HttpClient(); var disco = await client.GetDiscoveryDocumentAsync("http://127.0.0.1:7000"); //IDS4服務器 var deviceResponse = await client.RequestDeviceAuthorizationAsync(new DeviceAuthorizationRequest { Address = disco.DeviceAuthorizationEndpoint, ClientId = "client1", ClientSecret = "0001" }); //生成二維碼 CreateQrCode(deviceResponse.VerificationUriComplete); //通過瀏覽器打開地址 Process.Start(new ProcessStartInfo(deviceResponse.VerificationUriComplete) { UseShellExecute = true }); //輪詢請求 string accessToken; while (true) { // request token var tokenResponse = await client.RequestDeviceTokenAsync(new DeviceTokenRequest { Address = disco.TokenEndpoint, ClientId = "client1", ClientSecret = "0001", DeviceCode = deviceResponse.DeviceCode }); if (!tokenResponse.IsError) { accessToken = tokenResponse.AccessToken; break; } await Task.Delay(TimeSpan.FromSeconds(deviceResponse.Interval)); //await Task.Delay(TimeSpan.FromSeconds()); } await CallApiAsync(accessToken); } /// <summary> /// 請求api資源 /// </summary> /// <param name="token"></param> /// <returns></returns> private async Task CallApiAsync(string token) { // call api var apiClient = new HttpClient(); apiClient.SetBearerToken(token); var response = await apiClient.GetAsync("http://127.0.0.1:7001/api/Home/GetMsg"); if (!response.IsSuccessStatusCode) { var msg= response.Content.ReadAsStringAsync().Result; this.pictureBox1.Visible = false; //隱藏二維碼 this.label2.Text = msg; //顯示返回結果 //MessageBox.Show($"api返回值為:{msg}"); } else { var msg = response.Content.ReadAsStringAsync().Result; this.pictureBox1.Visible = false; //隱藏二維碼 this.label2.Text = msg; //顯示返回結果 //MessageBox.Show($"api返回值為:{msg}"); } } /// <summary> /// 生成二維碼 /// </summary> /// <param name="verificationUriComplete"></param> public void CreateQrCode(string verificationUriComplete) { QRCodeGenerator qrGenerator = new QRCodeGenerator(); QRCodeData qrCodeData = qrGenerator.CreateQrCode(verificationUriComplete, QRCodeGenerator.ECCLevel.Q); QRCode qrCode = new QRCode(qrCodeData); Bitmap qrCodeImage = qrCode.GetGraphic(6); this.pictureBox1.Image = Image.FromHbitmap(qrCodeImage.GetHbitmap()); }
窗體:

PS:詳細的流程剖析詳見下面的剖析測試
3.剖析測試
測試過程如下:
(PS:不知為啥fiddler捕捉不到winfrom發送的http請求,這里只能通過截圖來說明了)


!
- 作 者 : Yaopengfei(姚鵬飛)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 聲 明1 : 如有錯誤,歡迎討論,請勿謾罵^_^。
- 聲 明2 : 原創博客請在轉載時保留原文鏈接或在文章開頭加上本人博客地址,否則保留追究法律責任的權利。
