ทำระบบ Auth + RBAC ให้ IoT Platform

ทำระบบ Auth + RBAC ให้ IoT Platform

Showkhun · Workshop ·

Branch: workshop/dev-18-admin-auth Phase: Development (18/21) Repo: kangana1024/iot-workshop


เฮ้ น้องๆ ครั้งนี้เราจะมาทำอะไร? (ง่ะ)

 ___________________________________
|                                   |
|   ใครๆ ก็เข้าระบบได้ = อันตราย!  |
|___________________________________|
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

ลองนึกภาพนะ ถ้าเปิดร้านอาหาร แล้วให้ทุกคนเดินเข้าครัวได้เลย… คงวุ่นวายน่าดู ใช่มั้ย?

ระบบ IoT ก็เหมือนกัน — เราต้องการ ยาม (Authentication) และ ป้ายบอกว่าใครทำอะไรได้บ้าง (RBAC) ไม่งั้นใครก็มา delete device ทิ้งได้หมด ซึ่งไม่ดีแน่ๆ

Workshop นี้เราจะทำระบบ Auth แบบจริงจัง โดยใช้ JWT ที่เปรียบเหมือน “บัตรผ่าน” แบบมีวันหมดอายุ และ RBAC ที่เหมือน “ระดับสิทธิ์พนักงาน” — พนักงานขาย vs ผู้จัดการ vs เจ้าของร้าน ทำได้ไม่เหมือนกัน


น้องๆ จะได้เรียนรู้อะไรบ้าง

  • ทำไมต้องใช้ JWT แทน session เดิมๆ
  • ออกแบบ User Model + Role ใน MongoDB
  • เขียน JWT Service ด้วย Go (access token 15 นาที + refresh token 7 วัน)
  • Auth Middleware สำหรับ RBAC บน Gin
  • Login Page + RoleGuard Component ด้วย React
  • Session Management + Token Auto-refresh
  • Audit Log บันทึกทุก action ที่สำคัญ

ก่อนลงมือ ทำความเข้าใจ Flow กันก่อน

เราจะอธิบาย WHY ก่อนเสมอ ดูภาพรวมของ Authentication Flow นี้กันก่อนเลย:

Mermaid Diagram

ทำไมต้องมีสอง token?

เปรียบง่ายๆ เหมือนบัตร 2 ใบ:

  • Access Token = บัตรเข้าออฟฟิศรายวัน (15 นาที) ถ้าหาย เสียหายน้อย
  • Refresh Token = บัตรประชาชน (7 วัน) ใช้ทำบัตรออฟฟิศใหม่ได้

ทำไมไม่ทำ token เดียวอายุยาวๆ? เพราะถ้า token ถูกขโมย แฮกเกอร์จะมีสิทธิ์ใช้งานนานมาก การทำ access token ให้หมดอายุเร็วๆ ช่วยลดความเสี่ยงได้เยอะ


Part 1: Go Backend

Step 1: Auth Types และ Models

เริ่มจาก “พิมพ์เขียว” ของข้อมูลก่อน สร้าง backend/internal/model/auth.go:

package model

import (
    "time"

    "go.mongodb.org/mongo-driver/bson/primitive"
)

// UserRole กำหนด roles ที่มีในระบบ
type UserRole string

const (
    RoleAdmin    UserRole = "admin"
    RoleOperator UserRole = "operator"
    RoleViewer   UserRole = "viewer"
)

// User model ใน MongoDB
type User struct {
    ID           primitive.ObjectID `bson:"_id,omitempty" json:"id"`
    Username     string             `bson:"username"      json:"username"`
    Email        string             `bson:"email"         json:"email"`
    PasswordHash string             `bson:"passwordHash"  json:"-"`
    FirstName    string             `bson:"firstName"     json:"firstName"`
    LastName     string             `bson:"lastName"      json:"lastName"`
    Role         UserRole           `bson:"role"          json:"role"`
    IsActive     bool               `bson:"isActive"      json:"isActive"`
    LastLogin    *time.Time         `bson:"lastLogin"     json:"lastLogin,omitempty"`
    RefreshTokens []RefreshToken    `bson:"refreshTokens" json:"-"`
    CreatedAt    time.Time          `bson:"createdAt"     json:"createdAt"`
    UpdatedAt    time.Time          `bson:"updatedAt"     json:"updatedAt"`
}

// RefreshToken เก็บ refresh token แต่ละตัว (support multi-device)
type RefreshToken struct {
    Token     string    `bson:"token"`
    DeviceID  string    `bson:"deviceId"`
    ExpiresAt time.Time `bson:"expiresAt"`
    CreatedAt time.Time `bson:"createdAt"`
}

