博客 / 詳情

返回

Web 平台開發日記 - 第二章:認證與權限系統實戰

Web 平台開發日記 - 第二章:認證與權限系統實戰

核心內容: JWT 認證、Casbin RBAC 權限控制、前後端集成\
技術棧: Go + Gin + JWT + Casbin + Vue 3 + Pinia

📋 目錄

  1. 目標
  2. 系統架構設計
  3. JWT 認證實現
  4. Casbin RBAC 實現
  5. 響應碼統一處理
  6. 前端集成
  7. 測試驗證
  8. 項目實踐

🎯 目標

  • [x] JWT Token 生成與驗證
  • [x] 登錄/登出/Token刷新 API
  • [x] JWT 中間件
  • [x] Casbin RBAC 中間件
  • [x] 用户服務層(User Service)
  • [x] 用户管理 API
  • [x] 前端登錄集成
  • [x] HTTP 攔截器響應碼統一處理
  1. 完整的認證授權系統 - 支持登錄、登出、Token 刷新
  2. RBAC 權限控制 - 基於 Casbin 的角色訪問控制
  3. 前後端集成 - 統一的響應格式和錯誤處理

🏗️ 系統架構設計

認證授權架構圖

┌─────────────────────────────────────────────────────────────┐
│                         客户端層                              │
│                    HTTP 攔截器                                │
│         ┌─────────────┴─────────────┐                        │
│         │ • 自動添加 Token           │                        │
│         │ • Token 過期自動刷新       │                        │
│         │ • 統一響應碼處理           │                        │
│         │ • 錯誤統一提示             │                        │
│         └─────────────┬─────────────┘                        │
└───────────────────────┼───────────────────────────────────────┘
                        │ HTTP/JSON
                        ▼
