用JWT來保護我們的ASP.NET Core Web API


   在上一篇博客中,自己動手寫了一個Middleware來處理API的授權驗證,現在就采用另外一種方式來處理這個授權驗證的問題,畢竟現在也

有不少開源的東西可以用,今天用的是JWT。

  什么是JWT呢?JWT的全稱是JSON WEB TOKENS,是一種自包含令牌格式。官方網址:https://jwt.io/,或多或少應該都有聽過這個。

  先來看看下面的兩個圖:

  站點是通過RPC的方式來訪問api取得資源的,當站點是直接訪問api,沒有拿到有訪問權限的令牌,那么站點是拿不到相關的數據資源的。

就像左圖展示的那樣,發起了請求但是拿不到想要的結果;當站點先去授權服務器拿到了可以訪問api的access_token(令牌)后,再通過這個

access_token去訪問api,api才會返回受保護的數據資源。

  這個就是基於令牌驗證的大致流程了。可以看出授權服務器占着一個很重要的地位。

  下面先來看看授權服務器做了些什么並如何來實現一個簡單的授權。

  做了什么?授權服務器在整個過程中的作用是:接收客戶端發起申請access_token的請求,並校驗其身份的合法性,最終返回一個包含

access_token的json字符串。

  如何實現?我們還是離不開中間件這個東西。這次我們寫了一個TokenProviderMiddleware,主要是看看invoke方法和生成access_token

的方法。

 1         /// <summary>
 2         /// invoke the middleware
 3         /// </summary>
 4         /// <param name="context"></param>
 5         /// <returns></returns>
 6         public async Task Invoke(HttpContext context)
 7         {           
 8             if (!context.Request.Path.Equals(_options.Path, StringComparison.Ordinal))
 9             {
10                 await _next(context);
11             }
12 
13             // Request must be POST with Content-Type: application/x-www-form-urlencoded
14             if (!context.Request.Method.Equals("POST")
15                || !context.Request.HasFormContentType)
16             {
17                 await ReturnBadRequest(context);             
18             }
19             await GenerateAuthorizedResult(context);
20         }

 

  Invoke方法其實是不用多說的,不過我們這里是做了一個控制,只接收POST請求,並且是只接收以表單形式提交的數據,GET的請求和其

他contenttype類型是屬於非法的請求,會返回bad request的狀態。

  下面說說授權中比較重要的東西,access_token的生成。

 1         /// <summary>
 2         /// get the jwt
 3         /// </summary>
 4         /// <param name="username"></param>
 5         /// <returns></returns>
 6         private string GetJwt(string username)
 7         {
 8             var now = DateTime.UtcNow;
 9 
10             var claims = new Claim[]
11             {
12                 new Claim(JwtRegisteredClaimNames.Sub, username),
13                 new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
14                 new Claim(JwtRegisteredClaimNames.Iat, now.ToUniversalTime().ToString(),
15                           ClaimValueTypes.Integer64)
16             };
17 
18             var jwt = new JwtSecurityToken(
19                 issuer: _options.Issuer,
20                 audience: _options.Audience,
21                 claims: claims,
22                 notBefore: now,
23                 expires: now.Add(_options.Expiration),
24                 signingCredentials: _options.SigningCredentials);
25             var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);
26 
27             var response = new
28             {
29                 access_token = encodedJwt,
30                 expires_in = (int)_options.Expiration.TotalSeconds,
31                 token_type = "Bearer"
32             };   
33             return JsonConvert.SerializeObject(response, new JsonSerializerSettings { Formatting = Formatting.Indented });
34         }
  

  claims包含了多個claim,你想要那幾個,可以根據自己的需要來添加,JwtRegisteredClaimNames是一個結構體,里面包含了所有的可選項。

 1     public struct JwtRegisteredClaimNames
 2     {
 3         public const string Acr = "acr";
 4         public const string Actort = "actort";
 5         public const string Amr = "amr";
 6         public const string AtHash = "at_hash";
 7         public const string Aud = "aud";
 8         public const string AuthTime = "auth_time";
 9         public const string Azp = "azp";
10         public const string Birthdate = "birthdate";
11         public const string CHash = "c_hash";
12         public const string Email = "email";
13         public const string Exp = "exp";
14         public const string FamilyName = "family_name";
15         public const string Gender = "gender";
16         public const string GivenName = "given_name";
17         public const string Iat = "iat";
18         public const string Iss = "iss";
19         public const string Jti = "jti";
20         public const string NameId = "nameid";
21         public const string Nbf = "nbf";
22         public const string Nonce = "nonce";
23         public const string Prn = "prn";
24         public const string Sid = "sid";
25         public const string Sub = "sub";
26         public const string Typ = "typ";
27         public const string UniqueName = "unique_name";
28         public const string Website = "website";
29     }
JwtRegisteredClaimNames

還需要一個JwtSecurityToken對象,這個對象是至關重要的。有了時間、Claims和JwtSecurityToken對象,只要調用JwtSecurityTokenHandler

