กลับไปยังบทความ

Backend

ทำ JWT Authentication และ Casbin RBAC ใน Gin: Login, Middleware และสิทธิ์ตาม Role

ป้องกัน Gin API ด้วย JWT และ Casbin ตั้งแต่หลักการทำงานของ middleware, hash password ด้วย bcrypt, login ด้วย gin-jwt, อ่าน payload จาก token, เขียน Casbin RBAC policy และป้องกัน route group

24 กันยายน 256910 นาทีในการอ่าน

jwt authentication and casbin rbac in gin

Authentication ตอบคำถามว่า "ใครเป็นคนเรียก" ส่วน authorization ตอบว่า "เขามีสิทธิ์ทำสิ่งนี้ไหม" ใน Gin API วิธีที่สะอาดในการจัดการทั้งสองเรื่องคือใช้ Gin JWT คู่กับ Casbin โดย gin-jwt ทำหน้าที่ออกและตรวจ JSON Web Token ตอน login ส่วน Casbin ตัดสินว่า role ของผู้เรียกผ่าน route และ method นั้นได้หรือไม่ ทั้งคู่ทำงานเป็น middleware ทำให้ handler โฟกัสกับ business logic ได้เต็มที่

บทความนี้เป็นตอนที่สองของซีรีส์ ตอนแรก เราสร้าง API สำหรับบทความด้วย Gin, GORM และ PostgreSQL ซึ่งตอนนี้ใครก็สร้างหรือลบบทความได้ พอจบตอนนี้ การอ่านจะยังเปิดสาธารณะ การเขียนต้องมี token ที่ถูกต้อง การลบต้องเป็น role admin และ editor แก้ได้เฉพาะบทความของตัวเอง

Middleware ใน Gin ทำงานอย่างไร

Middleware ของ Gin ก็คือ gin.HandlerFunc ธรรมดาที่รันก่อน (และเลือกได้ว่าจะรันหลัง) route handler:

go
func RequestTimer() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next() // run the remaining middleware and the handler
		log.Printf("%s %s -> %d in %s",
			c.Request.Method, c.FullPath(), c.Writer.Status(), time.Since(start))
	}
}

กฎที่ต้องจำ:

  • Middleware รันตามลำดับที่ลงทะเบียน r.Use() มีผลกับทุก route, group.Use() มีผลกับ group นั้น และยังใส่ middleware ให้ route เดียวแบบ inline ได้ เช่น r.GET("/me", authMW, handler)
  • c.Next() สั่งให้ส่วนที่เหลือของ chain ทำงาน โค้ดหลัง c.Next() จะรันตอนขากลับ
  • c.Abort() (หรือ c.AbortWithStatusJSON) หยุด handler ตัวถัดไป แต่ ไม่ได้หยุดฟังก์ชันปัจจุบัน ต้อง return ทุกครั้งหลัง abort
  • c.Set และ c.Get ใช้ส่งค่า เช่น user ปัจจุบัน จาก middleware ไปยัง handler

เพิ่มตาราง users ด้วย migration

User ต้องมี email, password hash และ role ส่วนบทความจะมี user_id แบบ optional เพื่อให้รู้ว่าใครเป็นคนเขียน

go
package models

import (
	"time"

	"golang.org/x/crypto/bcrypt"
)

