Skip to content

JWT

JWT เป็นหนึ่งในวิธีการรับรองความถูกต้องหลักของการสื่อสารเซิร์ฟเวอร์สมัยใหม่ มีน้ำหนักเบาและไม่มีสถานะ

บทนำ

JWT หรือชื่อเต็ม JSON Web Tokens สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ JWT สามารถดูได้ที่ [jwt.io](JSON Web Token Introduction - jwt.io) เป็นวิธีการส่งข้อมูลที่เปิดกว้าง ปลอดภัย และกะทัดรัด โดยใช้ JSON object เป็นตัวกลางระหว่างผู้ให้บริการ JWT มีลักษณะเด่นคือความปลอดภัยสูง ป้องกันการแก้ไขเนื้อหา และใช้ทรัพยากรต่ำ

โครงสร้าง

ตามมาตรฐาน RFC JWT ประกอบด้วยสามส่วนดังนี้:

  • Header ส่วนหัว
  • Payload ส่วนเนื้อหา
  • Signature ลายเซ็น

แต่ละส่วนคั่นด้วยจุด. และรวมกันเป็นสตริงในรูปแบบ header.payload.signature นี่คือโครงสร้างมาตรฐานของโทเค็น JWT ต่อไปจะอธิบายหน้าที่ของแต่ละส่วน

TIP

ควรทราบว่า base64 กับ base64URL ไม่ใช่รูปแบบการเข้ารหัสเดียวกัน อย่างหลังเข้ากันได้กับ URL ของเว็บและมีการ escape

ส่วนหัว

ส่วนหัวเพียงประกาศข้อมูลพื้นฐาน โดยปกติประกอบด้วยสองส่วน คือ ประเภทของโทเค็น และอัลกอริทึมการเข้ารหัสที่ใช้สำหรับลายเซ็น ตัวอย่างเช่น:

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

ข้อมูลข้างต้นหมายความว่า ประเภทโทเค็นคือ JWT อัลกอริทึมการเข้ารหัสที่ใช้สำหรับลายเซ็นคือ HS256 จากนั้นเข้ารหัส JSON object เป็นสตริงด้วย Base64Url ซึ่งเป็นส่วนหัวของ JWT

ส่วนเนื้อหา

ส่วนที่สองของ JWT คือส่วนเนื้อหา ประกอบด้วยส่วน claims (claims) โดย claims มักเป็นข้อมูลเกี่ยวกับเอนทิตี เช่น ผู้ใช้ ประเภทของ claims มีสามประเภท:

  • registered: Registered claims เป็น claims ที่กำหนดไว้ล่วงหน้า ไม่บังคับใช้แต่แนะนำให้ใช้ เช่น iss (ผู้ออก), exp (เวลาหมดอายุ), aud (ผู้รับ)
  • public: Public claims สามารถกำหนดโดยผู้ใช้ JWT ได้อย่างอิสระ ควรหลีกเลี่ยงความขัดแย้งกับ claims อื่นๆ
  • private claims: ส่วน claims นี้ก็เป็นการกำหนดเองเช่นกัน มักใช้สำหรับแชร์ข้อมูลระหว่างผู้ให้บริการ

ตัวอย่าง payload:

json
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

JSON object นี้จะถูกเข้ารหัสเป็นสตริงด้วย Base64Url เพื่อเป็นส่วนที่สองของ JWT

DANGER

แม้ว่าส่วน payload จะได้รับการป้องกันและป้องกันการแก้ไข แต่ส่วนนี้เป็นสาธารณะที่อ่านได้ ดังนั้นอย่าใส่ข้อมูลสำคัญลงใน JWT

ลายเซ็น

หลังจากได้ส่วนหัวและส่วน payload ที่เข้ารหัสแล้ว สามารถใช้アルกอริทึมลายเซ็นที่ระบุในส่วนหัวเพื่อเข้ารหัสด้วยคีย์ลับ ดังนั้นเมื่อเนื้อหาของ JWT มีการเปลี่ยนแปลง ลายเซ็นที่ได้จากการถอดรหัสจะไม่เหมือนกัน และหากใช้คีย์ส่วนตัวก็สามารถตรวจสอบผู้ออก JWT ได้

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

