Skip to content

08-JWT认证与Session管理

认证(Authentication)和授权(Authorization)是 Web 应用安全的两大基石。在前后端分离架构中,JWT 几乎成了事实标准;而在传统服务端渲染场景,Session 依然非常实用。本篇将深入讲解两种方案的原理与实战,并介绍 RBAC 权限模型。

认证方案对比:Session vs JWT

Session(有状态)

服务器保存用户登录状态,给客户端返回一个 Session ID(通常放在 Cookie 里),后续请求带上这个 ID,服务器据此找到对应的会话数据。

优点

  • 服务端可以随时让会话失效(直接删除 Session 即可)
  • Session ID 是无意义的随机字符串,不携带敏感信息
  • 适合传统 Web 应用,浏览器自动带 Cookie

缺点

  • 需要服务端存储(内存或 Redis),有水平扩展成本
  • 默认依赖 Cookie,存在 CSRF 风险
  • 跨域处理较麻烦

JWT(无状态)

JWT(JSON Web Token)是一个自包含的 Token,把用户信息编码在 Token 里,服务器不需要存储 Session。Token 通常放在 HTTP Header 中(Authorization: Bearer xxx)。

优点

  • 无状态,易于水平扩展
  • 自包含,可以携带用户信息,减少数据库查询
  • 不依赖 Cookie,天然支持跨域、移动端

缺点

  • 一旦签发,在过期前无法主动作废(除非维护黑名单)
  • Token 体积较大
  • 续期方案需要设计(双 Token)

何时选哪个?

场景推荐
前后端分离 API、移动端、微服务JWT
服务端渲染 Web 应用(同源)Session
单点登录(SSO)JWT
需要随时踢人下线Session

JWT 原理详解:Header、Payload、Signature

JWT 由三部分组成,用 . 分隔:

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyImlkIjoxMjMsInVzZXJuYW1lIjoiYWxpY2UiLCJleHAiOjE2OTAwMDAwMDB9.Kx7pV9zY4mR3sT8wQ2nF5hA1bC6dE8gU0iO9pX3kLc

1. Header(头部)

描述 Token 类型和签名算法:

json
{
  "alg": "HS256",
  "typ": "JWT"
}

2. Payload(载荷)

存放实际数据,包括标准声明(Registered Claims)和自定义声明:

json
{
  "iss": "myapp",          // 签发者
  "sub": "123",            // 主题(一般是用户ID)
  "aud": "web",            // 受众
  "exp": 1690000000,       // 过期时间(必须)
  "iat": 1689900000,       // 签发时间
  "nbf": 1689900000,       // 生效时间
  "jti": "unique-id",      // 唯一标识
  "user_id": 123,          // 自定义:用户ID
  "username": "alice",     // 自定义:用户名
  "role": "admin"          // 自定义:角色
}

注意:Payload 默认只是 Base64 编码,不是加密,绝不能放密码等敏感信息。

3. Signature(签名)

用 Header 中指定的算法,对 base64(header) + "." + base64(payload) 进行签名:

text
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

服务器收到 Token 时,会用相同的 secret 重新计算签名并比对,从而验证 Token 没有被篡改。

常见算法

  • HS256:HMAC + SHA-256,对称加密,最常用
  • RS256:RSA + SHA-256,非对称加密,公钥验签,私钥签发,适合微服务
  • ES256:ECDSA + SHA-256,非对称加密,性能更好

安装 JWT 库:github.com/golang-jwt/jwt/v5

bash
go get -u github.com/golang-jwt/jwt/v5

注意:v5 是当前推荐版本,API 与旧版 v3 有较大差异。

生成 JWT Token

go
package main

import (
	"fmt"
	"github.com/golang-jwt/jwt/v5"
	"time"
)

// 自定义声明结构体
type CustomClaims struct {
	UserID   uint   `json:"user_id"`
	Username string `json:"username"`
	Role     string `json:"role"`
	jwt.RegisteredClaims
}

var secret = []byte("my-secret-key-please-change")

