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
ส่วนหัว
ส่วนหัวเพียงประกาศข้อมูลพื้นฐาน โดยปกติประกอบด้วยสองส่วน คือ ประเภทของโทเค็น และอัลกอริทึมการเข้ารหัสที่ใช้สำหรับลายเซ็น ตัวอย่างเช่น:
{
"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:
{
"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การนำเข้า
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 รวมถึงการแยกวิเคราะห์และการตรวจสอบ
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 ถูกสร้าง/เซ็นหรือแยกวิเคราะห์/ตรวจสอบอย่างไร
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
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 ที่กำหนดไว้ล่วงหน้า
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 แบบกำหนดเอง
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 แบบกำหนดเอง ต้องแน่ใจว่า:
Claims มาตรฐานที่ embed เป็นประเภทที่ไม่ใช่พอยน์เตอร์
หากเป็นประเภทพอยน์เตอร์ ควรจัดสรรหน่วยความจำที่เหมาะสมก่อนส่งมิฉะนั้นจะเกิด panic
ตัวอย่างที่ 4. การแยกวิเคราะห์และการตรวจสอบโทเค็น HMAC
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. การจัดการข้อผิดพลาด
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 แบบกำหนดเอง
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 มักใช้ในสถาปัตยกรรมแบบกระจาย กระบวนการโดยประมาณมีดังนี้:
- ศูนย์รับรองสร้างคู่คีย์ ใช้คีย์ส่วนตัวเพื่อเซ็น jwt ส่ง jwt กลับให้ลูกค้า ศูนย์ธุรกิจถือคีย์สาธารณะ
- ลูกค้า携带 jwt ส่งคำขอไปยังบริการธุรกิจ โมเดลธุรกิจใช้คีย์สาธารณะเพื่อแยกวิเคราะห์ jwt โดยไม่ต้องเข้าถึงศูนย์รับรอง
- หากการรับรองผ่านจะส่งคืนข้อมูลธุรกิจ
- หากการรับรองล้มเหลวจะส่งคืนข้อมูลล้มเหลว
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)
}
}