ตัวอย่างเช่น:

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

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

Verify Signature
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your secret
)

ผลลัพธ์สุดท้ายคือสตริงที่ประกอบด้วยสตริง base64Url สามส่วนคั่นด้วย. มีลักษณะดังนี้

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQ

หลักการทำงาน

ในการตรวจสอบสิทธิ์ เมื่อผู้ใช้เข้าสู่ระบบสำเร็จด้วยข้อมูลรับรอง จะส่งคืน JSON Web token เนื่องจากโทเค็นเป็นหลักฐานแสดงตัวตน จึงต้องระวังปัญหาความปลอดภัย โดยทั่วไปไม่ควรเก็บโทเค็นนานเกินความจำเป็น เมื่อผู้ใช้ต้องการเข้าถึงเส้นทางและทรัพยากรที่ป้องกัน ในการส่งคำขอจะต้อง携带โทเค็น มักอยู่ใน Authorization header ในรูปแบบ Bearer schema เช่น:

Authorization: Bearer <token>

เซิร์ฟเวอร์หลังจากได้รับ JWT จะตรวจสอบความถูกต้อง เช่น มีการแก้ไขเนื้อหา โทเค็นหมดอายุ ฯลฯ หากตรวจสอบผ่านก็จะเข้าถึงทรัพยากรได้ แม้ว่า JWT สามารถ携带ข้อมูลบางส่วนได้ แต่ก็แนะนำให้ข้อมูลไม่ใหญ่เกินไป

ไลบรารี JWT

ที่เก็บข้อมูลอย่างเป็นทางการ: golang-jwt/jwt: Community maintained clone of https://github.com/dgrijalva/jwt-go

เอกสารอย่างเป็นทางการ: jwt package - github.com/golang-jwt/jwt/v4 - Go Packages

ไลบรารีนี้รองรับการแยกวิเคราะห์ การตรวจสอบ การสร้าง และการเซ็น JWT อัลกอริทึมลายเซ็นที่รองรับ目前有 HMAC SHA, RSA, RSA-PSS และ ECDSA นอกจากนี้ยังสามารถเพิ่ม hook ของตัวเองได้

การติดตั้ง

go get -u github.com/golang-jwt/jwt/v4

การนำเข้า

go
import "github.com/golang-jwt/jwt/v4"

เลือกอัลกอริทึมลายเซ็น

มีอัลกอริทึมลายเซ็นหลายแบบ ควรเข้าใจความแตกต่างระหว่างพวกมันก่อนเลือกใช้อัลกอริทึมที่เหมาะสม ความแตกต่างที่สำคัญที่สุดคือการเข้ารหัสแบบสมมาตรและการเข้ารหัสแบบอสมมาตร

อัลกอริทึมการเข้ารหัสแบบสมมาตรที่ง่ายที่สุด HSA ทำให้ []byte ใดๆ สามารถใช้เป็นคีย์ที่มีประสิทธิภาพได้ ดังนั้นความเร็วในการคำนวณจึงเร็วกว่าเล็กน้อย เมื่อผู้ผลิตและผู้บริโภคทั้งสองฝ่ายเชื่อถือได้ อัลกอริทึมการเข้ารหัสแบบสมมาตรจะมีประสิทธิภาพสูงสุด อย่างไรก็ตาม เนื่องจากลายเซ็นและการตรวจสอบใช้คีย์เดียวกัน จึงไม่สามารถแจกจ่ายคีย์สำหรับการตรวจสอบได้ง่าย เนื่องจากคีย์สำหรับลายเซ็นก็เหมือนกัน หากคีย์ลายเซ็นรั่วไหล ความปลอดภัยของ JWT ก็ไม่มีความหมาย

วิธีการลายเซ็นแบบอสมมาตร เช่น RSA ใช้คีย์ที่แตกต่างกันสำหรับการเซ็นและการตรวจสอบโทเค็น ทำให้สามารถสร้างโทเค็นที่มีคีย์ส่วนตัวได้ และอนุญาตให้ผู้ที่มีคีย์สาธารณะเข้าถึงได้ตามปกติ