// JWT Claims
type JWTClaims struct {
    UserID   string   `json:"sub"`
    Username string   `json:"username"`
    Email    string   `json:"email"`
    Role     UserRole `json:"role"`
}

// Request/Response DTOs
type LoginRequest struct {
    Username string `json:"username" validate:"required,min=3"`
    Password string `json:"password" validate:"required,min=8"`
}

type RegisterRequest struct {
    Username  string   `json:"username"  validate:"required,min=3,max=50,alphanum"`
    Email     string   `json:"email"     validate:"required,email"`
    Password  string   `json:"password"  validate:"required,min=8"`
    FirstName string   `json:"firstName" validate:"required,min=2"`
    LastName  string   `json:"lastName"  validate:"required,min=2"`
    Role      UserRole `json:"role"      validate:"required,oneof=admin operator viewer"`
}

type RefreshRequest struct {
    RefreshToken string `json:"refreshToken" validate:"required"`
}

type AuthResponse struct {
    AccessToken  string `json:"accessToken"`
    RefreshToken string `json:"refreshToken"`
    ExpiresIn    int64  `json:"expiresIn"` // seconds
    User         *User  `json:"user"`
}

สังเกตว่า PasswordHash มี tag json:"-" — แปลว่าจะไม่ถูกส่งออกไปใน JSON response เด็ดขาด เรียกว่า “ซ่อนของสำคัญ” นั่นเอง (ʘ‿ʘ)

และ RefreshTokens ก็ซ่อนเหมือนกัน — เก็บไว้ใน DB แต่ไม่ส่ง token list ออกไปให้ client เห็น


Step 2: JWT Service

นี่คือ “โรงพิมพ์บัตรผ่าน” ของระบบ สร้าง backend/internal/service/jwt.service.go:

package service

import (
    "errors"
    "fmt"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "github.com/kangana1024/iot-workshop/backend/internal/config"
    "github.com/kangana1024/iot-workshop/backend/internal/model"
)

type JWTService struct {
    cfg *config.Config
}

func NewJWTService(cfg *config.Config) *JWTService {
    return &JWTService{cfg: cfg}
}

type tokenClaims struct {
    jwt.RegisteredClaims
    UserID   string         `json:"sub"`
    Username string         `json:"username"`
    Email    string         `json:"email"`
    Role     model.UserRole `json:"role"`
}

// GenerateAccessToken สร้าง JWT access token (อายุ 15 นาที)
func (s *JWTService) GenerateAccessToken(user *model.User) (string, error) {
    now := time.Now()
    claims := tokenClaims{
        RegisteredClaims: jwt.RegisteredClaims{
            Issuer:    "iot-workshop",
            Subject:   user.ID.Hex(),
            IssuedAt:  jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
        },
        UserID:   user.ID.Hex(),
        Username: user.Username,
        Email:    user.Email,
        Role:     user.Role,
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(s.cfg.JWTSecret))
}

// GenerateRefreshToken สร้าง refresh token (อายุ 7 วัน)
func (s *JWTService) GenerateRefreshToken(user *model.User) (string, error) {
    now := time.Now()
    claims := tokenClaims{
        RegisteredClaims: jwt.RegisteredClaims{
            Issuer:    "iot-workshop",
            Subject:   user.ID.Hex(),
            IssuedAt:  jwt.NewNumericDate(now),
            ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)),
        },
        UserID: user.ID.Hex(),
        Role:   user.Role,
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(s.cfg.JWTRefreshSecret))
}

// ValidateAccessToken ตรวจสอบ access token
func (s *JWTService) ValidateAccessToken(tokenStr string) (*model.JWTClaims, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &tokenClaims{}, func(t *jwt.Token) (interface{}, error) {
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return []byte(s.cfg.JWTSecret), nil
    })
    if err != nil {
        return nil, err
    }

    claims, ok := token.Claims.(*tokenClaims)
    if !ok || !token.Valid {
        return nil, errors.New("invalid token")
    }

    return &model.JWTClaims{
        UserID:   claims.UserID,
        Username: claims.Username,
        Email:    claims.Email,
        Role:     claims.Role,
    }, nil
}

// ValidateRefreshToken ตรวจสอบ refresh token
func (s *JWTService) ValidateRefreshToken(tokenStr string) (string, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &tokenClaims{}, func(t *jwt.Token) (interface{}, error) {
        return []byte(s.cfg.JWTRefreshSecret), nil
    })
    if err != nil {
        return "", err
    }

    claims, ok := token.Claims.(*tokenClaims)
    if !ok || !token.Valid {
        return "", errors.New("invalid refresh token")
    }

    return claims.UserID, nil
}

