ทำระบบ Auth + RBAC ให้ IoT Platform
Branch:
workshop/dev-18-admin-authPhase: 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 นี้กันก่อนเลย:
ทำไมต้องมีสอง 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 ชั้น”:
Authenticate()— ตรวจว่ามีบัตร (token) มั้ย บัตรหมดอายุมั้ย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)
| Component | File | หน้าที่ |
|---|---|---|
| JWT Service | jwt.service.go | สร้าง/ตรวจสอบ access + refresh tokens |
| Auth Service | auth.service.go | Login, Register, Refresh, Logout |
| Auth Middleware | auth.middleware.go | ตรวจสอบ JWT + RBAC |
| Auth Handler | auth.handler.go | HTTP handlers |
| Audit Repository | audit.repository.go | บันทึก audit logs |
Frontend (React)
| Component | File | หน้าที่ |
|---|---|---|
| Auth Store | auth.store.ts | เก็บ JWT tokens + user info |
| Axios Interceptor | api.ts | Auto-inject token + refresh on 401 |
| Login Page | LoginPage.tsx | หน้า login พร้อม validation |
| Protected Route | ProtectedRoute.tsx | Guard routes ตาม auth + role |
| RoleGuard | RoleGuard.tsx | ซ่อน/แสดง UI ตาม role |
| useSession | useSession.ts | ตรวจสอบ session ทุก 5 นาที |
RBAC Matrix (ใครทำอะไรได้บ้าง)
| Resource | admin | operator | viewer |
|---|---|---|---|
| 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 ของเราตอนนี้มียามเฝ้าแล้วอย่างเป็นทางการ (ง่ะ)
- ก่อนหน้า: Workshop #20: Admin Monitoring Dashboard
- ถัดไป: Workshop #22: Deployment & CI/CD (เร็วๆ นี้ — รอด้วยนะ!)