Json web token

介紹一下 jwt token 是怎麼一回事,並提供範例

JWT Token format

可以先到介紹 JWT 的網站https://jwt.io/,試玩一下 JWT token

Token 分為三部份,header.payload.signature

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0 NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9. TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

Part01 - header

屬性 說明
alg 加密採用何種演算法
typ 定義類型
1
{ "alg": "HS256", "typ": "JWT" }

透過base64編碼結果為eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9,這就是第一部份

Part02 - payload

實際上我們拿來存放資料的地方,當然也可以放自行定義的資料內容。例如:帳號、會員等級、暱稱等,RFC 7519 文件中也有預先定義,可以參考看看
REF:

  1. https://tools.ietf.org/html/rfc7519
  2. https://www.iana.org/assignments/jwt/jwt.xhtml
1
2
3
4
5
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

透過base64編碼結果為:eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9

part03 - signature

headerpayload兩部份的資料以.串接之後,透過設定之加密演算法使用金鑰產生簽章

1
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), "secret")

簽章為TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

JWT Token in CSharp

此處使用的是微軟的 JWT,使用之前需要先安裝 package

Install-Package System.IdentityModel.Tokens.Jwt

HMAC key

在這邊會需要使用到 HMAC 的 Key,對於 HMAC 這部分有興趣的人可以參考一下網路上的文章
此處所需要使用的 secret,直接透過隨機產生

1
2
3
var hmac = new HMACSHA256();
var key = Convert.ToBase64String(hmac.Key);
Console.WriteLine(key);

如果想要自行產生,請注意幾個要點

  1. 將金鑰儲存起來,必須使用base64格式
  2. 金鑰的內容必須要先經過編碼,編碼格式與你採用的 JWT 編碼相同

例如我想要設定我的金鑰是secret,所以經過base64之後,得到c2VjcmV0,因為我 JWT 採用 SHA256 編碼,所以接著將它再透過 SHA-256 hash 之後,結果為1c1185e02ff3e23b3e5a1c5bc86cf15d4126caa3dcde0fdb6e93adc4deec119e,最終就將這個字串放到 app.config 內就可以了

REF:

  1. 訊息鑑別技術
  2. HMAC Generator / Tester Tool

附加身份資訊

Claim這個類別可以用來存放一些我們想要在使用者身上綁定的一些相關資訊,MS 已經有做ClaimTypes Class提供常用的項目,所以我們可以直接拿來用就可以了
如果需要自己增添資訊,Claim的建構式也支援字串

1
2
3
4
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "art")
});

REF:

  1. .NET Framework 4.8: Claim Class
  2. .NET Framework 4.8: ClaimTypes Class

如何產生 Token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static readonly string _secret = "SjuXhiUgltqTPgJSBLn6Il2IsNN0WUydRVpjb8BkwoHcrEjQveFSNdBVW1NovijJXi1yFIlZxDbrm2ROMC8Fjw==";

public static string GenerateToken(ClaimsIdentity identity, int expireMinutes = 20)
{
var symmetricKey = Convert.FromBase64String(_secret);
var tokenHandler = new JwtSecurityTokenHandler();
var now = DateTime.UtcNow;

var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = identity,
Expires = now.AddMinutes(expireMinutes),
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(symmetricKey),
SecurityAlgorithms.HmacSha256Signature
)
};

var stoken = tokenHandler.CreateToken(tokenDescriptor);
var token = tokenHandler.WriteToken(stoken);
return token;
}
  1. 利用SecurityTokenDescriptor類別,準備相關資料
    1. 使用者身份資訊(ClaimsIdentity)
    2. Token 的有效期限
    3. 設定簽名憑證資料(SigningCredentials):在這一個步驟,需要使用金鑰,並且指定要採用何種加密演算法
  2. 利用JwtSecurityTokenHandler類別CreateToke(),產生 token
  3. 利用JwtSecurityTokenHandler類別WriteToken()取得緊湊的序列化格式的 Token,產生出來的 token 就會是像下面這樣的格式
1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

REF:

  1. [ASP.NET Web API] 實作 System.IdentityModel.Tokens.Jwt 進行身分驗證
  2. Azure SDK for .NET: JwtSecurityTokenHandler Class
  3. Azure SDK for .NET: SecurityTokenDescriptor Class

如何驗證 Token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private static readonly string _secret = "SjuXhiUgltqTPgJSBLn6Il2IsNN0WUydRVpjb8BkwoHcrEjQveFSNdBVW1NovijJXi1yFIlZxDbrm2ROMC8Fjw==";