อัลกอริทึมลายเซ็นที่แตกต่างกันต้องการประเภทคีย์ที่แตกต่างกัน ด้านล่างนี้เป็นประเภทของอัลกอริทึมลายเซ็นทั่วไปบางตัว:

  • HMAC: การเข้ารหัสแบบสมมาตร ต้องการค่าประเภท []byte สำหรับลายเซ็นและการตรวจสอบ (HS256,HS384,HS512)
  • RSA: การเข้ารหัสแบบอสมมาตร ต้องการค่าประเภท *rsa.PrivateKey สำหรับลายเซ็น และค่าประเภท *rsa.PublicKey สำหรับการตรวจสอบ (RS256,RS384,RS512)
  • ECDSA: การเข้ารหัสแบบอสมมาตร ต้องการค่าประเภท *ecdsa.PrivateKey สำหรับลายเซ็น และค่าประเภท *ecdsa.PublicKey สำหรับการตรวจสอบ (ES256,ES384,ES512)
  • EdDSA: การเข้ารหัสแบบอสมมาตร ต้องการค่าประเภท ed25519.PrivateKey สำหรับลายเซ็นและค่าประเภท ed25519.PublicKey สำหรับการตรวจสอบ (Ed25519)

ตัวอย่าง

ด้านล่างนี้จะสาธิตตัวอย่างบางตัว เกี่ยวกับการสร้างและการเซ็น jwt รวมถึงการแยกวิเคราะห์และการตรวจสอบ

go
type Token struct {
  Raw       string                 // สตริง Token ดั้งเดิม กรอกฟิลด์นี้เมื่อเริ่มแยกวิเคราะห์
  Method    SigningMethod          // วิธีการที่ใช้สำหรับลายเซ็น
  Header    map[string]interface{} // ส่วน header ของ JWT
  Claims    Claims                 // ส่วน payload ของ JWT
  Signature string                 // ส่วนลายเซ็นของ JWT กรอกฟิลด์นี้เมื่อเริ่มแยกวิเคราะห์
  Valid     bool                   // JWT ถูกต้องหรือไม่
}

โครงสร้าง Token แสดงถึง JWT Token การใช้ฟิลด์ขึ้นอยู่กับว่า JWT ถูกสร้าง/เซ็นหรือแยกวิเคราะห์/ตรวจสอบอย่างไร

go
type RegisteredClaims struct {
   // the `iss` (Issuer) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
   Issuer string `json:"iss,omitempty"`

   // the `sub` (Subject) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2
   Subject string `json:"sub,omitempty"`

   // the `aud` (Audience) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
   Audience ClaimStrings `json:"aud,omitempty"`

   // the `exp` (Expiration Time) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
   ExpiresAt *NumericDate `json:"exp,omitempty"`

   // the `nbf` (Not Before) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
   NotBefore *NumericDate `json:"nbf,omitempty"`

   // the `iat` (Issued At) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
   IssuedAt *NumericDate `json:"iat,omitempty"`

   // the `jti` (JWT ID) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7
   ID string `json:"jti,omitempty"`
}

นี่คือ Claims ที่กำหนดไว้ล่วงหน้าในไลบรารี สามารถใช้ได้อย่างเหมาะสมเพื่อให้บรรลุความต้องการ

ตัวอย่างที่ 1. การสร้างและการเซ็นด้วย HMAC

go
func TestHmac(t *testing.T) {
   // ประเภทคีย์ของ hmac คืออาร์เรย์ไบต์
   secret := []byte("my secret")
   // ใช้อัลกอริทึม HS256, jwt.MapClaims คือ payload
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
      "id":   123456,
      "name": "jack",
   })
   fmt.Printf("%+v\n", *token)
   // เซ็น
   signedString, err := token.SignedString(secret)
   fmt.Println(signedString, err)
}

ผลลัพธ์:

{Raw: Method:0xc000008150 Header:map[alg:HS256 typ:JWT] Claims:map[id:123456 name:jack] Signature: Valid:false}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.
QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M
<nil>

ตัวอย่างที่ 2. ใช้ Claims ที่กำหนดไว้ล่วงหน้า

go
mySigningKey := []byte("AllYourBase")

// สร้าง Claims
claims := &jwt.RegisteredClaims{
  ExpiresAt: jwt.NewNumericDate(time.Unix(1516239022, 0)),
  Issuer:    "test",
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(mySigningKey)
fmt.Printf("%v %v", ss, err)

ผลลัพธ์:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpc3MiOiJ0ZXN0IiwiZXhwIjoxNTE2MjM5MDIyfQ.
0XN_1Tpp9FszFOonIBpwha0c_SfnNI22DhTnjMshPg8
<nil>

ตัวอย่างที่ 3. Claims แบบกำหนดเอง

go
type MyClaims struct {
   User string `json:"user"`
   jwt.RegisteredClaims
}

func TestCustomClaims(t *testing.T) {
   // สร้างคีย์
   secret := []byte("my secret")
   // สร้าง Claims
   claims := MyClaims{
      User: "114514",
      RegisteredClaims: jwt.RegisteredClaims{
         ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
         IssuedAt:  jwt.NewNumericDate(time.Now()),
         NotBefore: jwt.NewNumericDate(time.Now()),
         Issuer:    "Server",
      },
   }
   // สร้าง Token
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
   // เซ็น
   signedString, err := token.SignedString(secret)
   fmt.Println(signedString, err)
}

ผลลัพธ์:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyIjoiMTE0NTE0IiwiaXNzIjoiU2VydmVyIiwiZXhwIjoxNjczMDg1Nzk2LCJuYmYiOjE2NzMwODIxOTYsImlhdCI6MTY3MzA4MjE5Nn0.
PdPXdQBbDuYtE4ENXzoAcrW-dBSxqsufeYXCT5zTwVI
<nil>

TIP

เมื่อ embed Claims มาตรฐานใน Claims แบบกำหนดเอง ต้องแน่ใจว่า:

  1. Claims มาตรฐานที่ embed เป็นประเภทที่ไม่ใช่พอยน์เตอร์

  2. หากเป็นประเภทพอยน์เตอร์ ควรจัดสรรหน่วยความจำที่เหมาะสมก่อนส่งมิฉะนั้นจะเกิด panic

ตัวอย่างที่ 4. การแยกวิเคราะห์และการตรวจสอบโทเค็น HMAC

go
func TestParse(t *testing.T) {
   secret := []byte("my secret")
   // สมมติว่าสร้างและเซ็นด้วยอัลกอริทึม HS256 ได้ token
   tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"

   // ส่งสตริง token และฟังก์ชันตรวจสอบ hook ค่าที่ส่งกลับคือโครงสร้าง Token
   token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
      // ตรวจสอบว่าอัลกอริทึมลายเซ็นตรงกันหรือไม่
      if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
         return nil, fmt.Errorf("อัลกอริทึมลายเซ็นไม่ตรงกัน: %s", token.Header["alg"])
      }

      // ส่งคืนคีย์ตรวจสอบ
      return secret, nil
   })
   if err != nil {
      fmt.Println(token, err)
   }

   if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
      fmt.Println(claims)
   } else {
      fmt.Println(err)
   }

}

ผลลัพธ์:

map[id:123456 name:jack]

ตัวอย่างที่ 5. การจัดการข้อผิดพลาด

