第 6 章 · JWT 认证与 RBAC
本章目标:实现用户注册/登录;使用 golang-jwt/jwt/v5 签发 Bearer Token;密码 bcrypt 哈希存入 password_hash;编写 Auth 中间件与 RBAC(user/admin);保护商品写接口;提供 curl 联调步骤;对照 fastapi-web ch07 OAuth2 Password Flow 与 flask-web ch06 Flask-Login。
学时建议:5~6 小时(含 2 小时 Token 联调)
前置:完成 gin-web ch05;User 模型(ch04)。
6.1 场景说明:保护写操作
商品读接口对匿名开放(仅 is_published=true);创建、更新、删除必须 admin 角色。
| 接口 | 认证 | 角色 |
|---|---|---|
GET /api/v1/products | 无 | 匿名 |
GET /api/v1/products/:slug | 无 | 匿名(仅已发布) |
POST/PATCH/DELETE /api/v1/products | Bearer JWT | admin |
POST /api/v1/auth/register | 无 | 注册默认 user |
POST /api/v1/auth/login | 无 | 返回 token |
GET /api/v1/auth/me | Bearer JWT | user/admin |
POST /api/v1/auth/login
──► 200 { "access_token": "eyJ...", "token_type": "Bearer" }
POST /api/v1/products
Header: Authorization: Bearer eyJ...
──► 201(admin) / 40301(user) / 40101(无 token)
对照章节:
| 主题 | flask-web ch06 | fastapi-web ch07 | gin-web ch06 |
|---|---|---|---|
| 认证 | Session Cookie | OAuth2 Password + JWT | Bearer JWT |
| 密码 | werkzeug hash | passlib bcrypt | bcrypt |
| 保护路由 | @login_required | Depends(get_current_user) | Auth + RequireRole |
6.2 逐步操作表
| 步骤 | 操作 | 验证 |
|---|---|---|
| 1 | go get jwt/v5、x/crypto | 依赖就绪 |
| 2 | 实现 auth/password.go、auth/jwt.go | 单元测试 Hash/Sign |
| 3 | repository/user.go、service/user.go | 按 email 查用户 |
| 4 | handler/auth.go register/login/me | 路由注册 |
| 5 | middleware/auth.go | 401/403 分支 |
| 6 | 种子 admin@example.com | DB 有 admin |
| 7 | curl login 拿 token | access_token 非空 |
| 8 | admin token POST 商品 | 201 |
6.3 依赖
go get github.com/golang-jwt/jwt/v5@v5.2.1
go get golang.org/x/crypto@v0.28.0
6.4 密码 bcrypt
// internal/auth/password.go
package auth
import "golang.org/x/crypto/bcrypt"
const bcryptCost = bcrypt.DefaultCost
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
return string(b), err
}
func CheckPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
| 规则 | 说明 |
|---|---|
| 仅存 hash | User 表列 password_hash |
| 不明文日志 | 禁止 log 密码 |
| cost | DefaultCost(10),可调高增安全 |
与 flask-web ch06 generate_password_hash 等价。
6.5 JWT 签发与校验
// internal/auth/jwt.go
package auth
import (
"errors"
"time"
jwt "github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"uid"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func SignToken(secret string, uid uint, email, role string, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: uid,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "api-go-demo",
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
Subject: email,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
func ParseToken(secret, tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, jwt.ErrTokenInvalidClaims
}
return claims, nil
}
配置:JWT_SECRET(prod ≥ 32 字节)、Access TTL 1h、算法 HS256。
6.6 UserRepository 与 UserService
// internal/repository/user.go — 核心方法
func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) { /* ... */ }
func (r *UserRepository) Create(ctx context.Context, u *model.User) error { /* ... */ }
// internal/service/user.go — Register / Authenticate(见仓库完整版)
6.7 Auth 中间件
// internal/middleware/auth.go
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
"example.com/api-go-demo/internal/auth"
)
func Auth(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
hdr := c.GetHeader("Authorization")
if !strings.HasPrefix(hdr, "Bearer ") {
c.AbortWithStatusJSON(401, gin.H{"code": 40101, "message": "未登录", "data": nil})
return
}
token := strings.TrimPrefix(hdr, "Bearer ")
claims, err := auth.ParseToken(secret, token)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"code": 40101, "message": "无效或过期的令牌", "data": nil})
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Next()
}
}
func RequireRole(roles ...string) gin.HandlerFunc {
allowed := make(map[string]struct{}, len(roles))
for _, r := range roles {
allowed[r] = struct{}{}
}
return func(c *gin.Context) {
roleVal, ok := c.Get("role")
if !ok {
c.AbortWithStatusJSON(401, gin.H{"code": 40101, "message": "未登录", "data": nil})
return
}
role, _ := roleVal.(string)
if _, ok := allowed[role]; !ok {
c.AbortWithStatusJSON(403, gin.H{"code": 40301, "message": "无权限", "data": nil})
return
}
c.Next()
}
}
40301 与 flask-web ch07、fastapi-web ch12 错误码对齐。