public static bool TryValidateToken(string token, out ClaimsPrincipal principal)
{
principal = null;
try
{
var tokenHandler = new JwtSecurityTokenHandler();
var jwtToken = tokenHandler.ReadToken(token);
if (jwtToken == null) return false;

var symmetricKey = Convert.FromBase64String(_secret);

var validationParameters = new TokenValidationParameters()
{
RequireExpirationTime = true,
ValidateIssuer = false,
ValidateAudience = false,
IssuerSigningKey = new SymmetricSecurityKey(symmetricKey)
};

SecurityToken securityToken;
principal = tokenHandler.ValidateToken(token, validationParameters, out securityToken);

return true;
}
catch (Exception)
{
return false;
}
}
  1. 先讀取 token 內容,看看有沒有東西,此步驟可透過ReadToken()或是ReadJwtToken()來判斷回傳值是否為null
  2. 利用TokenValidationParameters類別,準備相關資料
    1. 需要驗證 token 是否必須具備時效(expirationTime)
    2. 不針對 token 進行發行者(iss)的驗證,因為我們在產生 token 的時候也沒有建立發行者的資訊,iss 為可選項目
    3. 不針對 token 進行 Audience 的驗證
    4. 設定簽名驗證所使用的金鑰
  3. 利用JwtSecurityTokenHandler類別ValidateToken()方法對 Token 進行驗證,回傳值為類別ClaimsPrincipal

最終取得的claimsPrincipal,就可以透過Identity.IsAuthenticated屬性,取得是否經過授權等等的資訊了

REF:

  1. RFC 7519
  2. Azure SDK for .NET: TokenValidationParameters Class
  3. Azure SDK for .NET: ClaimsPrincipal Class

如何透過 Attribute 驗證 JWT Token

如果採用的是 WebAPI 專案,應該是透過繼承AuthorizationFilterAttribute類別,並複寫其中的OnAuthorization方法實作;如果是一般的 MVC 網站,那麼繼承AuthorizeAttribute後,複寫OnAuthorization即可

有一種情況是,nuget 已經有安裝了 Microsoft aspnet.WebApi.Core 5.2.3,但是在參考那邊卻沒有引用到System.Web.Http,解決辦法是重新安裝該套件,或者是直接從 nuget 安裝 System.Web.http 這個套件

1
PM> Update-Package Microsoft.AspNet.WebApi.Core -reinstall
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

public class JWTAuthAttribute : AuthorizeAttribute
{
/// <summary>
/// 系統權限Module
/// </summary>
private ISystemAuthorityModule _systemAuthorityModule;

protected ISystemAuthorityModule SystemAuthorityModule
{
get => this._systemAuthorityModule ?? (this._systemAuthorityModule = ModuleFactory.GetSystemAuthorityModule());
set => this._systemAuthorityModule = value;
}

protected override bool AuthorizeCore(HttpContextBase httpContext)
{
// 解析header取得principal
var principal = ParseAuthorizeHeader(httpContext);
if (principal == null) return false;

// 取得員工編號
var employeeNo = int.Parse(principal.FindFirst(ClaimTypes.NameIdentifier).Value);
if (employeeNo == 0) return false;

// 驗證權限
var controller = Convert.ToString(httpContext.Request.RequestContext.RouteData.Values["controller"]);
var action = Convert.ToString(httpContext.Request.RequestContext.RouteData.Values["action"]);
var urls = new[] { $"/{controller}", $"/{controller}/{action}" };
var result = this.SystemAuthorityModule.ValidateUserFunction(employeeNo, urls);

return result;
}

private ClaimsPrincipal ParseAuthorizeHeader(HttpContextBase httpContext)
{
var token = httpContext.Request.Headers["Authorization"];
if (string.IsNullOrEmpty(token)) return null;

token = token.Replace("Bearer ", string.Empty);
var principal = JWTHelper.GetPrincipal(token);

return principal;
}
}

如何於 Controller 中套用 Attribute 進行 JWT token 的驗證

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class JWTController : Controller
{
/// <summary>
/// 使用者Module
/// </summary>
private readonly Lazy<IUserModule> _userModule = new Lazy<IUserModule>(ModuleFactory.GetUserModule);

protected IUserModule UserModule => this._userModule.Value;

[HttpPost]
public JsonResult GetToken(LoginForm loginForm)
{
// 輸入驗證
if (string.IsNullOrEmpty(loginForm.Account) || string.IsNullOrEmpty(loginForm.Password))
{
return Json(new APIResult() { Message = "請輸入帳號密碼", IsSuccess = false });
}

// 驗證使用者
var user = UserModule.VerifyUser(loginForm.Account, loginForm.Password);
if (user == null)
{
return Json(new APIResult() { Message = "帳號密碼錯誤", IsSuccess = false });
}

// 產生token
var identity = new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, user.EmployeeNo.ToString()),
new Claim(ClaimTypes.Name, user.UserName),
new Claim("Account", user.Account),
new Claim(ClaimTypes.Email, user.EmailAccount),
});
var token = JWTHelper.GenerateToken(identity);