go
func TestProcess(t *testing.T) {
   secret := []byte("my secret")
   // สมมติว่าสร้างและเซ็นด้วยอัลกอริทึม HS256 ได้ token
   tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"

   // ส่งสตริง token และฟังก์ชันตรวจสอบ hook ค่าที่ส่งกลับคือโครงสร้าง Token
   token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
      // ตรวจสอบว่าอัลกอริทึมลายเซ็นตรงกันหรือไม่
      if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
         return nil, fmt.Errorf("อัลกอริทึมลายเซ็นไม่ตรงกัน: %s", token.Header["alg"])
      }
      // ส่งคืนคีย์ตรวจสอบ
      return secret, nil
   })

   if token.Valid {
      fmt.Println("token ถูกต้อง")
   } else if errors.Is(err, jwt.ErrTokenMalformed) {
      fmt.Println("สตริงที่ส่งเข้ามาไม่ใช่ token ด้วยซ้ำ...")
   } else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
      fmt.Println("token หมดอายุหรือยังไม่มีผล")
   } else {
      fmt.Println("token เกิดข้อผิดพลาด...")
   }
}

ผลลัพธ์:

token ถูกต้อง

ตัวอย่างที่ 6. การแยกวิเคราะห์ Claims แบบกำหนดเอง

หากใช้ Claims แบบกำหนดเองเมื่อสร้าง Token ในการแยกวิเคราะห์หากต้องการให้ Claims สามารถแปลงเป็น Claims แบบกำหนดเองได้โดยตรงแทนที่จะเป็น map จำเป็นต้องส่ง Claims แบบกำหนดเอง

go
func TestCustomClaimsParse(t *testing.T) {
  secret := []byte("my secret")
  tokenstring := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMTE0NTE0IiwiaXNzIjoiU2VydmVyIiwiZXhwIjoxNjczMDg4MDU2LCJuYmYiOjE2NzMwODQ0NTYsImlhdCI6MTY3MzA4NDQ1Nn0.T245aoDeL2x19X8_JZde0EmZ2TDyIgr1u3ddKFjQmgw"
  token, err := jwt.ParseWithClaims(tokenstring, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
    return secret, nil
  }, jwt.WithValidMethods([]string{"HS256"})) // ใช้ option สำหรับการตรวจสอบ

    // Type assertion
  if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
    fmt.Println(claims)
  } else {
    fmt.Println(err)
  }
}

ผลลัพธ์:

&{114514 {Server  [] 2023-01-07 18:40:56 +0800 CST 2023-01-07 17:40:56 +0800 CST 2023-01-07 17:40:56 +0800 CST }}

ตัวอย่างที่ 7. การเซ็นและการแยกวิเคราะห์ด้วย RSA

RSA มักใช้ในสถาปัตยกรรมแบบกระจาย กระบวนการโดยประมาณมีดังนี้:

  1. ศูนย์รับรองสร้างคู่คีย์ ใช้คีย์ส่วนตัวเพื่อเซ็น jwt ส่ง jwt กลับให้ลูกค้า ศูนย์ธุรกิจถือคีย์สาธารณะ
  2. ลูกค้า携带 jwt ส่งคำขอไปยังบริการธุรกิจ โมเดลธุรกิจใช้คีย์สาธารณะเพื่อแยกวิเคราะห์ jwt โดยไม่ต้องเข้าถึงศูนย์รับรอง
  3. หากการรับรองผ่านจะส่งคืนข้อมูลธุรกิจ
  4. หากการรับรองล้มเหลวจะส่งคืนข้อมูลล้มเหลว
go
func TestRsa(t *testing.T) {

   // สร้างคู่คีย์
   privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
   publicKey := &privateKey.PublicKey

   if err != nil {
      fmt.Println(err)
      return
   }

   // claims
   claims := MyClaims{
      User: "114514",
      RegisteredClaims: jwt.RegisteredClaims{
         ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
         IssuedAt:  jwt.NewNumericDate(time.Now()),
         NotBefore: jwt.NewNumericDate(time.Now()),
         Issuer:    "Server",
      },
   }

   token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
   // เข้ารหัสด้วยคีย์ส่วนตัว
   signedString, err := token.SignedString(privateKey)

   fmt.Println(signedString, err)

   // ถอดรหัสด้วยคีย์สาธารณะ
   token, err = jwt.ParseWithClaims(signedString, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
      return publicKey, nil
   })

   if err != nil {
      fmt.Println(err)
   } else if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
      fmt.Println(claims)
   }
}

Golang by www.golangdev.cn edit