type User struct {
	ID        uint      `gorm:"primaryKey" json:"id"`
	Email     string    `gorm:"size:255;uniqueIndex;not null" json:"email"`
	Name      string    `gorm:"size:100;not null" json:"name"`
	Password  string    `gorm:"size:60;not null" json:"-"`
	Role      string    `gorm:"size:20;not null;default:user" json:"role"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

func (u *User) SetPassword(plain string) error {
	hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
	if err != nil {
		return err // includes bcrypt.ErrPasswordTooLong for > 72 bytes
	}
	u.Password = string(hash)
	return nil
}

func (u *User) CheckPassword(plain string) bool {
	return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plain)) == nil
}

เพิ่ม field UserID *uint (ใส่ tag gorm:"index" json:"userId") ใน models.Article ที่เป็น pointer เพราะบทความที่ seed ไว้ยังไม่มีผู้เขียน จากนั้นเพิ่ม migration ต่อท้ายรายการของ gormigrate:

go
{
	ID: "202609250001_add_users",
	Migrate: func(tx *gorm.DB) error {
		type User struct {
			ID        uint   `gorm:"primaryKey"`
			Email     string `gorm:"size:255;uniqueIndex;not null"`
			Name      string `gorm:"size:100;not null"`
			Password  string `gorm:"size:60;not null"`
			Role      string `gorm:"size:20;not null;default:user"`
			CreatedAt time.Time
			UpdatedAt time.Time
		}
		type Article struct {
			UserID *uint `gorm:"index"`
			User   *User // creates the foreign key constraint
		}
		return tx.AutoMigrate(&User{}, &Article{})
	},
	Rollback: func(tx *gorm.DB) error {
		if err := tx.Migrator().DropColumn("articles", "user_id"); err != nil {
			return err
		}
		return tx.Migrator().DropTable("users")
	},
},

Hash password ด้วย bcrypt

ห้ามเก็บ password แม้จะเข้ารหัสแล้วก็ตาม ให้เก็บเป็น hash ที่ช้าและมี salt bcrypt สร้าง salt ให้เองและฝังไว้พร้อม cost ในผลลัพธ์ยาว 60 ตัวอักษร จึงใช้คอลัมน์เดียวพอ

  • Cost ค่า bcrypt.DefaultCost คือ 10 และทุกขั้นที่เพิ่มขึ้นทำให้งานเพิ่มเป็นสองเท่า cost 14 จึงช้ากว่า 16 เท่า ให้วัดบนเครื่อง production แล้วเลือก cost สูงสุดที่ login ยังเร็วพอ
  • จัดการ error ถ้าเพิกเฉย อาจกลายเป็นว่าเก็บ hash ว่างลงฐานข้อมูล
  • ข้อจำกัด 72 byte bcrypt ใช้แค่ 72 byte แรก และ x/crypto เวอร์ชันปัจจุบันคืน ErrPasswordTooLong แทนการตัดทิ้งเงียบ ๆ ส่วน max=72 ของ validator นับเป็นตัวอักษร ไม่ใช่ byte password ภาษาไทยจึงเกินได้ ต้องจัดการ error นี้ให้ชัดเจน

Handler สำหรับสมัครสมาชิก:

go
type registerRequest struct {
	Email    string `json:"email" binding:"required,email,max=255"`
	Name     string `json:"name" binding:"required,max=100"`
	Password string `json:"password" binding:"required,min=8,max=72"`
}

func (h *AuthHandler) Register(c *gin.Context) {
	var req registerRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	// Role is never taken from the request body.
	user := models.User{Email: strings.ToLower(req.Email), Name: req.Name, Role: "user"}
	if err := user.SetPassword(req.Password); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "password is too long"})
		return
	}
	err := h.db.WithContext(c.Request.Context()).Create(&user).Error
	switch {
	case errors.Is(err, gorm.ErrDuplicatedKey):
		c.JSON(http.StatusConflict, gin.H{"error": "email is already registered"})
	case err != nil:
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create user"})
	default:
		c.JSON(http.StatusCreated, gin.H{"data": user})
	}
}

JWT login flow ด้วย gin-jwt

github.com/appleboy/gin-jwt/v2 มี login handler, refresh handler และ middleware ที่ตรวจ header Authorization: Bearer <token> มาให้ เราแค่เขียน callback ไม่กี่ตัว:

Callbackถูกเรียกเมื่อคืนค่า
AuthenticatorPOST /loginUser หรือ error ถ้า credential ผิด
PayloadFuncหลัง login สำเร็จClaim ที่จะใส่ใน token
IdentityHandlerทุก request ที่ถูกป้องกันIdentity ที่เก็บไว้ใน context
UnauthorizedAuthentication ล้มเหลวเขียน error response
go
package middleware

import (
	"strings"
	"time"

	"example.com/articles-api/models"
	jwt "github.com/appleboy/gin-jwt/v2"
	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
)

const identityKey = "id"

type AuthUser struct {
	ID   uint
	Role string
}

type loginRequest struct {
	Email    string `json:"email" binding:"required,email"`
	Password string `json:"password" binding:"required"`
}

func NewJWT(db *gorm.DB, secret []byte) (*jwt.GinJWTMiddleware, error) {
	return jwt.New(&jwt.GinJWTMiddleware{
		Realm:       "articles-api",
		Key:         secret,
		Timeout:     15 * time.Minute,
		MaxRefresh:  24 * time.Hour,
		IdentityKey: identityKey,
		Authenticator: func(c *gin.Context) (interface{}, error) {
			var req loginRequest
			if err := c.ShouldBindJSON(&req); err != nil {
				return nil, jwt.ErrMissingLoginValues
			}
			var user models.User
			err := db.WithContext(c.Request.Context()).
				Where("email = ?", strings.ToLower(req.Email)).First(&user).Error
			// Same error for "no such user" and "wrong password".
			if err != nil || !user.CheckPassword(req.Password) {
				return nil, jwt.ErrFailedAuthentication
			}
			return &user, nil
		},
		PayloadFunc: func(data interface{}) jwt.MapClaims {
			if u, ok := data.(*models.User); ok {
				return jwt.MapClaims{identityKey: u.ID, "role": u.Role}
			}
			return jwt.MapClaims{}
		},
		IdentityHandler: func(c *gin.Context) interface{} {
			claims := jwt.ExtractClaims(c)
			id, _ := claims[identityKey].(float64) // JSON numbers decode as float64
			role, _ := claims["role"].(string)
			return &AuthUser{ID: uint(id), Role: role}
		},
		Unauthorized: func(c *gin.Context, code int, message string) {
			c.JSON(code, gin.H{"error": message})
		},
		TokenLookup:   "header: Authorization",
		TokenHeadName: "Bearer",
	})
}

// CurrentUser returns the identity set by the JWT middleware.
func CurrentUser(c *gin.Context) *AuthUser {
	v, _ := c.Get(identityKey)
	u, _ := v.(*AuthUser)
	return u
}

หลัง middleware ตรวจ token ผ่าน มันจะเรียก IdentityHandler แล้วเก็บผลไว้ใต้ IdentityKey ทำให้ handler ไหนก็อ่าน access payload ได้ด้วย middleware.CurrentUser(c) อย่าลืมว่า JWT ถูก sign ไม่ได้ถูกเข้ารหัส ใครถือ token ก็ decode base64 อ่าน claim ได้ ใส่แค่ ID และ role ห้ามใส่ข้อมูลส่วนตัวหรือความลับ

สิ่งที่ต้องทำให้ถูก:

  • Secret โหลดจาก environment variable ต้องเป็น byte สุ่มอย่างน้อย 32 byte และไม่ยอม start ถ้าไม่มี
  • Access token อายุสั้น Timeout: 15 * time.Minute จำกัดความเสียหายเมื่อ token หลุด ส่วน RefreshHandler ออก token ใหม่ให้ได้ตราบที่การ login ครั้งแรกยังไม่เก่ากว่า MaxRefresh
  • Role ที่ค้างอยู่ใน token Role ถูกคัดลอกลง token ตอน login ถ้าลดสิทธิ์ใคร จะมีผลเมื่อ token หมดอายุ ถ้ารับไม่ได้ ให้ดึง role จากฐานข้อมูลทุก request แทน
  • User enumeration ตอบ error เดียวกันทั้งกรณีไม่มี email และ password ผิด แต่เวลาตอบกลับยังอาจบอกได้ว่า email ไหนมีอยู่จริง เพราะ bcrypt รันเฉพาะเมื่อเจอ user การเทียบกับ dummy hash ในกรณีไม่เจอช่วยปิดช่องนี้

Authorization ด้วย Casbin RBAC

การเขียน if user.Role == "admin" กระจายไปทั่ว handler ไม่ scale Casbin ย้ายกฎเหล่านี้ไปไว้ใน model และ policy โดย model กำหนดวิธี match:

ini
# config/rbac_model.conf
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act

Policy ระบุว่าใครทำอะไรได้บ้าง บรรทัด g, admin, editor ทำให้ admin ได้สิทธิ์ทั้งหมดของ editor ด้วย:

text
# config/rbac_policy.csv
p, editor, /api/v1/articles, POST
p, editor, /api/v1/articles/:id, PUT
p, admin, /api/v1/articles/:id, DELETE

g, admin, editor

keyMatch2 เข้าใจ segment แบบ :id policy จึงเขียนให้ตรงกับนิยาม route ได้เลย Middleware ส่ง role, route ที่ match และ method ให้ Casbin ตรวจ:

go
func Authorize(e *casbin.Enforcer) gin.HandlerFunc {
	return func(c *gin.Context) {
		user := CurrentUser(c)
		if user == nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
			return
		}
		ok, err := e.Enforce(user.Role, c.FullPath(), c.Request.Method)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authorization failed"})
			return
		}
		if !ok {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
			return
		}
		c.Next()
	}
}

c.FullPath() คืน route template (/api/v1/articles/:id) ไม่ใช่ URL ดิบ ตัวอักษรที่ถูก encode หรือ slash เกินใน request จึงหลบ policy ไม่ได้ สังเกต status code ด้วย 401 แปลว่า "ไม่รู้ว่าคุณเป็นใคร" ส่วน 403 แปลว่า "รู้แล้ว และคำตอบคือไม่ได้"

File adapter เหมาะเมื่อ policy เปลี่ยนผ่าน code review เท่านั้น ถ้าต้องแก้สิทธิ์ตอน runtime ให้เก็บใน PostgreSQL ด้วย github.com/casbin/gorm-adapter/v3 และใช้ casbin.NewSyncedEnforcer เพื่อให้ reload ได้ปลอดภัยขณะมี request พร้อมกัน

ป้องกัน route group

ประกอบทุกอย่างเข้าด้วยกันใน routes.Register:

go
func Register(r *gin.Engine, db *gorm.DB, authMW *jwt.GinJWTMiddleware, enforcer *casbin.Enforcer) {
	articles := controllers.NewArticleHandler(db)
	auth := controllers.NewAuthHandler(db)

	api := r.Group("/api/v1")

	a := api.Group("/auth")
	a.POST("/register", auth.Register)
	a.POST("/login", authMW.LoginHandler)
	a.GET("/refresh", authMW.RefreshHandler)
	a.GET("/me", authMW.MiddlewareFunc(), auth.Me) // any logged-in user

	// Public reads
	api.GET("/articles", articles.List)
	api.GET("/articles/:id", articles.Get)

	// Writes: valid token first, then role check
	protected := api.Group("")
	protected.Use(authMW.MiddlewareFunc(), middleware.Authorize(enforcer))
	protected.POST("/articles", articles.Create)
	protected.PUT("/articles/:id", articles.Update)
	protected.DELETE("/articles/:id", articles.Delete)
}

และใน main.go:

go
secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) < 32 {
	log.Fatal("JWT_SECRET must be at least 32 bytes")
}
authMW, err := middleware.NewJWT(db, secret)
if err != nil {
	log.Fatalf("jwt: %v", err)
}
enforcer, err := casbin.NewEnforcer("config/rbac_model.conf", "config/rbac_policy.csv")
if err != nil {
	log.Fatalf("casbin: %v", err)
}
routes.Register(r, db, authMW, enforcer)

ลองใช้งาน:

bash
TOKEN=$(curl -s -X POST localhost:8080/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct-horse"}' | jq -r .token)

curl -X DELETE localhost:8080/api/v1/articles/1 -H "Authorization: Bearer $TOKEN"
# 403 for an editor, 204 for an admin

Ownership check: จุดที่ RBAC ไปไม่ถึง

RBAC ตอบได้ว่า "editor แก้บทความได้ไหม" แต่ตอบไม่ได้ว่า "editor คนนี้ แก้บทความ นี้ ได้ไหม" คำถามหลังขึ้นกับข้อมูล จึงต้องตรวจใน handler หลังโหลดแถวมาแล้ว:

go
me := middleware.CurrentUser(c)
if me.Role != "admin" && (article.UserID == nil || *article.UserID != me.ID) {
	c.JSON(http.StatusForbidden, gin.H{"error": "you can only edit your own articles"})
	return
}

ใน Create ให้ตั้ง article.UserID = &me.ID จาก token เสมอ ห้ามรับจาก request body Casbin เองก็ทำ ownership ได้ด้วย matcher แบบ ABAC แต่การเช็กตรง ๆ ข้าง query อ่านง่ายและ test ง่ายกว่า

คำถามที่พบบ่อย

Frontend ควรเก็บ JWT ไว้ที่ไหน

สำหรับเว็บแอป cookie ที่ตั้ง HttpOnly, Secure และ SameSite ถูกขโมยผ่าน XSS ได้ยากกว่า localStorage gin-jwt รองรับ cookie ด้วย SendCookie: true และ TokenLookup ที่มี cookie: jwt ถ้าใช้ cookie ต้องป้องกัน CSRF สำหรับ request ที่เปลี่ยนข้อมูลด้วย

Logout กับ JWT ทำอย่างไร

Token ที่ sign แล้วใช้ได้จนกว่าจะหมดอายุ ให้ตั้งอายุสั้น ลบ token ฝั่ง client และถ้าต้อง revoke ทันที ให้เก็บ denylist ของ token ID ใน Redis แล้วตรวจใน middleware

Casbin RBAC ต่างจาก ABAC อย่างไร

RBAC ผูกสิทธิ์กับ role ส่วน ABAC ตัดสินจาก attribute ของ user, resource และ request เช่น "เจ้าของเท่ากับผู้เรียก" Casbin รองรับทั้งสองแบบผ่าน matcher

ทำไม request ได้ 401 แทน 403

401 มาจาก JWT middleware แปลว่า token หายไป ผิดรูปแบบ หมดอายุ หรือ sign ด้วย secret คนละตัว ส่วน 403 มาจาก Casbin หรือ ownership check ให้ตรวจ header Authorization: Bearer ก่อน

เช็กลิสต์

  • Hash password ด้วย bcrypt จัดการ error และวัด cost แล้ว
  • JWT secret มาจาก environment ยาวและสุ่ม
  • Access token อายุสั้นและไม่มีข้อมูลส่วนตัว
  • Role กำหนดฝั่ง server เท่านั้น ไม่รับจาก request body
  • Casbin policy ตรงกับ route template ผ่าน c.FullPath()
  • ตรวจ ownership ใน handler หลังโหลดแถว

ต่อไป ตอนที่สาม จะเข้มงวดเรื่อง validation เพิ่มการอัปโหลดรูปภาพ และทำ pagination ให้รายการบทความ ถ้าอยากให้มีคนช่วยตรวจดีไซน์ auth ก่อนขึ้น production Vectorkub รับ review และพัฒนาระบบ backend

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

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