的WriteToken就可以得到類似這樣的一個加密之后的字符串,這個字符串由3部分組成用‘.’分隔。每部分代表什么可以去官網查找。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

  最后我們要用json的形式返回這個access_token、access_token的有效時間和一些其他的信息。

  還需要在Startup的Configure方法中去調用我們的中間件。

 1             var audienceConfig = Configuration.GetSection("Audience");
 2             var symmetricKeyAsBase64 = audienceConfig["Secret"];
 3             var keyByteArray = Encoding.ASCII.GetBytes(symmetricKeyAsBase64);
 4             var signingKey = new SymmetricSecurityKey(keyByteArray);
 5 
 6             app.UseTokenProvider(new TokenProviderOptions
 7             {
 8                 Audience = "Catcher Wong",
 9                 Issuer = "http://catcher1994.cnblogs.com/",
10                 SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256),
11             });

 

  到這里,我們的授權服務站點已經是做好了。下面就編寫幾個單元測試來驗證一下這個授權。

  測試一:授權服務站點能生成正確的jwt。

 1         [Fact]
 2         public async Task authorized_server_should_generate_token_success()
 3         {
 4             //arrange
 5             var data = new Dictionary<string, string>();
 6             data.Add("username", "Member");
 7             data.Add("password", "123");
 8             HttpContent ct = new FormUrlEncodedContent(data);
 9 
10             //act
11             System.Net.Http.HttpResponseMessage message_token = await _client.PostAsync("http://127.0.0.1:8000/auth/token", ct);
12             string res = await message_token.Content.ReadAsStringAsync();
13             var obj = Newtonsoft.Json.JsonConvert.DeserializeObject<Token>(res);
14 
15             //assert
16             Assert.NotNull(obj);
17             Assert.Equal("600", obj.expires_in);
18             Assert.Equal(3, obj.access_token.Split('.').Length);
19             Assert.Equal("Bearer", obj.token_type);
20         }

 

  測試二:授權服務站點因為用戶名或密碼不正確導致不能生成正確的jwt。

 1         [Fact]
 2         public async Task authorized_server_should_generate_token_fault_by_invalid_app()
 3         {
 4             //arrange
 5             var data = new Dictionary<string, string>();
 6             data.Add("username", "Member");
 7             data.Add("password", "123456");
 8             HttpContent ct = new FormUrlEncodedContent(data);
 9 
10             //act
11             System.Net.Http.HttpResponseMessage message_token = await _client.PostAsync("http://127.0.0.1:8000/auth/token", ct);
12             var res = await message_token.Content.ReadAsStringAsync();
13             dynamic obj = Newtonsoft.Json.JsonConvert.DeserializeObject(res);
14 
15             //assert
16             Assert.Equal("invalid_grant", (string)obj.error);
17             Assert.Equal(HttpStatusCode.BadRequest, message_token.StatusCode);
18         }

 

  測試三:授權服務站點因為不是發起post請求導致不能生成正確的jwt。

 1         [Fact]
 2         public async Task authorized_server_should_generate_token_fault_by_invalid_httpmethod()
 3         {
 4             //arrange
 5             Uri uri = new Uri("http://127.0.0.1:8000/auth/token?username=Member&password=123456");
 6 
 7             //act
 8             System.Net.Http.HttpResponseMessage message_token = await _client.GetAsync(uri);
 9             var res = await message_token.Content.ReadAsStringAsync();
10             dynamic obj = Newtonsoft.Json.JsonConvert.DeserializeObject(res);
11 
12             //assert
13             Assert.Equal("invalid_grant", (string)obj.error);
14             Assert.Equal(HttpStatusCode.BadRequest, message_token.StatusCode);
15         }

 

  再來看看測試的結果:
   
  都通過了。

  斷點拿一個access_token去http://jwt.calebb.net/ 解密看看

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJNZW1iZXIiLCJqdGkiOiI2MzI1MmE1My0yMjY5LTQ4YzEtYmQwNi1lOWRiMzdmMTRmYTQiLCJpYXQiOiIyMDE2LzExLzEyIDI6NDg6MTciLCJuYmYiOjE0Nzg5MTg4OTcsImV4cCI6MTQ3ODkxOTQ5NywiaXNzIjoiaHR0cDovL2NhdGNoZXIxOTk0LmNuYmxvZ3MuY29tLyIsImF1ZCI6IkNhdGNoZXIgV29uZyJ9.Cu2vTJ4JAHgbJGzwv2jCmvz17HcyOsRnTjkTIEA0EbQ

  下面就是API的開發了。

  這里是直接用了新建API項目生成的ValueController作為演示,畢竟跟ASP.NET Web API是大同小異的。這里的重點是配置