ทำไม Access Token ถึงต้องแค่ 15 นาที? เพราะ JWT มันเป็น “stateless” — backend ไม่ได้เก็บมันไว้ในฐานข้อมูล ถ้าแฮกเกอร์ขโมย token ไปได้ อยากยกเลิกก็ยกเลิกไม่ได้ (จนกว่า token จะหมดอายุเอง) ดังนั้นยิ่งหมดอายุเร็วยิ่งดี


Step 3: Auth Service

เปรียบ Auth Service เหมือน “แผนกทรัพยากรบุคคล” — รับ login, สร้างบัญชีใหม่, จัดการ session สร้าง backend/internal/service/auth.service.go:

package service

import (
    "context"
    "errors"
    "time"

    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"
    "golang.org/x/crypto/bcrypt"

    "github.com/kangana1024/iot-workshop/backend/internal/model"
    "github.com/kangana1024/iot-workshop/backend/internal/repository"
)

var (
    ErrUserNotFound       = errors.New("user not found")
    ErrInvalidCredentials = errors.New("invalid credentials")
    ErrUserInactive       = errors.New("user account is inactive")
    ErrUserExists         = errors.New("username or email already exists")
    ErrInvalidToken       = errors.New("invalid or expired token")
)

type AuthService struct {
    userRepo   repository.UserRepository
    jwtService *JWTService
    auditRepo  repository.AuditRepository
}

func NewAuthService(
    userRepo repository.UserRepository,
    jwtService *JWTService,
    auditRepo repository.AuditRepository,
) *AuthService {
    return &AuthService{
        userRepo:   userRepo,
        jwtService: jwtService,
        auditRepo:  auditRepo,
    }
}

// Login ตรวจสอบ credentials และออก tokens
func (s *AuthService) Login(ctx context.Context, req model.LoginRequest, ipAddress string) (*model.AuthResponse, error) {
    // ค้นหา user
    user, err := s.userRepo.FindByUsername(ctx, req.Username)
    if err != nil {
        if errors.Is(err, mongo.ErrNoDocuments) {
            return nil, ErrInvalidCredentials
        }
        return nil, err
    }

    // ตรวจสอบ active status
    if !user.IsActive {
        return nil, ErrUserInactive
    }

    // ตรวจสอบ password
    if err := bcrypt.CompareHashAndPassword(
        []byte(user.PasswordHash),
        []byte(req.Password),
    ); err != nil {
        // บันทึก failed login
        s.auditRepo.Log(ctx, model.AuditLog{
            Action:   "login.failed",
            UserID:   user.ID.Hex(),
            Username: user.Username,
            IPAddress: ipAddress,
            Details:  "Invalid password",
        })
        return nil, ErrInvalidCredentials
    }

    // สร้าง tokens
    accessToken, err := s.jwtService.GenerateAccessToken(user)
    if err != nil {
        return nil, err
    }

    refreshToken, err := s.jwtService.GenerateRefreshToken(user)
    if err != nil {
        return nil, err
    }

    // บันทึก refresh token
    now := time.Now()
    s.userRepo.AddRefreshToken(ctx, user.ID, model.RefreshToken{
        Token:     refreshToken,
        ExpiresAt: now.Add(7 * 24 * time.Hour),
        CreatedAt: now,
    })

    // อัปเดต last login
    s.userRepo.UpdateLastLogin(ctx, user.ID)

    // บันทึก audit log
    s.auditRepo.Log(ctx, model.AuditLog{
        Action:   "login.success",
        UserID:   user.ID.Hex(),
        Username: user.Username,
        IPAddress: ipAddress,
    })

    return &model.AuthResponse{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        ExpiresIn:    900, // 15 minutes
        User:         user,
    }, nil
}

// Register สร้าง user ใหม่
func (s *AuthService) Register(ctx context.Context, req model.RegisterRequest, createdBy string) (*model.User, error) {
    // ตรวจสอบ username/email ซ้ำ
    exists, err := s.userRepo.ExistsByUsernameOrEmail(ctx, req.Username, req.Email)
    if err != nil {
        return nil, err
    }
    if exists {
        return nil, ErrUserExists
    }

    // Hash password
    hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        return nil, err
    }

    now := time.Now()
    user := &model.User{
        ID:           primitive.NewObjectID(),
        Username:     req.Username,
        Email:        req.Email,
        PasswordHash: string(hash),
        FirstName:    req.FirstName,
        LastName:     req.LastName,
        Role:         req.Role,
        IsActive:     true,
        CreatedAt:    now,
        UpdatedAt:    now,
    }

    if err := s.userRepo.Create(ctx, user); err != nil {
        return nil, err
    }

    // บันทึก audit log
    s.auditRepo.Log(ctx, model.AuditLog{
        Action:    "user.created",
        UserID:    user.ID.Hex(),
        Username:  user.Username,
        PerformedBy: createdBy,
        Details:   "User registered",
    })

    return user, nil
}

