TÌm hiểu về JSON Web Token

It's that time of the month again, time for yet another last minute report, in an desperate attemp to save our precious 2-days salary. And again, i wish this would be the last time i have to do so.

Tình cờ đọc lại một bài report của chính mình viết từ khá lâu trước đây Tản mạn về API design, mình đã viết một câu khá là chất

Nếu chỉ nhìn thoáng qua, từ flow xác thực, cho đến nguyên lí chung, JWT và OAuth2 khá là tương đồng, có khác chăng chỉ là về cấu trúc của mỗi loại token

thật sự là đọc xong câu này, cảm giác rất là (facepalm), đúng kiểu khi xưa ta bé ta ngu =)). Thế nên, trong khuôn khổ 2 ngày lương tháng này, xin mạn phép được viết chi tiết hơn một chút về JSON Web Token, để phần nào chữa thẹn cho tuyên bố sai lè trên kia.

JWT là gì

Nói về mặt định nghĩa chính thức, thì khái niệm Java Web Token được mô tả một cách đầy đủ nhất như sau

JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted.

Đọc hơi loằng ngoằng khó hiểu phải không, nếu chỉ dịch chay đoạn text này ra, chắc cũng chả ai hình dung ra được JWT là gì. Muốn nó trở nên dễ hiểu hơn một chút, ta hãy cùng tìm hiểu qua về cấu trúc của một JSON Web Token. Về mặt hình thức, rất dễ để nhận ra một JWT, tất cả chúng đều có dạng kiểu kiểu như thế này :

aaaaaaaaaa.bbbbbbbbbbb.cccccccccccc

Ta dễ dàng nhận ra, có 3 phần là 3 chuỗi kí tự, được ngăn cách với nhau bởi kí tự dấu . Và cũng không có gì là quá bất ngờ khi tôi nói với bận rằng, 3 phần này có ý nghĩa riêng của chúng, biểu thi cho những thông tin nhất định. Ta cùng đi vào tìm hiểu từng phần một

Header

Chuỗi kí tự đầu tiên trong một JWT biểu thị cho phần header. Thông thường thì phần header này sẽ bao gồm 2 thông tin, thứ nhất là loại của token ( trong trường hợp này là jwt ) , và thứ 2 là loại thuật toán mã hóa được sử dụng trong token ( cái này đến đoạn sau sẽ nói rõ dùng ở đâu). Xin được nói thêm một chút là bạn hoàn toàn có thể thêm các thông tin khác vào đây, ko sao cả, JWT bạn tạo ra có thể vẫn sẽ đúng, nhưng phần header, chỉ cần có 2 thông tin kia là đủ rồi. Các thông tin đó, sau khi được biểu diễn dưới dạng JSON object, chẳng hạn như

{
  "alg": "HS256",
  "typ": "JWT"
}

ta đưa vào Base64Encode. Và như vậy, ta đã có được phần đầu tiên, header của JWT. Ở đây xin mở rộng ra một chút , và cũng xin được nói trước là phần mở rộng này là "chém gió" của mình, nên xin cân nhắc suy xét trước khi tin, vì như ngay phần đầu tiên đã nói, các bài report của mình viết sai cũng không phải là hiếm =)) . Trước tiên, ta hiểu được tại sao lại là JSON, đơn giản có lẽ là vì JSON object là cách đơn giản nhất để biểu thị các thông tin mà ta cần truyền tải. Với header, lượng thông tin còn ít, nhưng sang payload, sẽ có rất nhiều thông tin ta cần phải đưa ra, JSON là một lựa chọn tốt để diễn đạt. Thêm nữa, tại sao lại cần phải đưa qua Base64Encode, cái này có lẽ đơn giản là để có thể thực hiện việc truyền qua header của HTML request.

Payload

Giống giống kiểu hồi bé học làm văn vâỵ, header có thể gọi là cái mở bài, giới thiệu qua về cái JWT của ta, còn payload này sẽ là phần thân bài, chứa nội dung chính, chủ yếu mà ta cần truyền tải. Hiểu một cách đơn giản thì là như thế, phần thứ hai - payload - của JWT sẽ chứa nội dung thông tin mà ta cần truyền đi, ví dụ như

{
  "author": "DungNQ",
  "handsome": true
}

Nói cụ thể hơn một chút, thì bên trong payload sẽ chứa các khai báo (claim). Các claim này sẽ bao gồm dữ liệu mà ta muốn truyền đi, và có thể cả các thông tin khác về token của ta nữa. Chia nhỏ ra thì claim bao gồm các loại như sau

  • Register Claim ( Reserved Claim ) : Các claim này , tuy không phải là bắt buộc , nhưng được khuyến khích là nên có thì tốt hơn. Là các claim đã được định nghĩa sẵn, thống nhất từ trước về mặt ý nghĩa, ai nhìn cũng có thể hiểu nó biểu hiện điều gì. Các claim này chứa những thông tin về bản thân token của chúng ta, chứ không phải là thông tin mình muốn truyền tải. Tuy nhiên , nó cũng tương đối đi vào chi tiết nên không thích hợp để đặt vào phần header - giới thiệu chung. Có thể kể ra một vài registered claim như : iss (issuer), exp (expiration time), sub (subject), aud (audience) ... Cụ thể , bạn thể có thể xem ở tài liệu này hay hàng hịn hơn, do đội Internet Engineering Task Force cung cấp . Vì là những claim được thống nhất sẵn, nên cũng chỉ có vài cái thôi, đọc cũng nhanh.

  • Private Claim: Ngược lại, các claim này do chúng ta tùy ý định nghĩa, để chứa những thông tin mà ta muốn truyền đi. Về mặt ý nghĩa của nó, chỉ cần người tạo ra token, và người nhận token hiểu với nhau là đủ. Như cái ví dụ củ chuối ở trên chẳng hạn, chính là một dạng private claim.

  • Public Claim: Có thể nói là trung gian hòa giải giữa 2 dạng trên, các claim này cũng do ta tùy ý định nghĩa để truyền tải thông tin mà mình muốn. Tuy nhiên, để cho dễ hiểu và tránh tình trạng conflict, các claim dạng này nên được khai báo tại IANA JSON Web Token Registry