func GenerateToken(userID uint, username, role string) (string, error) {
	claims := CustomClaims{
		UserID:   userID,
		Username: username,
		Role:     role,
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer:    "myapp",
			Subject:   fmt.Sprintf("%d", userID),
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			NotBefore: jwt.NewNumericDate(time.Now()),
			ID:        fmt.Sprintf("%d-%d", userID, time.Now().UnixNano()),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(secret)
}

func main() {
	token, err := GenerateToken(123, "alice", "admin")
	if err != nil {
		fmt.Println("生成失败:", err)
		return
	}
	fmt.Println("Token:", token)
}

验证 JWT Token

go
package main

import (
	"errors"
	"fmt"
	"github.com/golang-jwt/jwt/v5"
)

func ParseToken(tokenString string) (*CustomClaims, error) {
	claims := &CustomClaims{}
	token, err := jwt.ParseWithClaims(tokenString, claims, 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 secret, nil
	})

	if err != nil {
		return nil, err
	}
	if !token.Valid {
		return nil, errors.New("invalid token")
	}
	return claims, nil
}

func main() {
	// 假设 token 是上一步生成的
	token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx.yyy"
	claims, err := ParseToken(token)
	if err != nil {
		fmt.Println("解析失败:", err)
		return
	}
	fmt.Printf("user_id=%d, username=%s, role=%s\n",
		claims.UserID, claims.Username, claims.Role)
}

重要:解析时一定要校验 t.Method 类型,否则可能被 alg: none 攻击绕过签名验证。

在 Gin 中实现 JWT 认证中间件

把上面的逻辑封装成中间件,挂到需要认证的路由组上:

go
package main

import (
	"errors"
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"net/http"
	"strings"
	"time"
)

type CustomClaims struct {
	UserID   uint   `json:"user_id"`
	Username string `json:"username"`
	Role     string `json:"role"`
	jwt.RegisteredClaims
}

var secret = []byte("my-secret-key-please-change")

func GenerateToken(userID uint, username, role string) (string, error) {
	claims := CustomClaims{
		UserID:   userID,
		Username: username,
		Role:     role,
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer:    "myapp",
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(secret)
}

func ParseToken(tokenString string) (*CustomClaims, error) {
	claims := &CustomClaims{}
	token, err := jwt.ParseWithClaims(tokenString, claims, 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 secret, nil
	})
	if err != nil {
		return nil, err
	}
	if !token.Valid {
		return nil, errors.New("invalid token")
	}
	return claims, nil
}

// JWTAuthMiddleware JWT 认证中间件
func JWTAuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "缺少认证头"})
			return
		}
		parts := strings.SplitN(authHeader, " ", 2)
		if len(parts) != 2 || parts[0] != "Bearer" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证头格式错误"})
			return
		}
		claims, err := ParseToken(parts[1])
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token 无效或已过期"})
			return
		}
		// 把用户信息存入上下文
		c.Set("user_id", claims.UserID)
		c.Set("username", claims.Username)
		c.Set("role", claims.Role)
		c.Next()
	}
}

func main() {
	r := gin.Default()

	// 公开路由
	r.POST("/login", func(c *gin.Context) {
		var req struct {
			Username string `json:"username" binding:"required"`
			Password string `json:"password" binding:"required"`
		}
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		// 简化:硬编码校验
		if req.Username != "alice" || req.Password != "123456" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
			return
		}
		token, err := GenerateToken(123, "alice", "admin")
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{
			"token":      token,
			"token_type": "Bearer",
			"expires_in": 7200,
		})
	})

	// 受保护路由
	auth := r.Group("/api", JWTAuthMiddleware())
	{
		auth.GET("/profile", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{
				"user_id":  c.GetUint("user_id"),
				"username": c.GetString("username"),
				"role":     c.GetString("role"),
			})
		})
	}

	r.Run(":8080")
}

完整的登录认证流程示例(登录、获取用户信息、刷新Token)

实际项目中常使用双 Token 机制

  • Access Token:短期有效(如 2 小时),用于 API 访问
  • Refresh Token:长期有效(如 7 天),仅用于换新的 Access Token

这样可以平衡安全性和用户体验:Access Token 短期,即使泄露损失有限;Refresh Token 只走 /refresh 接口,便于监控和撤销。

go
package main

import (
	"errors"
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt/v5"
	"net/http"
	"strings"
	"time"
)

type CustomClaims struct {
	UserID   uint   `json:"user_id"`
	Username string `json:"username"`
	Role     string `json:"role"`
	Type     string `json:"type"` // access / refresh
	jwt.RegisteredClaims
}