JwtBearerAuthentication,這里是不用我們再寫一個中間件了,我們是定義好要用的Option然后直接用JwtBearerAuthentication就可以了。

 1         public void ConfigureJwtAuth(IApplicationBuilder app)
 2         {            
 3             var audienceConfig = Configuration.GetSection("Audience");
 4             var symmetricKeyAsBase64 = audienceConfig["Secret"];
 5             var keyByteArray = Encoding.ASCII.GetBytes(symmetricKeyAsBase64);
 6             var signingKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(keyByteArray);            
 7 
 8             var tokenValidationParameters = new TokenValidationParameters
 9             {
10                 // The signing key must match!
11                 ValidateIssuerSigningKey = true,
12                 IssuerSigningKey = signingKey,
13 
14                 // Validate the JWT Issuer (iss) claim
15                 ValidateIssuer = true,
16                 ValidIssuer = "http://catcher1994.cnblogs.com/",
17 
18                 // Validate the JWT Audience (aud) claim
19                 ValidateAudience = true,
20                 ValidAudience = "Catcher Wong",
21 
22                 // Validate the token expiry
23                 ValidateLifetime = true,
24          
25                 ClockSkew = TimeSpan.Zero
26             };
27 
28             app.UseJwtBearerAuthentication(new JwtBearerOptions
29             {
30                 AutomaticAuthenticate = true,
31                 AutomaticChallenge = true,
32                 TokenValidationParameters = tokenValidationParameters,
33             });                        
34         }

 

  然后在Startup的Configure中調用上面的方法即可。

1         public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
2         {
3             loggerFactory.AddConsole(Configuration.GetSection("Logging"));
4             loggerFactory.AddDebug();
5 
6             ConfigureJwtAuth(app);
7 
8             app.UseMvc();
9         }

 

  到這里之后,大部分的工作是已經完成了,還有最重要的一步,在想要保護的api上加上Authorize這個Attribute,這樣Get這個方法就會要

求有access_token才會返回結果,不然就會返回401。這是在單個方法上的,也可以在整個控制器上面添加這個Attribute,這樣控制器里面的方

法就都會受到保護。

1         // GET api/values/5
2         [HttpGet("{id}")]
3         [Authorize]
4         public string Get(int id)
5         {
6             return "value";
7         }

 

  OK,同樣編寫幾個單元測試驗證一下。

  測試一:valueapi在沒有授權的請求會返回401狀態。

 1         [Fact]
 2         public void value_api_should_return_unauthorized_without_auth()
 3         {           
 4             //act         
 5             HttpResponseMessage message = _client.GetAsync("http://localhost:63324/api/values/1").Result;
 6             string result = message.Content.ReadAsStringAsync().Result;
 7          
 8             //assert
 9             Assert.False(message.IsSuccessStatusCode);
10             Assert.Equal(HttpStatusCode.Unauthorized,message.StatusCode);
11             Assert.Empty(result);
12         }

  

   測試二:valueapi請求沒有[Authorize]標記的方法時能正常返回結果。

 1         [Fact]
 2         public void value_api_should_return_result_without_authorize_attribute()
 3         {
 4             //act         
 5             HttpResponseMessage message = _client.GetAsync("http://localhost:63324/api/values").Result;
 6             string result = message.Content.ReadAsStringAsync().Result;
 7             var res = Newtonsoft.Json.JsonConvert.DeserializeObject<string[]>(result);
 8 
 9             //assert
10             Assert.True(message.IsSuccessStatusCode);
11             Assert.Equal(2, res.Length);
12         }

  

   測試三:valueapi在授權的請求中會返回正確的結果。

 1         [Fact]
 2         public void value_api_should_success_by_valid_auth()
 3         {
 4             //arrange
 5             var data = new Dictionary<string, string>();
 6             data.Add("username", "Member");
 7             data.Add("password", "123");
 8             HttpContent ct = new FormUrlEncodedContent(data);
 9 
10             //act
11             var obj = GetAccessToken(ct);                        
12             _client.DefaultRequestHeaders.Add("Authorization", "Bearer " + obj.access_token);
13             HttpResponseMessage message = _client.GetAsync("http://localhost:63324/api/values/1").Result;
14             string result = message.Content.ReadAsStringAsync().Result;
15 
16             //assert
17             Assert.True(message.IsSuccessStatusCode);
18             Assert.Equal(3, obj.access_token.Split('.').Length);
19             Assert.Equal("value",result);            
20         }

 

   再來看看測試的結果:

  

  測試通過。

  再通過瀏覽器直接訪問那個受保護的方法。響應頭就會提示www-authenticate:Bearer,這個是身份驗證的質詢,告訴客戶端必須要提供相

應的身份驗證才能訪問這個資源(api)。

   

   這也是為什么在單元測試中會添加一個Header的原因,正常的使用也是要在請求的報文頭中加上這個。

   _client.DefaultRequestHeaders.Add("Authorization", "Bearer " + obj.access_token); 

  其實看一下源碼,更快知道為什么。JwtBearerHandler.cs

  下圖是關於頭部加Authorization的源碼解釋。
 
  

 

  JwtBearer的源碼: Microsoft.AspNetCore.Authentication.JwtBearer
 
  本文的示例代碼: JWTTokenDemo
 
 
   Thanks for your reading!!!
 


免責聲明!

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



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