Web 平台开发日记 - 第二章:认证与权限系统实战

26 阅读7分钟

Web 平台开发日记 - 第二章:认证与权限系统实战

核心内容: JWT 认证、Casbin RBAC 权限控制、前后端集成
技术栈: Go + Gin + JWT + Casbin + Vue 3 + Pinia


📋 目录

  1. 目标
  2. 系统架构设计
  3. JWT 认证实现
  4. Casbin RBAC 实现
  5. 响应码统一处理
  6. 前端集成
  7. 测试验证
  8. 项目实践

🎯 目标

  • JWT Token 生成与验证
  • 登录/登出/Token刷新 API
  • JWT 中间件
  • Casbin RBAC 中间件
  • 用户服务层(User Service)
  • 用户管理 API
  • 前端登录集成
  • 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:1role: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 快过期时自动刷新

登录报错.png

cookie_token.png

login页面.png

🎯 项目实践

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)
}

📚 相关文档

技术文档