var secret = []byte("my-secret-key-please-change")

// 模拟用户表
var users = map[string]struct {
	ID       uint
	Password string
	Role     string
}{
	"alice": {ID: 1, Password: "123456", Role: "admin"},
	"bob":   {ID: 2, Password: "654321", Role: "user"},
}

// refreshTokens 存储用户当前有效的 refresh token(生产环境用 Redis)
var refreshTokens = map[uint]string{}

func genToken(userID uint, username, role, tokenType string, dur time.Duration) (string, error) {
	claims := CustomClaims{
		UserID:   userID,
		Username: username,
		Role:     role,
		Type:     tokenType,
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer:    "myapp",
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(dur)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return t.SignedString(secret)
}

func parseToken(s string) (*CustomClaims, error) {
	claims := &CustomClaims{}
	t, err := jwt.ParseWithClaims(s, claims, func(t *jwt.Token) (interface{}, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("alg mismatch")
		}
		return secret, nil
	})
	if err != nil {
		return nil, err
	}
	if !t.Valid {
		return nil, errors.New("invalid token")
	}
	return claims, nil
}

// 颁发 access + refresh 双 token
func issueTokens(userID uint, username, role string) (string, string, error) {
	access, err := genToken(userID, username, role, "access", 2*time.Hour)
	if err != nil {
		return "", "", err
	}
	refresh, err := genToken(userID, username, role, "refresh", 7*24*time.Hour)
	if err != nil {
		return "", "", err
	}
	refreshTokens[userID] = refresh // 记录
	return access, refresh, nil
}

func JWTAuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "缺少认证头"})
			return
		}
		parts := strings.SplitN(authHeader, " ", 2)
		if len(parts) != 2 || parts[0] != "Bearer" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "认证头格式错误"})
			return
		}
		claims, err := parseToken(parts[1])
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token 无效"})
			return
		}
		if claims.Type != "access" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token 类型错误"})
			return
		}
		c.Set("user_id", claims.UserID)
		c.Set("username", claims.Username)
		c.Set("role", claims.Role)
		c.Next()
	}
}

func main() {
	r := gin.Default()

	// 登录
	r.POST("/login", func(c *gin.Context) {
		var req struct {
			Username string `json:"username" binding:"required"`
			Password string `json:"password" binding:"required"`
		}
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		u, ok := users[req.Username]
		if !ok || u.Password != req.Password {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
			return
		}
		access, refresh, err := issueTokens(u.ID, req.Username, u.Role)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{
			"access_token":  access,
			"refresh_token": refresh,
			"token_type":    "Bearer",
			"expires_in":    7200,
		})
	})

	// 刷新 token
	r.POST("/refresh", func(c *gin.Context) {
		var req struct {
			RefreshToken string `json:"refresh_token" binding:"required"`
		}
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		claims, err := parseToken(req.RefreshToken)
		if err != nil || claims.Type != "refresh" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token 无效"})
			return
		}
		// 校验 refresh token 是否仍然有效(未被撤销)
		stored, ok := refreshTokens[claims.UserID]
		if !ok || stored != req.RefreshToken {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token 已失效"})
			return
		}
		// 重新签发
		access, refresh, err := issueTokens(claims.UserID, claims.Username, claims.Role)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{
			"access_token":  access,
			"refresh_token": refresh,
		})
	})

	// 登出(撤销 refresh token)
	auth := r.Group("/api", JWTAuthMiddleware())
	{
		auth.POST("/logout", func(c *gin.Context) {
			uid := c.GetUint("user_id")
			delete(refreshTokens, uid)
			c.JSON(http.StatusOK, gin.H{"msg": "已登出"})
		})

		auth.GET("/profile", func(c *gin.Context) {
			c.JSON(http.StatusOK, gin.H{
				"user_id":  c.GetUint("user_id"),
				"username": c.GetString("username"),
				"role":     c.GetString("role"),
			})
		})
	}

	r.Run(":8080")
}

测试流程:

bash
# 1. 登录拿双 token
curl -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"123456"}'

# 2. 用 access token 访问
curl http://localhost:8080/api/profile \
  -H "Authorization: Bearer <access_token>"

# 3. 用 refresh token 换新的 access token
curl -X POST http://localhost:8080/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"<refresh_token>"}'