// RefreshToken ออก access token ใหม่
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*model.AuthResponse, error) {
    // ตรวจสอบ refresh token
    userID, err := s.jwtService.ValidateRefreshToken(refreshToken)
    if err != nil {
        return nil, ErrInvalidToken
    }

    objID, err := primitive.ObjectIDFromHex(userID)
    if err != nil {
        return nil, ErrInvalidToken
    }

    // ตรวจสอบว่า refresh token ยังอยู่ใน database
    user, err := s.userRepo.FindByIDAndRefreshToken(ctx, objID, refreshToken)
    if err != nil {
        return nil, ErrInvalidToken
    }

    if !user.IsActive {
        return nil, ErrUserInactive
    }

    // สร้าง access token ใหม่
    newAccessToken, err := s.jwtService.GenerateAccessToken(user)
    if err != nil {
        return nil, err
    }

    return &model.AuthResponse{
        AccessToken:  newAccessToken,
        RefreshToken: refreshToken, // ใช้ refresh token เดิม
        ExpiresIn:    900,
        User:         user,
    }, nil
}

// Logout ลบ refresh token
func (s *AuthService) Logout(ctx context.Context, userID string, refreshToken string) error {
    objID, err := primitive.ObjectIDFromHex(userID)
    if err != nil {
        return ErrInvalidToken
    }

    return s.userRepo.RemoveRefreshToken(ctx, objID, refreshToken)
}

ทำไม Logout ต้องลบ refresh token ออกจาก DB?

เพราะ access token มันหมดอายุเองใน 15 นาทีอยู่แล้ว แต่ refresh token อยู่ได้ 7 วัน ถ้าไม่ลบ แฮกเกอร์ที่ขโมย refresh token ไปก็ยังออก access token ใหม่ได้ตลอด — ล็อกออกแล้วก็เหมือนไม่ได้ล็อกออก!


Step 4: Auth Middleware (ยาม + ระบบตรวจบัตร)

Middleware คือ “ด่านตรวจ” ที่ทุก request ต้องผ่านก่อนไปถึง handler สร้าง backend/internal/middleware/auth.middleware.go:

package middleware

import (
    "net/http"
    "strings"

    "github.com/gin-gonic/gin"
    "github.com/kangana1024/iot-workshop/backend/internal/model"
    "github.com/kangana1024/iot-workshop/backend/internal/service"
)

const (
    UserIDKey   = "userID"
    UsernameKey = "username"
    UserRoleKey = "userRole"
    ClaimsKey   = "claims"
)

type AuthMiddleware struct {
    jwtService *service.JWTService
}

func NewAuthMiddleware(jwtService *service.JWTService) *AuthMiddleware {
    return &AuthMiddleware{jwtService: jwtService}
}

// Authenticate ตรวจสอบ JWT token
func (m *AuthMiddleware) Authenticate() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "success": false,
                "message": "Authorization header required",
            })
            return
        }

        parts := strings.SplitN(authHeader, " ", 2)
        if len(parts) != 2 || parts[0] != "Bearer" {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "success": false,
                "message": "Invalid authorization format",
            })
            return
        }

        claims, err := m.jwtService.ValidateAccessToken(parts[1])
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "success": false,
                "message": "Invalid or expired token",
            })
            return
        }

        // เก็บ claims ใน context
        c.Set(ClaimsKey, claims)
        c.Set(UserIDKey, claims.UserID)
        c.Set(UsernameKey, claims.Username)
        c.Set(UserRoleKey, string(claims.Role))

        c.Next()
    }
}

// RequireRoles ตรวจสอบว่า user มี role ที่กำหนด
func (m *AuthMiddleware) RequireRoles(roles ...model.UserRole) gin.HandlerFunc {
    return func(c *gin.Context) {
        roleStr, exists := c.Get(UserRoleKey)
        if !exists {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "success": false,
                "message": "Not authenticated",
            })
            return
        }

        userRole := model.UserRole(roleStr.(string))
        for _, allowed := range roles {
            if userRole == allowed {
                c.Next()
                return
            }
        }

        c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
            "success": false,
            "message": "Insufficient permissions",
        })
    }
}

// GetUserID helper function ดึง userID จาก context
func GetUserID(c *gin.Context) string {
    id, _ := c.Get(UserIDKey)
    return id.(string)
}

