在ASP.NET Core Web API中實作基于令牌(JWT)的身份驗證的小Demo
在上一篇部落格中,自己動手寫了一個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!!!
如果您認為這篇文章還不錯或者有所收獲,可以點選右下角的【推薦】按鈕,因為你的支援是我繼續寫作,分享的最大動力!
作者:Catcher Wong ( 黃文清 )
來源:http://catcher1994.cnblogs.com/
聲明:
本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接,否則保留追究法律責任的權利。如果您發現部落格中出現了錯誤,或者有更好的建議、想法,請及時與我聯系!!如果想找我私下交流,可以私信或者加我微信。