# 4. 登出
curl -X POST http://localhost:8080/api/logout \
  -H "Authorization: Bearer <access_token>"

Session 管理:使用 gin-contrib/sessions

gin-contrib/sessions 是 Gin 官方维护的 Session 库,支持多种存储后端。

安装

bash
go get -u github.com/gin-contrib/sessions
go get -u github.com/gin-contrib/sessions/cookie
go get -u github.com/gin-contrib/sessions/redis

Cookie Session 把数据加密后存在客户端 Cookie 里,无需服务端存储,适合数据量小的场景。

go
package main

import (
	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/cookie"
	"github.com/gin-gonic/gin"
	"net/http"
)

func main() {
	r := gin.Default()
	// secret 用于签名/加密 cookie,请用长随机串
	store := cookie.NewStore([]byte("secret-key-please-change-32bytes-long!!"))
	store.Options(sessions.Options{
		Path:     "/",
		MaxAge:   3600, // 1 小时
		HttpOnly: true,
		Secure:   false, // 生产环境用 HTTPS 时设为 true
		SameSite: http.SameSiteLaxMode,
	})
	r.Use(sessions.Sessions("mysession", store))

	r.POST("/login", func(c *gin.Context) {
		session := sessions.Default(c)
		session.Set("user_id", 123)
		session.Set("username", "alice")
		if err := session.Save(); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{"msg": "登录成功"})
	})

	r.GET("/profile", func(c *gin.Context) {
		session := sessions.Default(c)
		uid := session.Get("user_id")
		if uid == nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
			return
		}
		c.JSON(http.StatusOK, gin.H{
			"user_id":  uid,
			"username": session.Get("username"),
		})
	})

	r.POST("/logout", func(c *gin.Context) {
		session := sessions.Default(c)
		session.Clear()
		session.Options(sessions.Options{Path: "/", MaxAge: -1}) // 删除 cookie
		session.Save()
		c.JSON(http.StatusOK, gin.H{"msg": "已登出"})
	})

	r.Run(":8080")
}

Redis Session

生产环境推荐用 Redis 存储 Session,便于水平扩展和集中管理。

go
package main

import (
	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/redis"
	"github.com/gin-gonic/gin"
	"net/http"
)

func main() {
	r := gin.Default()

	// 参数:size, network, address, password, keyPrefix
	// size=10 表示连接池大小;"127.0.0.1:6379" 是 Redis 地址
	store, err := redis.NewStore(10, "tcp", "127.0.0.1:6379", "", "session:")
	if err != nil {
		panic(err)
	}
	store.Options(sessions.Options{
		Path:     "/",
		MaxAge:   3600,
		HttpOnly: true,
	})
	r.Use(sessions.Sessions("mysession", store))

	r.POST("/login", func(c *gin.Context) {
		session := sessions.Default(c)
		session.Set("user_id", 123)
		session.Set("username", "alice")
		session.Save()
		c.JSON(http.StatusOK, gin.H{"msg": "登录成功"})
	})

	r.GET("/profile", func(c *gin.Context) {
		session := sessions.Default(c)
		uid := session.Get("user_id")
		if uid == nil {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"user_id": uid})
	})

	// 强制踢人下线:直接删除 Redis 中的 key
	// key 格式:session:<session_id>
	// 可通过 session.ID() 拿到 session id

	r.Run(":8080")
}

Session 中间件封装

go
package main

import (
	"github.com/gin-contrib/sessions"
	"github.com/gin-gonic/gin"
	"net/http"
)

func AuthRequired() gin.HandlerFunc {
	return func(c *gin.Context) {
		session := sessions.Default(c)
		uid := session.Get("user_id")
		if uid == nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
			return
		}
		c.Set("user_id", uid)
		c.Next()
	}
}

// 使用:
// r.GET("/api/profile", AuthRequired(), handler)

权限控制:RBAC 基础

RBAC(Role-Based Access Control,基于角色的访问控制)是最常用的权限模型:

  • 用户(User):登录主体
  • 角色(Role):权限的集合,如 admin、editor、viewer
  • 权限(Permission):对资源的具体操作,如 article:create、article:delete

用户被赋予角色,角色拥有权限,请求时校验用户是否有对应权限。