┌─────────────────────────────────────────────────────────────┐
│                       後端 API 層                             │
│  ┌────────────┬────────────┬────────────┬────────────┐      │
│  │  登錄接口   │  登出接口   │  刷新接口   │  用户接口   │      │
│  │ /api/login │ /api/logout│/api/refresh│/api/user/*  │      │
│  └─────┬──────┴─────┬──────┴─────┬──────┴─────┬──────┘      │
└────────┼────────────┼────────────┼────────────┼──────────────┘
         │            │            │            │
         └────────────┴────────────┴────────────┘
                        ▼
┌─────────────────────────────────────────────────────────────┐
│                      中間件層                                 │
│  ┌──────────────────────┐  ┌──────────────────────┐         │
│  │   JWT 中間件          │  │  Casbin RBAC 中間件   │         │
│  │ • 驗證 Token 有效性   │  │ • 檢查用户角色         │         │
│  │ • 解析用户信息        │  │ • 驗證資源權限         │         │
│  │ • 注入上下文          │  │ • 動態權限加載         │         │
│  └──────────┬───────────┘  └──────────┬───────────┘         │
└─────────────┼──────────────────────────┼─────────────────────┘
              │                          │
              └──────────┬───────────────┘
                         ▼
┌─────────────────────────────────────────────────────────────┐
│                     服務層 (Service)                          │
│  UserService: GetUser, UpdateUser, AssignRoles, ...         │
└─────────────────────┬───────────────────────────────────────┘
                      ▼
┌─────────────────────────────────────────────────────────────┐
│                   數據訪問層 (GORM)                           │
│  User | Role | UserRole | Permission | Casbin Policy        │
└─────────────────────┬───────────────────────────────────────┘
                      ▼
              ┌────────────────┐
              │  MySQL 數據庫   │
              └────────────────┘

認證流程

用户 → 提交登錄 → 後端驗證 → 生成 JWT Token → 存儲 Session
                                    ↓
                            返回 Token + User
                                    ↓
               前端存儲(Cookie + LocalStorage)
                                    ↓
        訪問受保護資源 → JWT中間件驗證 → Casbin權限驗證 → 業務處理

權限控制模型

Casbin RBAC (Role-Based Access Control) 模型:

Subject (主體): user:1, user:2, ...
    ↓
Role (角色): role:admin, role:user
    ↓
Object (資源): /api/users, /api/roles, ...
    ↓
Action (操作): GET, POST, PUT, DELETE

示例:
p, role:admin, /api/users, GET      # 管理員可以查看用户
g, user:1, role:admin               # 用户1是管理員

🔐 JWT 認證實現

JWT Token 結構

// server/utils/jwt.go
type JWTClaims struct {
    UserID   uint     `json:"userId"`
    Username string   `json:"username"`
    RoleIDs  []uint   `json:"roleIds"`
    jwt.RegisteredClaims
}

Token 組成:

Header.Payload.Signature

JWT 生成

func GenerateToken(userID uint, username string, roleIDs []uint) (string, error) {
    expiresTime := time.Now().Add(time.Duration(global.Cfg.JWT.ExpiresTime) * time.Second)

    claims := &JWTClaims{
        UserID:   userID,
        Username: username,
        RoleIDs:  roleIDs,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expiresTime),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "enterprise-web-platform",
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(global.Cfg.JWT.SigningKey))
}

關鍵參數:

  • ExpiresAt: Token 過期時間(默認 7 天)
  • SigningKey: 密鑰(從配置文件讀取)
  • SigningMethod: HS256 算法

登錄接口實現

// server/api/v1/auth/login.go
func Login(c *gin.Context) {
    var req LoginRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        respondError(c, http.StatusBadRequest, "Invalid request")
        return
    }

    // 驗證用户憑證
    user, err := authenticateUser(req.Username, req.Password)
    if err != nil {
        respondError(c, http.StatusUnauthorized, err.Error())
        return
    }

    // 生成 JWT Token
    token, expiresAt, err := generateUserToken(&user)
    if err != nil {
        respondError(c, http.StatusInternalServerError, "Failed to generate token")
        return
    }

    // 存儲 Session
    storeSession(user.ID, user.Username, token)

    // 返回響應
    respondLoginSuccess(c, token, expiresAt, &user)
}

認證流程

  1. 驗證用户名密碼(bcrypt)
  2. 生成 JWT Token
  3. 存儲 Session 到 Redis
  4. 返回 Token 和用户信息

Token 刷新機制

// server/api/v1/auth/refresh.go
func RefreshToken(c *gin.Context) {
    userID, _ := middleware.GetUserID(c)
    username, _ := middleware.GetUsername(c)
    roleIDs, _ := middleware.GetRoleIDs(c)

    // 檢查是否在刷新窗口期內
    claims, _ := c.Get("claims")
    jwtClaims := claims.(*utils.JWTClaims)
    
    if !isTokenEligibleForRefresh(jwtClaims) {
        respondError(c, http.StatusBadRequest, "Token not eligible for refresh")
        return
    }

    // 生成新 Token
    newToken, err := utils.GenerateToken(userID, username, roleIDs)
    if err != nil {
        respondError(c, http.StatusInternalServerError, "Failed to generate token")
        return
    }

    expiresAt := time.Now().Add(time.Duration(global.Cfg.JWT.ExpiresTime) * time.Second).Unix()
    
    c.JSON(http.StatusOK, gin.H{
        "code": 200,
        "data": RefreshTokenResponse{Token: newToken, ExpiresAt: expiresAt},
    })
}

刷新時間窗口:

Token 創建              可刷新窗口              過期
    │                      │                  │
    │◄──── 6 天 ───────────►│◄──── 1 天 ─────►│
    │                      │                  │

🛡️ Casbin RBAC 實現

Casbin 模型定義

# server/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) && r.obj == p.obj && r.act == p.act

初始化權限策略

// server/initialize/data.go
func initializeCasbinPolicies(adminUserID uint) {
    // 定義管理員權限
    adminPolicies := [][]string{
        {"role:admin", "/api/users", "GET"},
        {"role:admin", "/api/users", "POST"},
        {"role:admin", "/api/user/:id", "PUT"},
        {"role:admin", "/api/user/:id", "DELETE"},
    }

    // 添加策略
    for _, policy := range adminPolicies {
        global.Enforcer.AddPolicy(policy)
    }

    // 關聯用户到角色
    adminSubject := fmt.Sprintf("user:%d", adminUserID)
    global.Enforcer.AddGroupingPolicy(adminSubject, "role:admin")

    global.Enforcer.SavePolicy()
}

Casbin 中間件

// server/middleware/casbin.go
func CasbinRBAC() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 獲取用户 ID
        userID, exists := GetUserID(c)
        if !exists {
            c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "Unauthorized"})
            c.Abort()
            return
        }

        // 構建主體標識
        subject := fmt.Sprintf("user:%d", userID)
        object := c.Request.URL.Path
        action := c.Request.Method

        // 檢查權限
        allowed, err := global.Enforcer.Enforce(subject, object, action)
        if err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "Permission check failed"})
            c.Abort()
            return
        }

        if !allowed {
            c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "Permission denied"})
            c.Abort()
            return
        }

        c.Next()
    }
}

權限檢查流程:

1. 輸入: (user:1, /api/users, GET)
2. 查詢: user:1 → role:admin
3. 匹配: role:admin + /api/users + GET → allow
4. 結果: ✅ 放行

UserService 使用 Casbin

// server/service/user_service.go
func (s *UserService) AssignRoles(userID uint, roleIDs []uint) error {
    // 查詢角色
    var roles []model.Role
    global.DB.Where("id IN ?", roleIDs).Find(&roles)

    // 更新用户角色
    var user model.User
    global.DB.Preload("Roles").First(&user, userID)
    global.DB.Model(&user).Association("Roles").Replace(roles)

    // 同步 Casbin 策略
    subject := fmt.Sprintf("user:%d", userID)
    global.Enforcer.DeleteRolesForUser(subject)
    
    for _, role := range roles {
        roleSubject := fmt.Sprintf("role:%s", role.Name)
        global.Enforcer.AddGroupingPolicy(subject, roleSubject)
    }
    
    global.Enforcer.SavePolicy()
    return nil
}

📡 響應碼統一處理

HTTP 標準狀態碼規範

遵循 RFC 7231 規範:

  • 2xx 成功: 200 OK, 201 Created, 202 Accepted, 204 No Content
  • 4xx 客户端錯誤: 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found
  • 5xx 服務器錯誤: 500 Internal Server Error, 503 Service Unavailable

前端響應碼工具

// web/src/utils/http/response-code.ts

// 判斷成功響應 (2xx)
export function isSuccessCode(code: number): boolean {
  return code >= 200 && code < 300;
}

export const ResponseCode = {
  SUCCESS: 200,
  CREATED: 201,
  
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
  
  INVALID_CREDENTIALS: 40001,
  ACCOUNT_DISABLED: 40002,
  TOKEN_EXPIRED: 40005,
  
  INTERNAL_ERROR: 500,
} as const;

HTTP 攔截器增強

// web/src/utils/http/index.ts
private httpInterceptorsResponse(): void {
  instance.interceptors.response.use(
    (response) => {
      const res = response.data;
      
      // 統一處理業務響應碼
      if (res && "code" in res) {
        if (!isSuccessCode(res.code)) {
          this.handleBusinessError(res.code, res.message);
          return Promise.reject(new Error(res.message));
        }
      }
      
      return response.data;
    },
    (error) => {
      if (error.response) {
        this.handleHttpError(error.response.status);
      }
      return Promise.reject(error);
    }
  );
}

private handleBusinessError(code: number, msg?: string): void {
  switch (code) {
    case ResponseCode.TOKEN_EXPIRED:
      message("登錄已過期,請重新登錄");
      useUserStoreHook().logOutLocal();
      break;
    case ResponseCode.ACCOUNT_DISABLED:
      message("賬號已被禁用,請聯繫管理員");
      break;
    case ResponseCode.FORBIDDEN:
      message("您沒有權限執行此操作");
      break;
    default:
      message(msg || "操作失敗");
  }
}
// web/src/views/login/index.vue
loginByUsername(data)
  .then(res => {
    // 攔截器已處理錯誤,這裏只會收到成功響應
    return initRouter().then(() => {
      router.push(getTopMenu(true).path);
      message("登錄成功", { type: "success" });
    });
  })
  .catch(err => {
    console.error("Login error:", err);
  });

🖥️ 前端集成

Vue Store 集成

// web/src/store/modules/user.ts
export const useUserStore = defineStore("pure-user", {
  actions: {
    async loginByUsername(data) {
      return new Promise((resolve, reject) => {
        getLogin(data)
          .then(response => {
            if (response?.data) {
              const { token, expiresAt, user } = response.data;
              
              const tokenData = {
                accessToken: token,
                expires: new Date(expiresAt * 1000),
                refreshToken: token,
                id: user.id,
                username: user.username,
                roles: user.roles,
                // ...
              };
              
              setToken(tokenData);
              resolve(response);
            }
          })
          .catch(reject);
      });
    },
  }
});

Token 存儲

// web/src/utils/auth.ts
export function setToken(data: DataInfo<Date>) {
  const { accessToken, refreshToken, expires } = data;
  
  // 1. 存儲到 Cookie
  Cookies.set(TokenKey, JSON.stringify({ accessToken, expires, refreshToken }), {
    expires: (expires - Date.now()) / 86400000
  });

  // 2. 存儲用户信息到 LocalStorage
  useUserStoreHook().SET_USERNAME(data.username);
  useUserStoreHook().SET_ROLES(data.roles);
  
  storageLocal().setItem(userKey, {
    id: data.id,
    username: data.username,
    roles: data.roles,
    // ...
  });
}

HTTP 請求攔截器

// web/src/utils/http/index.ts
private httpInterceptorsRequest(): void {
  instance.interceptors.request.use(async (config) => {
    const whiteList = ["/refresh-token", "/login"];
    if (whiteList.some(url => config.url.endsWith(url))) {
      return config;
    }

    const data = getToken();
    if (data) {
      const expired = parseInt(data.expires) - Date.now() <= 0;
      
      if (expired) {
        // Token 過期,觸發刷新
        await useUserStoreHook().handRefreshToken({ 
          refreshToken: data.refreshToken 
        });
      }
      
      config.headers["Authorization"] = formatToken(data.accessToken);
    }
    
    return config;
  });
}

🧪 測試驗證

登錄測試

# 正常登錄
curl -X POST http://localhost:8888/api/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123"}'

# 預期響應: 200 + Token + User

JWT 中間件測試

# 有效 Token 訪問
curl http://localhost:8888/api/user/info \
  -H "Authorization: Bearer <token>"

# 預期響應: 200 + 用户信息

Casbin 權限測試

# 管理員訪問用户列表
curl http://localhost:8888/api/users \
  -H "Authorization: Bearer <admin_token>"
# 預期: 200 成功

# 普通用户訪問
curl http://localhost:8888/api/users \
  -H "Authorization: Bearer <user_token>"
# 預期: 403 Permission denied

前端集成測試

# 1. 啓動開發環境
./start_dev.sh

# 2. 訪問前端
http://localhost:8848

# 3. 測試登錄
用户名: admin
密碼: admin123

# 驗證:
✅ 登錄成功後自動跳轉
✅ Cookie 中有 authorized-token
✅ LocalStorage 中有 user-info
✅ Token 快過期時自動刷新

🎯 項目實踐

1. 安全實踐

密碼存儲:

// 使用 bcrypt 哈希
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)

Token 簽名:

// 使用強密鑰(至少32字符)
SigningKey: "your-super-secret-key-with-at-least-32-characters"

Session 管理:

// 設置 TTL
ttl := time.Duration(global.Cfg.JWT.ExpiresTime) * time.Second
global.Redis.Set(ctx, sessionKey, data, ttl)

2. 中間件順序

// 正確的順序
router.Use(middleware.Logger())      // 1. 日誌
router.Use(middleware.Recovery())    // 2. 恢復
router.Use(middleware.CORS())        // 3. 跨域
router.Use(middleware.JWT())         // 4. 認證
router.Use(middleware.CasbinRBAC())  // 5. 授權

3. 路由配置

// 公開路由(無需認證)
publicGroup := router.Group("/api")
{
    publicGroup.POST("/login", auth.Login)
}

// 需要認證的路由
privateGroup := router.Group("/api")
privateGroup.Use(middleware.JWT())
{
    privateGroup.POST("/logout", auth.Logout)
    privateGroup.GET("/user/info", user.GetUserInfo)
}

// 需要認證+授權的路由
adminGroup := router.Group("/api")
adminGroup.Use(middleware.JWT())
adminGroup.Use(middleware.CasbinRBAC())
{
    adminGroup.GET("/users", user.ListUsers)
    adminGroup.POST("/users", user.CreateUser)
}

📚 相關文檔

技術文檔

  • JWT 官方文檔 - JSON Web Token 標準
  • Casbin 官方文檔 - 權限管理框架
  • Gin 官方文檔 - Go Web 框架
  • Vue 3 官方文檔 - 漸進式 JavaScript 框架
  • Pinia 官方文檔 - Vue 狀態管理庫
  • bcrypt 文檔 - 密碼哈希算法
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.