// GetUserRole helper function ดึง role จาก context
func GetUserRole(c *gin.Context) model.UserRole {
    role, _ := c.Get(UserRoleKey)
    return model.UserRole(role.(string))
}

Middleware 2 ตัวนี้ทำงานเหมือน “ยาม 2 ชั้น”:

  1. Authenticate() — ตรวจว่ามีบัตร (token) มั้ย บัตรหมดอายุมั้ย
  2. RequireRoles(...) — ตรวจว่าบัตรสีไหน (role อะไร) เข้าห้องนี้ได้มั้ย

Step 5: Auth Handler

Handler คือ “พนักงานต้อนรับ” ที่รับ HTTP request แล้วส่งต่อไปให้ service สร้าง backend/internal/handler/auth.handler.go:

package handler

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/kangana1024/iot-workshop/backend/internal/middleware"
    "github.com/kangana1024/iot-workshop/backend/internal/model"
    "github.com/kangana1024/iot-workshop/backend/internal/service"
)

type AuthHandler struct {
    authService *service.AuthService
}

func NewAuthHandler(authService *service.AuthService) *AuthHandler {
    return &AuthHandler{authService: authService}
}

func (h *AuthHandler) Login(c *gin.Context) {
    var req model.LoginRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
        return
    }

    result, err := h.authService.Login(c.Request.Context(), req, c.ClientIP())
    if err != nil {
        switch err {
        case service.ErrInvalidCredentials:
            c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"})
        case service.ErrUserInactive:
            c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "บัญชีถูกปิดใช้งาน"})
        default:
            c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "เกิดข้อผิดพลาด"})
        }
        return
    }

    c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}

func (h *AuthHandler) Register(c *gin.Context) {
    var req model.RegisterRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
        return
    }

    createdBy := middleware.GetUserID(c)
    user, err := h.authService.Register(c.Request.Context(), req, createdBy)
    if err != nil {
        if err == service.ErrUserExists {
            c.JSON(http.StatusConflict, gin.H{"success": false, "message": "ชื่อผู้ใช้หรืออีเมลมีอยู่แล้ว"})
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "เกิดข้อผิดพลาด"})
        return
    }

    c.JSON(http.StatusCreated, gin.H{"success": true, "data": user})
}

func (h *AuthHandler) RefreshToken(c *gin.Context) {
    var req model.RefreshRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
        return
    }

    result, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken)
    if err != nil {
        c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "Token ไม่ถูกต้องหรือหมดอายุ"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}

func (h *AuthHandler) Logout(c *gin.Context) {
    var req model.RefreshRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
        return
    }

    userID := middleware.GetUserID(c)
    if err := h.authService.Logout(c.Request.Context(), userID, req.RefreshToken); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "เกิดข้อผิดพลาด"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"success": true, "message": "ออกจากระบบสำเร็จ"})
}

func (h *AuthHandler) Me(c *gin.Context) {
    userID := middleware.GetUserID(c)
    // ดึงข้อมูล user จาก userID ...
    c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"userID": userID}})
}

Step 6: Route Registration (ตั้งด่านตรวจ)

ใน backend/cmd/server/main.go กำหนด route ว่าเส้นไหนต้องผ่านด่านไหน:

// Auth routes (ไม่ต้อง authenticate — เส้นสาธารณะ)
auth := r.Group("/api/auth")
{
    auth.POST("/login", authHandler.Login)
    auth.POST("/refresh", authHandler.RefreshToken)
}

// Protected routes — ต้องผ่านด่านแรก (มีบัตรมั้ย?)
api := r.Group("/api")
api.Use(authMiddleware.Authenticate())
{
    // Auth
    api.POST("/auth/logout", authHandler.Logout)
    api.GET("/auth/me", authHandler.Me)

    // Register - เฉพาะ admin (ด่านที่สอง: บัตรสีถูกมั้ย?)
    api.POST("/auth/register",
        authMiddleware.RequireRoles(model.RoleAdmin),
        authHandler.Register,
    )

    // Devices - admin และ operator ลบได้, viewer ดูได้
    devices := api.Group("/devices")
    {
        devices.GET("", deviceHandler.List)
        devices.GET("/:id", deviceHandler.Get)
        devices.POST("",
            authMiddleware.RequireRoles(model.RoleAdmin, model.RoleOperator),
            deviceHandler.Create,
        )
        devices.PUT("/:id",
            authMiddleware.RequireRoles(model.RoleAdmin, model.RoleOperator),
            deviceHandler.Update,
        )
        devices.DELETE("/:id",
            authMiddleware.RequireRoles(model.RoleAdmin),
            deviceHandler.Delete,
        )
    }

    // Users - เฉพาะ admin
    users := api.Group("/users")
    users.Use(authMiddleware.RequireRoles(model.RoleAdmin))
    {
        users.GET("", userHandler.List)
        users.GET("/:id", userHandler.Get)
        users.PUT("/:id", userHandler.Update)
        users.DELETE("/:id", userHandler.Delete)
    }
}

