Web 平台开发日记 - 第二章:认证与权限系统实战
核心内容: JWT 认证、Casbin RBAC 权限控制、前后端集成
技术栈: Go + Gin + JWT + Casbin + Vue 3 + Pinia
📋 目录
🎯 目标
- JWT Token 生成与验证
- 登录/登出/Token刷新 API
- JWT 中间件
- Casbin RBAC 中间件
- 用户服务层(User Service)
- 用户管理 API
- 前端登录集成
- HTTP 拦截器响应码统一处理
- 完整的认证授权系统 - 支持登录、登出、Token 刷新
- RBAC 权限控制 - 基于 Casbin 的角色访问控制
- 前后端集成 - 统一的响应格式和错误处理
🏗️ 系统架构设计
认证授权架构图
┌─────────────────────────────────────────────────────────────┐
│ 客户端层 │
│ 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)
}
认证流程:
- 验证用户名密码(bcrypt)
- 生成 JWT Token
- 存储 Session 到 Redis
- 返回 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 文档 - 密码哈希算法