return Json(new APIResult() { Data = token, IsSuccess = true, Message = string.Empty });
}

[HttpPost]
[JWTAuth]
public JsonResult FeatureA()
{
return Json(new APIResult() { IsSuccess = true, Message = "允許執行" });
}

[HttpPost]
[JWTAuth]
public JsonResult FeatureB()
{
return Json(new APIResult() { IsSuccess = true, Message = "不允許執行" });
}
}

REF:

  1. ASP.NET Web API Unit Test 出現需要加入 System.Web.Http 參考錯誤
  2. A WebAPI Basic Authentication Authorization Filter

如何測試

對於完成的功能,如果需要進行測試,當然會希望知道實際上運作是否正確,一般來說撰寫單元測試的目的是為了確保某一個類別、方法的職責如我們預期般運作,但如果是要測試網站真的能不能用 jwt,當然最實際的做法就是把網站真的跑起來測試看看

透過 OWIN SelfHost

透過 OWIN 可以利用程式啟動你的 WebAPI 網站,細節可以看一下外部參考連結,因為這個方式我並沒有實作,所以也就無法分享一些實際經驗了,特別推薦一下余小章的相關文章,這系列含金量很高,而且都貼心的包含了 Sample Code

REF:

  1. [Web API] 使用 OWIN 進行單元測試
  2. 使用 OWIN 自我裝載 ASP.NET Web API

手動啟動

另外的方式當然就是在跑測試的時候將網站先手動啟動,然後再去執行測試,這樣的方式僅適合用來開發時期驗證一下,以現在專案大多都與 CI Server 整合在一起的情況,應該是不太能夠在 CI Server 上還在每次 Build 都先手動掛載網站;不過測試程式還有一個很棒的意義,就是提供人家理解,怎麼去使用你所開發的程式,相當於使用說明書了,如果你的程式介面沒有高竿到讓人一看簽章方法就能用,還是提供一下說明書比較好些

測試案例撰寫

產生 Token

因為執行後產生的 token 之中,在每一次執行都不一樣,因為 payload 的值透過 MS 的 JWT 類別處理都會加上nbfiat,再加上我們設定的exp,這幾個都是每次都不同的,所以也很難對他執行單元測試(除非改用別的 nuget 套件應該可以完全自訂要產生哪些內容),所以這裡的測試程式其實比較像是一個說明書,並沒有驗證任何東西,只是單純地將結果 Console 出來而已

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[TestMethod()]
public void GenerateToken()
{
var user = GetTestUser();
var identity = new ClaimsIdentity(
new [] {
new Claim(ClaimTypes.NameIdentifier, user.EmployeeNo.ToString()),
new Claim(ClaimTypes.Name, user.UserName),
new Claim("Account",user.UserName),
new Claim(ClaimTypes.Email, user.EmailAccount),
});
var token = JWTHelper.GenerateToken(identity);
Console.WriteLine(token);
}
private User GetTestUser()
{
return new User()
{
Account = "spiderman",
EmailAccount = "spiderman@email.com",
UserName = "蜘蛛人",
EmployeeNo = 123456
};
}
1
{"alg":"HS256","typ":"JWT"}.{"nameid":"123456","unique_name":"蜘蛛人","Account":"spiderman","email":"spiderman@email.com","nbf":1573090246,"exp":1573091446,"iat":1573090246}

exp (Expiration Time) - jwt 的過期時間,這個過期時間必須要大於簽發時間
nbf (Not Before) - 定義在什麼時間之前,該 jwt 都是不可用的
iat (Issued At) - jwt 的簽發時間

對於這幾個 payload 的參數可以看一下 RFC 文件

解析 token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[TestMethod]
public void ParseToken()
{
var expected = GetTestUser();
var token = GenerateTestToken();
var principal = JWTHelper.GetPrincipal(token);
Assert.AreEqual(expected.UserName, principal.Identity.Name);
Assert.AreEqual(expected.EmployeeNo.ToString(), principal.FindFirst(ClaimTypes.NameIdentifier).Value);
Assert.AreEqual(expected.Account, principal.FindFirst("Account").Value);
Assert.AreEqual(expected.EmailAccount, principal.FindFirst(ClaimTypes.Email).Value);
}