Part 2: React Frontend

Step 7: Auth Service (Frontend)

ฝั่ง React ก็ต้องมี service ของตัวเองที่คุยกับ backend สร้าง src/services/auth.service.ts:

import api from './api'
import type { ApiResponse } from '@types/api.types'
import type { User } from '@types/user.types'

export interface LoginInput {
  username: string
  password: string
}

export interface AuthTokens {
  accessToken: string
  refreshToken: string
  expiresIn: number
  user: User
}

export const authService = {
  async login(input: LoginInput): Promise<ApiResponse<AuthTokens>> {
    const response = await api.post<ApiResponse<AuthTokens>>('/auth/login', input)
    return response.data
  },

  async register(input: {
    username: string
    email: string
    password: string
    firstName: string
    lastName: string
    role: string
  }): Promise<ApiResponse<User>> {
    const response = await api.post<ApiResponse<User>>('/auth/register', input)
    return response.data
  },

  async logout(refreshToken: string): Promise<void> {
    await api.post('/auth/logout', { refreshToken })
  },

  async me(): Promise<ApiResponse<User>> {
    const response = await api.get<ApiResponse<User>>('/auth/me')
    return response.data
  },
}

Step 8: Login Page

หน้า login ที่สวยงามพร้อม validation ด้วย Zod สร้าง src/pages/auth/LoginPage.tsx:

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useNavigate, useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import { Eye, EyeOff, Wifi, LogIn } from 'lucide-react'
import { authService } from '@services/auth.service'
import { useAuthStore } from '@stores/auth.store'

const loginSchema = z.object({
  username: z.string().min(1, 'กรุณาระบุชื่อผู้ใช้'),
  password: z.string().min(1, 'กรุณาระบุรหัสผ่าน'),
})

type LoginFormValues = z.infer<typeof loginSchema>