Ngoài ra , ở đây còn có một khuyến khích nhỏ, là nếu có thể, tên của các claim, bạn nên giới hạn dùng một chuỗi 3 kí tự để đảm bảo tính tinh gọn của JWT, nhưng cái đó không quá quan trọng. Tương tự như phần Header, sau khi tạo ra một json object chứa các thông tin payload như trên, ta đem vào Base64Encode, và như thế, ta đã có được phần thứ 2 của JWT.

Signature

Như ở trên đã nói, giống kiểu khi ta làm văn vậy, Header là mở bài, Payload là thân bài, còn phần cuối cùng của một JWT - gọi là signature - cũng theo một nghĩa nào đó, làm nhiệm vụ kết bài, tổng kết lại cho 2 phần trước. Nhưng có thể nói đây là phần quan trọng nhất, là linh hồn của JWT. Những gì JWT làm được , như nhiệm vụ làm xác thực, hay đảm bảo tính toàn vẹn của dữ liệu, đều từ đây mà ra. Về cách tạo ra signature, ta đem những chuỗi encode tạo được bên trên, cộng với một chuỗi bí mật secret, sử dụng qua thuật toán mã hóa khai báo trong header, đem mã hóa và tạo thành chuỗi signature. VÍ dụ nếu ta dùng HMAC SHA256

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

Chuỗi secret sẽ là một chuỗi chỉ có người tạo ra token, và người mà họ muốn nhận được thôn tin , biết.

Làm việc với JWT

Qua việc tìm hiểu cấu trúc và cách tạo nên một JWT, ta có thể dễ dàng nhận ra những đặc điểm sau :

  • Bất kì ai, nếu có trong tay chuỗi secret, đều có thể tự tạo ra một JWT. Điều này có nghĩa là, khác với trong flow OAuth2, khi mà client cần liên lạc với resource server để nhận được access token, nếu bạn quyết định sử dụng JWT là công cụ để xác thực, bản thân client có thể tạo ra JWT cũng được.
  • Với mỗi request khác nhau, nghĩa là nội dung payload khác nhau, ta sẽ có các JWT khác nhau.
  • Bản thân bên trong JWT đã chứa payload, nghĩa là chứa nội dung cần truyền tải rồi, nên ta hoàn toàn có thể lấy được nội dung này chỉ từ bản thân token mà thôi
  • Với việc nội dung chuỗi secret tham gia vào quá trình tạo và giải mã JWT, nhưng không hề xuất hiện trong dữ liệu truyền đi, và chuỗi này chỉ có người gửi thông tin và người mà ta muốn gửi thông tin tới được biết, JWT có thể dùng để thực hiện công việc xác thực ( authorization )
  • Nội dung thông tin truyền tải đi ( payload ) được đánh dấu ( signed ) thông qua signature, nên JWT có thể được sử dụng trong kịch bản truyền tải dữ liệu bình thường, để đảm bảo tính toàn vẹn của dữ liệu được truyền đi.

Thế nên, ta có thể dựng nên một vài workflow khi làm việc với JWT :

Trong trường hợp xác thực với JWT

  • Phía gửi và phía nhận thông tin ( ví dụ như client và server ) cùng thống nhất trước với nhau một chuỗi secret . Một kịch bản thường gặp là, ta đăng kí user và password, sau đó sử dụng chính password làm chuỗi secret
  • Khi thực hiện việc đăng nhập cho user, phía client đã có được username và password
  • Khi thực hiện một request lên phía server, client chủ động đưa vào payload và dựng lên JWT. Lưu ý là trong nội dung payload nên có thông tin để định danh user, ví dụ như user id hay username. Kết hợp với password lấy được từ phía client, tạo ra payload.
  • Phía server nhận được request, lấy ra JWT từ header. Base64Decode để lấy được thông tin header và payload. Sau đó, dựa vào thông tin định danh ( username ) trong payload, lấy ra chuỗi secret ( password ) tương ứng lưu trên server. Server qua đó tạo nên signature và so sánh với signature của JWT do client gửi lên. Nếu giống nhau, ta có thể xác định danh tính của user là hợp lệ, và nội dung của request là nguyên bản.

Trong trường hợp đảm bảo tính toàn vẹn của dữ liệu truyền tải

Kịch bản xác thực bên trên, thực ra bao gồm trong nó đã đảm bảo tính toàn vẹn của dữ liệu rồi. Nếu muốn chỉ làm việc đó không, đơn giản chỉ là, phía nhận thông tin quy định sẵn một chuỗi secret. Và tất cả các phía muốn gửi thông tin lên, đều được cho biết trước chuỗi secret này. Qua đó tạo ra JWT tương ứng với mỗi gói tin. Cách xác định xem JWT này có hợp lệ ko, thực hiện tại bên nhận thông tin tương tự như trên