private string GenerateTestToken()
{
var user = GetTestUser();
var identity = new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, user.EmployeeNo.ToString()),
new Claim(ClaimTypes.Name, user.UserName),
new Claim("Account", user.Account),
new Claim(ClaimTypes.Email, user.EmailAccount),
});
var token = JWTHelper.GenerateToken(identity);
return token;
}

private User GetTestUser()
{
return new User()
{
Account = "spiderman",
EmailAccount = "spiderman@email.com",
UserName = "蜘蛛人",
EmployeeNo = 123456
};
}

呼叫 API

這邊是實際去利用 HttpClient 呼叫網站的方法,所以網站要先啟起來,而透過 HttpClient 若要帶 token,則可以利用_client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");的方式附加於 Header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/// <summary>
/// API測試
/// </summary>
[TestClass]
public class ApiTest
{
// 參考文章:https://dotblogs.com.tw/yc421206/2019/01/07/authentication_via_jwt-dotnet
// Code:https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/JWT/MsJwt/Client/UnitTest1.cs

private const string Host = "http://localhost:17459";
private static HttpClient _client;

[TestInitialize]
public void BeforeEach()
{
_client = new HttpClient { BaseAddress = new Uri(Host) };
}

[TestMethod]
public void 無token執行回應unauthorized()
{
var queryUrl = "JWT/FeatureB";

var queryResponse = _client.PostAsync(queryUrl, null).Result;
Assert.AreEqual(HttpStatusCode.Unauthorized, queryResponse.StatusCode);
}

[TestMethod]
public void 有token無權限執行回應unauthorized()
{
var queryUrl = "JWT/FeatureB";
var token = GenerateToken();
_client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
var queryResponse = _client.PostAsync(queryUrl, null).Result;
Assert.AreEqual(HttpStatusCode.Unauthorized, queryResponse.StatusCode);
}

[TestMethod]
public void 有權限執行回應ok()
{
var queryUrl = "JWT/FeatureA";
var token = GenerateToken();
_client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
var queryResponse = _client.PostAsync(queryUrl, null).Result;
Assert.AreEqual(HttpStatusCode.OK, queryResponse.StatusCode);
}

private string GenerateToken()
{
var identity = new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, "123456"),
new Claim(ClaimTypes.Name, "蜘蛛人"),
new Claim("Account", "spiderman"),
new Claim(ClaimTypes.Email, "spiderman@email.com"),
});
var token = JWTHelper.GenerateToken(identity);
return token;
}
}

安全性

最近剛好看到兩篇文章,一篇是關於 JWT 的資安相關的:Hacking JSON Web Tokens (JWTs),另外一篇是Web API 開發心得 (7) - 使用 Token 進行 API 授權驗證,借鑑 JWT 機制,實作自己的規則,這兩篇都很有趣,我也想動手嘗試一下

第一篇文章中的Token signed with key A -> Token verified with key B (RSA scenario)這個驗證,我嘗試了一個晚上還是做不出來 POC,我想是因為相關知識太薄弱了,時間有限,我決定放棄這件事情,改為研究第二篇文章所說的,自訂編碼方式將 token 的 payload 加密

對於實作細節想了解的人,可以參考一下連結,文章最後也有附上我的練習 Code

這一篇文章對我而言較為容易理解,在實作的部分程式碼則是全部照搬,概念懂了再透過逐步偵錯來學習一下,最終終於做出來了

這兩種方式都有其優劣,畢竟使用 jwt 的場合情境很多,也許有的情況就合適採用原版的,像是提供服務給第三方合作夥伴的,JWT 畢竟是一個通用的協議,對於開發人員來說應該也是較容易的,而且在各種語言都有很多已經實作出來的 lib 可以使用,對於快速開發是很有利的,也省去測試、驗證演算法的時間

不過如果只是小型網站,或是不需要與外部介接的服務,採用自行設計的演算法編碼規則,或許是個不錯的選項 (?)

結論

正如訊息鑑別技術一文所說

加密做的是:保證資料的安全性
驗證做的是:保證資料的完整性

JWT token 既然是採用base64編碼而已,實際上其實也就相當於明文,使用 jwt 我們只能確保我們所傳送的資料是完整的,沒有被別人竄改過,但是卻不能保證他的機密性,所以在使用 jwt token 的時候,敏感性資訊不要放在裡面,僅提供一些用來識別的 key 值,最後再從後端去資料庫拉所需要的資料比較好

Github Sample Code

JWTDemoSite