export default function LoginPage() {
  const navigate = useNavigate()
  const location = useLocation()
  const { setUser, setTokens } = useAuthStore()
  const [showPassword, setShowPassword] = useState(false)

  const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<LoginFormValues>({
    resolver: zodResolver(loginSchema),
  })

  const onSubmit = async (values: LoginFormValues) => {
    try {
      const result = await authService.login(values)
      if (result.success) {
        const { user, accessToken, refreshToken } = result.data
        setUser(user)
        setTokens(accessToken, refreshToken)
        toast.success(`ยินดีต้อนรับ, ${user.firstName}!`)
        navigate(from, { replace: true })
      }
    } catch (error: unknown) {
      const axiosError = error as { response?: { status: number; data?: { message: string } } }
      if (axiosError?.response?.status === 401) {
        setError('password', {
          message: 'ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง',
        })
      } else if (axiosError?.response?.status === 403) {
        toast.error('บัญชีถูกปิดใช้งาน กรุณาติดต่อผู้ดูแลระบบ')
      } else {
        toast.error('เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง')
      }
    }
  }

  return (
    <div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900 flex items-center justify-center p-4">
      <div className="w-full max-w-md">
        {/* Logo & Title */}
        <div className="text-center mb-8">
          <div className="inline-flex items-center justify-center w-16 h-16 bg-primary-600 rounded-2xl mb-4 shadow-lg">
            <Wifi className="w-9 h-9 text-white" />
          </div>
          <h1 className="text-2xl font-bold text-white">IoT Admin Panel</h1>
          <p className="text-slate-400 mt-1">เข้าสู่ระบบเพื่อจัดการอุปกรณ์ IoT</p>
        </div>

        {/* Form */}
        <div className="bg-white rounded-2xl shadow-xl p-8">
          <form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
            <div>
              <label className="label">ชื่อผู้ใช้</label>
              <input
                {...register('username')}
                placeholder="กรอกชื่อผู้ใช้"
                autoComplete="username"
                autoFocus
                className="input"
              />
              {errors.username && (
                <p className="mt-1 text-xs text-red-500">{errors.username.message}</p>
              )}
            </div>

            <div>
              <label className="label">รหัสผ่าน</label>
              <div className="relative">
                <input
                  {...register('password')}
                  type={showPassword ? 'text' : 'password'}
                  placeholder="กรอกรหัสผ่าน"
                  autoComplete="current-password"
                  className="input pr-10"
                />
                <button
                  type="button"
                  onClick={() => setShowPassword((s) => !s)}
                  className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
                >
                  {showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
                </button>
              </div>
              {errors.password && (
                <p className="mt-1 text-xs text-red-500">{errors.password.message}</p>
              )}
            </div>

            <button
              type="submit"
              disabled={isSubmitting}
              className="btn-primary w-full py-3"
            >
              {isSubmitting ? (
                <span className="flex items-center justify-center gap-2">
                  <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
                  กำลังเขาสูระบบ...
                </span>
              ) : (
                <span className="flex items-center justify-center gap-2">
                  <LogIn className="w-4 h-4" />
                  เขาสูระบบ
                </span>
              )}
            </button>
          </form>

          <p className="mt-6 text-center text-xs text-gray-400">
            ระบบรองรับการเข้าสู่ระบบผ่าน JWT Authentication
          </p>
        </div>
      </div>
    </div>
  )
}

Step 9: Role-based UI Component (ป้ายบอกว่าใครเห็นอะไรได้)

นี่คือส่วนที่เราชอบมากที่สุด — RoleGuard เปรียบเหมือน “ผ้าม่าน” ที่ดึงลงปิดปุ่มที่ user ไม่มีสิทธิ์ใช้ สร้าง src/components/auth/RoleGuard.tsx:

import { useAuthStore } from '@stores/auth.store'
import type { UserRole } from '@types/user.types'

interface RoleGuardProps {
  allowedRoles: UserRole[]
  children: React.ReactNode
  fallback?: React.ReactNode
}

/**
 * Component สำหรับ render content ตาม role
 * ใช้งาน: <RoleGuard allowedRoles={['admin']}><DeleteButton /></RoleGuard>
 */
export function RoleGuard({ allowedRoles, children, fallback = null }: RoleGuardProps) {
  const { user } = useAuthStore()

  if (!user || !allowedRoles.includes(user.role)) {
    return <>{fallback}</>
  }

  return <>{children}</>
}

/**
 * Hook สำหรับตรวจสอบ permission ใน component
 */
export function usePermission() {
  const { user } = useAuthStore()

  const hasRole = (...roles: UserRole[]) => {
    if (!user) return false
    return roles.includes(user.role)
  }

  const isAdmin = () => hasRole('admin')
  const isOperator = () => hasRole('admin', 'operator')
  const isViewer = () => hasRole('admin', 'operator', 'viewer')

  return { hasRole, isAdmin, isOperator, isViewer, user }
}

ตัวอย่างการใช้งาน RoleGuard ใน component:

// ใน DevicesPage.tsx
import { RoleGuard, usePermission } from '@components/auth/RoleGuard'

function DeviceActions({ device }: { device: Device }) {
  const { isOperator, isAdmin } = usePermission()

  return (
    <div className="flex gap-2">
      {/* ทุก role ดูได้ */}
      <button onClick={() => navigate(`/devices/${device.id}`)}>
        ดูรายละเอียด
      </button>

      {/* operator และ admin แก้ไขได้ */}
      <RoleGuard allowedRoles={['admin', 'operator']}>
        <button onClick={() => navigate(`/devices/${device.id}/edit`)}>
          แก้ไข
        </button>
      </RoleGuard>

      {/* เฉพาะ admin ลบได้ */}
      <RoleGuard allowedRoles={['admin']}>
        <button onClick={() => setDeleteTarget(device.id)} className="text-red-500">
          ลบ
        </button>
      </RoleGuard>
    </div>
  )
}

Step 10: Session Management (เฝ้าดูแลตลอดเวลา)

เปรียบเหมือน “พนักงานรักษาความปลอดภัย” ที่ตรวจสถานะบัตรทุก 5 นาที — ถ้าบัตรหมดอายุ พาออกไปที่หน้า login เลย สร้าง src/hooks/useSession.ts:

import { useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '@stores/auth.store'
import { authService } from '@services/auth.service'
import { ROUTES } from '@router/routes'

const SESSION_CHECK_INTERVAL = 5 * 60 * 1000 // ตรวจสอบ session ทุก 5 นาที

export function useSession() {
  const navigate = useNavigate()
  const { isAuthenticated, accessToken, logout, setUser } = useAuthStore()

  // ตรวจสอบ session ด้วยการ call /auth/me
  const { error } = useQuery({
    queryKey: ['session'],
    queryFn: async () => {
      const result = await authService.me()
      if (result.data) {
        setUser(result.data)
      }
      return result.data
    },
    enabled: isAuthenticated && !!accessToken,
    refetchInterval: SESSION_CHECK_INTERVAL,
    retry: false,
  })

  // ถ้า session หมดอายุ ให้ logout
  useEffect(() => {
    if (error && isAuthenticated) {
      logout()
      navigate(ROUTES.LOGIN)
    }
  }, [error, isAuthenticated, logout, navigate])

  return { isAuthenticated }
}

Step 11: Audit Log (Go Backend)

Audit Log คือ “สมุดบันทึกของยาม” — ทุกครั้งที่มีเหตุการณ์สำคัญเกิดขึ้น (login สำเร็จ, login ล้มเหลว, สร้าง user ใหม่) ระบบจะจดบันทึกไว้ ถ้าเกิดอะไรขึ้นก็ย้อนดูได้

สร้าง backend/internal/model/audit.go:

package model

import (
    "time"

    "go.mongodb.org/mongo-driver/bson/primitive"
)

type AuditLog struct {
    ID          primitive.ObjectID `bson:"_id,omitempty" json:"id"`
    Action      string             `bson:"action"        json:"action"`
    UserID      string             `bson:"userId"        json:"userId"`
    Username    string             `bson:"username"      json:"username"`
    PerformedBy string             `bson:"performedBy"   json:"performedBy,omitempty"`
    IPAddress   string             `bson:"ipAddress"     json:"ipAddress,omitempty"`
    ResourceID  string             `bson:"resourceId"    json:"resourceId,omitempty"`
    ResourceType string            `bson:"resourceType"  json:"resourceType,omitempty"`
    Details     string             `bson:"details"       json:"details,omitempty"`
    Metadata    map[string]interface{} `bson:"metadata" json:"metadata,omitempty"`
    CreatedAt   time.Time          `bson:"createdAt"     json:"createdAt"`
}

สร้าง backend/internal/repository/audit.repository.go:

package repository

import (
    "context"
    "time"

    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"

    "github.com/kangana1024/iot-workshop/backend/internal/model"
)

type AuditRepository interface {
    Log(ctx context.Context, log model.AuditLog) error
    GetLogs(ctx context.Context, filter AuditFilter) ([]model.AuditLog, error)
}

type AuditFilter struct {
    UserID    string
    Action    string
    StartDate *time.Time
    EndDate   *time.Time
    Limit     int
    Skip      int
}

type mongoAuditRepository struct {
    collection *mongo.Collection
}

func NewAuditRepository(db *mongo.Database) AuditRepository {
    return &mongoAuditRepository{
        collection: db.Collection("audit_logs"),
    }
}

func (r *mongoAuditRepository) Log(ctx context.Context, log model.AuditLog) error {
    log.ID = primitive.NewObjectID()
    log.CreatedAt = time.Now()
    _, err := r.collection.InsertOne(ctx, log)
    return err
}

func (r *mongoAuditRepository) GetLogs(ctx context.Context, filter AuditFilter) ([]model.AuditLog, error) {
    // Implementation ตาม filter...
    return nil, nil
}

สรุปสิ่งที่ทำใน Workshop นี้

มาลุยกันมาเยอะมาก เราสรุปให้เป็นตารางให้ดูง่ายๆ เลย:

Backend (Go)

ComponentFileหน้าที่
JWT Servicejwt.service.goสร้าง/ตรวจสอบ access + refresh tokens
Auth Serviceauth.service.goLogin, Register, Refresh, Logout
Auth Middlewareauth.middleware.goตรวจสอบ JWT + RBAC
Auth Handlerauth.handler.goHTTP handlers
Audit Repositoryaudit.repository.goบันทึก audit logs

Frontend (React)

ComponentFileหน้าที่
Auth Storeauth.store.tsเก็บ JWT tokens + user info
Axios Interceptorapi.tsAuto-inject token + refresh on 401
Login PageLoginPage.tsxหน้า login พร้อม validation
Protected RouteProtectedRoute.tsxGuard routes ตาม auth + role
RoleGuardRoleGuard.tsxซ่อน/แสดง UI ตาม role
useSessionuseSession.tsตรวจสอบ session ทุก 5 นาที

RBAC Matrix (ใครทำอะไรได้บ้าง)

Resourceadminoperatorviewer
View devices
Create/Edit devices
Delete devices
Manage users
View monitoring
Manage alerts
Export data
View settings

Step ต่อไป

Workshop นี้เราได้ระบบ Auth + RBAC ที่แข็งแกร่งมากแล้ว ทั้ง backend Go และ frontend React — ระบบ IoT ของเราตอนนี้มียามเฝ้าแล้วอย่างเป็นทางการ (ง่ะ)