数据模型

go
package main

import (
	"gorm.io/gorm"
)

type User struct {
	gorm.Model
	Username string `gorm:"uniqueIndex"`
	Password string
	Roles    []Role `gorm:"many2many:user_roles;"`
}

type Role struct {
	gorm.Model
	Name        string `gorm:"uniqueIndex"`
	Permissions []Permission `gorm:"many2many:role_permissions;"`
}

type Permission struct {
	gorm.Model
	Name string `gorm:"uniqueIndex"` // 如 article:create
	Desc string
}

权限校验中间件

go
package main

import (
	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
	"net/http"
)

// 检查用户是否拥有某个权限
func hasPermission(db *gorm.DB, userID uint, permName string) bool {
	var count int64
	db.Table("users").
		Joins("JOIN user_roles ON user_roles.user_id = users.id").
		Joins("JOIN roles ON roles.id = user_roles.role_id").
		Joins("JOIN role_permissions ON role_permissions.role_id = roles.id").
		Joins("JOIN permissions ON permissions.id = role_permissions.permission_id").
		Where("users.id = ? AND permissions.name = ?", userID, permName).
		Count(&count).Limit(1)
	return count > 0
}

// RequirePermission 权限校验中间件
func RequirePermission(db *gorm.DB, perm string) gin.HandlerFunc {
	return func(c *gin.Context) {
		// 假设已经过了 JWT 中间件,user_id 已存入 context
		uid, exists := c.Get("user_id")
		if !exists {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
			return
		}
		if !hasPermission(db, uid.(uint), perm) {
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
			return
		}
		c.Next()
	}
}

// 使用:
// r.POST("/articles", JWTAuthMiddleware(), RequirePermission(db, "article:create"), handler)

角色校验(简化版)

如果权限粒度不需要细到操作级别,可以只校验角色:

go
func RequireRole(roles ...string) gin.HandlerFunc {
	return func(c *gin.Context) {
		userRole := c.GetString("role")
		for _, r := range roles {
			if userRole == r {
				c.Next()
				return
			}
		}
		c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
			"error": "需要以下角色之一: " + strings.Join(roles, ", "),
		})
	}
}

// 使用:
// r.DELETE("/users/:id", JWTAuthMiddleware(), RequireRole("admin"), handler)

Casbin:更专业的权限库

复杂的权限场景推荐使用 Casbin,它支持 RBAC、ABAC、ACL 等多种模型,且与具体存储解耦。

bash
go get -u github.com/casbin/casbin/v2
go get -u github.com/casbin/gorm-adapter
go
package main

import (
	"fmt"
	"github.com/casbin/casbin/v2"
	"github.com/casbin/casbin/v2/model"
)

func main() {
	// 模型定义(也可以放在文件 model.conf 里)
	mText := `
[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
`
	m, _ := model.NewModelFromString(mText)
	e, _ := casbin.NewEnforcer(m)

	// 策略:alice 拥有 admin 角色;admin 可以读 article
	e.AddGroupingPolicy("alice", "admin")
	e.AddPolicy("admin", "article", "read")
	e.AddPolicy("admin", "article", "delete")

	// 校验
	ok, _ := e.Enforce("alice", "article", "read")
	fmt.Println("alice read article:", ok) // true
	ok, _ = e.Enforce("alice", "article", "delete")
	fmt.Println("alice delete article:", ok) // true
	ok, _ = e.Enforce("alice", "user", "delete")
	fmt.Println("alice delete user:", ok) // false
}

小结

本篇系统讲解了认证与授权:

  1. 方案对比:Session 有状态、JWT 无状态,各有适用场景。
  2. JWT 原理:Header + Payload + Signature 三段式,签名防篡改。
  3. 生成与解析:使用 golang-jwt/jwt/v5,自定义 Claims 携带业务数据。
  4. 认证中间件:从 Header 提取 Bearer Token,校验后写入 Context。
  5. 双 Token 机制:Access Token 短期 + Refresh Token 长期,平衡安全和体验。
  6. Session 管理gin-contrib/sessions 支持 Cookie、Redis 等多种后端。
  7. 权限控制:RBAC 模型,角色-权限分离;复杂场景用 Casbin。

下一篇我们将学习日志、错误处理与测试,为生产级应用做好最后一块拼图。