🔐 认证与安全
JWT 认证、OAuth 2.0 授权、RBAC 权限控制、API 限流防护——构建安全可靠的服务是生产系统的必修课。
1. JWT 认证
JWT(JSON Web Token)是无状态的认证方案,由三部分组成:
HeadereyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
PayloadeyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIn0
SignatureHMACSHA256(base64(header)+"."+base64(payload), secret)
go get github.com/golang-jwt/jwt/v5
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type TokenConfig struct {
AccessSecret []byte
RefreshSecret []byte
AccessTTL time.Duration // 15 分钟
RefreshTTL time.Duration // 7 天
}
type Claims struct {
UserID int64 `json:"uid"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
}
type JWTService struct {
cfg TokenConfig
}
func NewJWTService(cfg TokenConfig) *JWTService {
return &JWTService{cfg: cfg}
}
// 生成 Token 对 (access + refresh)
func (s *JWTService) GenerateTokenPair(userID int64, email, role string) (*TokenPair, error) {
now := time.Now()
accessExpires := now.Add(s.cfg.AccessTTL)
// Access Token
accessClaims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(accessExpires),
Issuer: "myapp",
},
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).
SignedString(s.cfg.AccessSecret)
if err != nil {
return nil, err
}
// Refresh Token (只存 userID,权限少)
refreshClaims := jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.cfg.RefreshTTL)),
Issuer: "myapp",
}
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).
SignedString(s.cfg.RefreshSecret)
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: accessExpires.Unix(),
}, nil
}
// 验证 Access Token
func (s *JWTService) ValidateAccessToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("意外的签名方法: %v", t.Header["alg"])
}
return s.cfg.AccessSecret, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
return nil, ErrTokenInvalid
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrTokenInvalid
}
return claims, nil
}
var (
ErrTokenExpired = errors.New("token 已过期")
ErrTokenInvalid = errors.New("token 无效")
)
Token 刷新流程
客户端 (Client)
服务器 (Server)
— 登录,获取 Token 对 —
POST /api/v1/auth/login
携带用户名 + 密码
→
←
{"access_token":"…","refresh_token":"…"}
access 有效期 15 min,refresh 有效期 7 天
— 正常请求(access_token 有效期内)—
GET /api/v1/users
Authorization: Bearer <access_token>
携带 access_token 请求资源
→
←
200 OK {"data":[...]}
正常返回数据
— access_token 过期后,用 refresh_token 换新 —
POST /api/v1/auth/refresh
{"refresh_token":"…"}
access_token 已过期,发起刷新
→
←
{"access_token":"new_token…"}
返回新 access_token,无需重新登录
GET /api/v1/users
Authorization: Bearer <new_access_token>
继续正常请求
→
package handler
// POST /api/v1/auth/login
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
user, err := h.userSvc.FindByEmail(c.Request.Context(), req.Email)
if err != nil || !checkPassword(req.Password, user.Password) {
// 注意:不区分"用户不存在"和"密码错误",防止用户枚举
c.JSON(401, gin.H{"error": "邮箱或密码错误"})
return
}
if !user.Active {
c.JSON(403, gin.H{"error": "账户已被禁用"})
return
}
tokens, err := h.jwtSvc.GenerateTokenPair(user.ID, user.Email, user.Role)
if err != nil {
c.JSON(500, gin.H{"error": "Token 生成失败"})
return
}
// Refresh Token 存入 HttpOnly Cookie (防 XSS)
c.SetCookie("refresh_token", tokens.RefreshToken, 7*24*3600,
"/api/v1/auth/refresh", "", true, true)
c.JSON(200, gin.H{
"access_token": tokens.AccessToken,
"expires_at": tokens.ExpiresAt,
})
}
// 密码哈希 (bcrypt)
import "golang.org/x/crypto/bcrypt"
func hashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(b), err
}
func checkPassword(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
2. OAuth 2.0 第三方登录
OAuth 2.0 授权码流程
用户点击"用 GitHub 登录" → 跳转 GitHub 授权页 → 用户同意 → GitHub 回调带 code → 服务器用 code 换 access_token → 获取用户信息
go get golang.org/x/oauth2
package oauth
import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
"golang.org/x/oauth2/google"
)
// GitHub OAuth 配置
var githubOAuth = &oauth2.Config{
ClientID: os.Getenv("GITHUB_CLIENT_ID"),
ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
RedirectURL: "https://myapp.com/auth/github/callback",
Scopes: []string{"user:email"},
Endpoint: github.Endpoint,
}
// Google OAuth 配置
var googleOAuth = &oauth2.Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
RedirectURL: "https://myapp.com/auth/google/callback",
Scopes: []string{"email", "profile"},
Endpoint: google.Endpoint,
}
// Step 1: 重定向到 GitHub 授权页
func (h *OAuthHandler) GithubLogin(c *gin.Context) {
// state 参数防 CSRF
state := generateRandomState()
c.SetCookie("oauth_state", state, 600, "/", "", true, true)
url := githubOAuth.AuthCodeURL(state, oauth2.AccessTypeOffline)
c.Redirect(302, url)
}
// Step 2: GitHub 回调处理
func (h *OAuthHandler) GithubCallback(c *gin.Context) {
// 验证 state 防 CSRF
state, _ := c.Cookie("oauth_state")
if c.Query("state") != state {
c.JSON(400, gin.H{"error": "state 不匹配,可能的 CSRF 攻击"})
return
}
code := c.Query("code")
if code == "" {
c.JSON(400, gin.H{"error": "缺少授权码"})
return
}
// 用 code 换 access_token
token, err := githubOAuth.Exchange(c.Request.Context(), code)
if err != nil {
c.JSON(500, gin.H{"error": "Token 交换失败"})
return
}
// 获取 GitHub 用户信息
client := githubOAuth.Client(c.Request.Context(), token)
resp, _ := client.Get("https://api.github.com/user")
defer resp.Body.Close()
var githubUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
Name string `json:"name"`
}
json.NewDecoder(resp.Body).Decode(&githubUser)
// 查找或创建本地用户
user, err := h.userSvc.FindOrCreateOAuthUser(c.Request.Context(), service.OAuthUserInfo{
Provider: "github",
ProviderID: fmt.Sprintf("%d", githubUser.ID),
Email: githubUser.Email,
Name: githubUser.Name,
})
if err != nil {
c.JSON(500, gin.H{"error": "用户创建失败"})
return
}
// 生成本系统的 JWT
tokens, _ := h.jwtSvc.GenerateTokenPair(user.ID, user.Email, user.Role)
c.Redirect(302, "/dashboard?token="+tokens.AccessToken)
}
3. RBAC 权限控制
go get github.com/casbin/casbin/v2
// model.conf - RBAC 模型定义
// [request_definition]
// r = sub, obj, act
//
// [policy_definition]
// p = sub, obj, act
//
// [role_definition]
// g = _, _
//
// [matchers]
// m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
// policy.csv - 权限规则
// p, admin, /api/v1/users, GET
// p, admin, /api/v1/users, POST
// p, admin, /api/v1/users/:id, DELETE
// p, user, /api/v1/users/me, GET
// p, user, /api/v1/users/me, PATCH
// g, alice, admin
// g, bob, user
package middleware
import (
"net/http"
"github.com/casbin/casbin/v2"
"github.com/gin-gonic/gin"
)
func CasbinMiddleware(enforcer *casbin.Enforcer) gin.HandlerFunc {
return func(c *gin.Context) {
role := c.GetString("user_role") // 来自 JWT 中间件
path := c.FullPath() // 路由路径模式, 如 /api/v1/users/:id
method := c.Request.Method
ok, err := enforcer.Enforce(role, path, method)
if err != nil || !ok {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "权限不足",
"required": fmt.Sprintf("%s %s", method, path),
})
return
}
c.Next()
}
}
// 动态添加权限
func (h *AdminHandler) GrantPermission(c *gin.Context) {
var req struct {
Role string `json:"role" binding:"required"`
Path string `json:"path" binding:"required"`
Method string `json:"method" binding:"required"`
}
c.ShouldBindJSON(&req)
// 运行时动态添加策略
ok, _ := h.enforcer.AddPolicy(req.Role, req.Path, req.Method)
if !ok {
c.JSON(400, gin.H{"error": "权限规则已存在"})
return
}
c.JSON(200, gin.H{"message": "权限添加成功"})
}
4. 安全最佳实践
SQL 注入防护
// ❌ 危险:字符串拼接 SQL
db.Raw("SELECT * FROM users WHERE name = '" + name + "'").Scan(&users)
// ✅ 安全:参数化查询
db.Where("name = ?", name).Find(&users)
db.Raw("SELECT * FROM users WHERE name = ?", name).Scan(&users)
XSS 防护
import (
"github.com/microcosm-cc/bluemonday"
"html/template"
)
var sanitizer = bluemonday.UGCPolicy()
func sanitizeInput(input string) string {
// 允许基本 HTML 标签,移除恶意脚本
return sanitizer.Sanitize(input)
}
// 渲染 HTML 时使用 template.HTML 安全转义
func renderTemplate(w http.ResponseWriter, data string) {
t := template.Must(template.New("").Parse(`{{.}}
`))
t.Execute(w, data) // Go template 默认 HTML 转义
}
请求限流
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
// 按 IP 限流
type IPRateLimiter struct {
ips map[string]*rate.Limiter
mu sync.RWMutex
rps float64
burst int
}
func NewIPRateLimiter(rps float64, burst int) *IPRateLimiter {
rl := &IPRateLimiter{
ips: make(map[string]*rate.Limiter),
rps: rps,
burst: burst,
}
// 定期清理过期 limiter
go rl.cleanup()
return rl
}
func (rl *IPRateLimiter) getLimiter(ip string) *rate.Limiter {
rl.mu.RLock()
limiter, ok := rl.ips[ip]
rl.mu.RUnlock()
if ok {
return limiter
}
rl.mu.Lock()
limiter = rate.NewLimiter(rate.Limit(rl.rps), rl.burst)
rl.ips[ip] = limiter
rl.mu.Unlock()
return limiter
}
func (rl *IPRateLimiter) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
limiter := rl.getLimiter(ip)
if !limiter.Allow() {
c.Header("Retry-After", "1")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "请求过于频繁,请稍后再试",
})
return
}
c.Next()
}
}
func (rl *IPRateLimiter) cleanup() {
for range time.Tick(10 * time.Minute) {
rl.mu.Lock()
rl.ips = make(map[string]*rate.Limiter)
rl.mu.Unlock()
}
}
安全响应头
func SecurityHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 防止点击劫持
c.Header("X-Frame-Options", "DENY")
// 防止 MIME 类型嗅探
c.Header("X-Content-Type-Options", "nosniff")
// XSS 保护 (旧浏览器)
c.Header("X-XSS-Protection", "1; mode=block")
// HSTS: 强制 HTTPS
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// 内容安全策略
c.Header("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'")
// 引用来源策略
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// 权限策略
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
c.Next()
}
}
敏感数据加密
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io"
)
// AES-GCM 加密 (认证加密,同时保证机密性和完整性)
func Encrypt(key []byte, plaintext string) (string, error) {
block, err := aes.NewCipher(key) // key 必须是 16, 24 或 32 字节
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func Decrypt(key []byte, ciphertextB64 string) (string, error) {
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return "", err
}
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonceSize := gcm.NonceSize()
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
安全红线
① 密码必须用 bcrypt/argon2 哈希,绝不明文存储 ② Secret Key 必须从环境变量读取,绝不硬编码 ③ 数据库连接字符串不能出现在日志中 ④ 用户输入必须验证和消毒 ⑤ 生产环境必须开启 HTTPS