Appearance
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.Kx7pV9zY4mR3sT8wQ2nF5hA1bC6dE8gU0iO9pX3kLc1. 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/redisCookie Session
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-adaptergo
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
}小结
本篇系统讲解了认证与授权:
- 方案对比:Session 有状态、JWT 无状态,各有适用场景。
- JWT 原理:Header + Payload + Signature 三段式,签名防篡改。
- 生成与解析:使用
golang-jwt/jwt/v5,自定义 Claims 携带业务数据。 - 认证中间件:从 Header 提取 Bearer Token,校验后写入 Context。
- 双 Token 机制:Access Token 短期 + Refresh Token 长期,平衡安全和体验。
- Session 管理:
gin-contrib/sessions支持 Cookie、Redis 等多种后端。 - 权限控制:RBAC 模型,角色-权限分离;复杂场景用 Casbin。
下一篇我们将学习日志、错误处理与测试,为生产级应用做好最后一块拼图。