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:
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ทุกครั้งหลัง abortc.Setและc.Getใช้ส่งค่า เช่น user ปัจจุบัน จาก middleware ไปยัง handler
เพิ่มตาราง users ด้วย migration
User ต้องมี email, password hash และ role ส่วนบทความจะมี user_id แบบ optional เพื่อให้รู้ว่าใครเป็นคนเขียน
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:
{
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 สำหรับสมัครสมาชิก:
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 | ถูกเรียกเมื่อ | คืนค่า |
|---|---|---|
Authenticator | POST /login | User หรือ error ถ้า credential ผิด |
PayloadFunc | หลัง login สำเร็จ | Claim ที่จะใส่ใน token |
IdentityHandler | ทุก request ที่ถูกป้องกัน | Identity ที่เก็บไว้ใน context |
Unauthorized | Authentication ล้มเหลว | เขียน error response |
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:
# 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.actPolicy ระบุว่าใครทำอะไรได้บ้าง บรรทัด g, admin, editor ทำให้ admin ได้สิทธิ์ทั้งหมดของ editor ด้วย:
# 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, editorkeyMatch2 เข้าใจ segment แบบ :id policy จึงเขียนให้ตรงกับนิยาม route ได้เลย Middleware ส่ง role, route ที่ match และ method ให้ Casbin ตรวจ:
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:
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:
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)ลองใช้งาน:
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 adminOwnership check: จุดที่ RBAC ไปไม่ถึง
RBAC ตอบได้ว่า "editor แก้บทความได้ไหม" แต่ตอบไม่ได้ว่า "editor คนนี้ แก้บทความ นี้ ได้ไหม" คำถามหลังขึ้นกับข้อมูล จึงต้องตรวจใน handler หลังโหลดแถวมาแล้ว:
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
