49·Web 开发进阶

Session 与 Cookie

sessioncookiejwtauth

第49课:Session 与 Cookie

🎯 学习目标

通过本节课的学习,你将能够:

  1. 理解 HTTP 协议无状态性的本质及 Cookie/Session 的作用
  2. 掌握在 Go 中使用标准库操作 Cookie 的方法
  3. 学会使用第三方库实现 Session 管理
  4. 了解 JWT 与传统 Session 认证的差异
  5. 掌握用户认证中的安全最佳实践

📚 核心概念

HTTP 的无状态性

HTTP 协议本身是无状态的——服务器不会记住之前与客户端的任何交互。就像你每次去图书馆,管理员都不记得你借过什么书一样。

Cookie 和 Session 的出现就是为了解决这个问题,让服务器能够"记住"用户。

Cookie:客户端的记忆卡

工作原理

  1. 服务器在响应中通过 Set-Cookie 头发送一个小数据包
  2. 浏览器收到后自动保存
  3. 后续每次请求都会自动在 Cookie 头中带上这个数据
// Cookie 本质就是一个键值对
name := "user_id"
value := "123456"

Session:服务器的记忆库

工作原理

  1. 服务器为每个用户创建唯一的 Session ID
  2. 将这个 ID 通过 Cookie 发送给客户端
  3. 服务器存储与该 ID 关联的用户数据
  4. 后续请求通过 Cookie 中的 Session ID 获取用户数据

类比:Cookie 是你的会员卡,Session 是商场里的会员系统。

JWT vs Session

特性SessionJWT
存储位置服务器客户端
扩展性需要共享存储天然支持分布式
性能需要查询存储自包含,验证快
安全性依赖服务器存储安全需要妥善保管密钥

💻 代码示例

1. 使用标准库操作 Cookie

package main

import (
    "fmt"
    "log"
    "net/http"
    "time"
)

func main() {
    // 处理设置 Cookie 的请求
    http.HandleFunc("/set-cookie", func(w http.ResponseWriter, r *http.Request) {
        // 创建 Cookie
        cookie := &http.Cookie{
            Name:     "user_id",
            Value:    "1234567890",
            Path:     "/",
            MaxAge:   3600, // 1小时有效期
            HttpOnly: true, // 禁止 JavaScript 访问
            Secure:   true, // 仅通过 HTTPS 传输
            SameSite: http.SameSiteStrictMode,
        }
        
        // 设置 Cookie
        http.SetCookie(w, cookie)
        
        // 也可以设置多个 Cookie
        themeCookie := &http.Cookie{
            Name:  "theme",
            Value: "dark",
            Path:  "/",
        }
        http.SetCookie(w, themeCookie)
        
        fmt.Fprintln(w, "Cookie 设置成功!")
        log.Println("Cookie 已设置")
    })

    // 处理读取 Cookie 的请求
    http.HandleFunc("/get-cookie", func(w http.ResponseWriter, r *http.Request) {
        // 读取指定 Cookie
        userIDCookie, err := r.Cookie("user_id")
        if err != nil {
            if err == http.ErrNoCookie {
                http.Error(w, "Cookie 不存在", http.StatusNotFound)
                return
            }
            http.Error(w, "读取 Cookie 失败", http.StatusInternalServerError)
            return
        }
        
        // 读取所有 Cookie
        allCookies := r.Cookies()
        
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintf(w, `{
            "user_id": "%s",
            "theme": "%s",
            "cookie_count": %d
        }`, userIDCookie.Value, r.FormValue("theme"), len(allCookies))
    })

    // 处理删除 Cookie 的请求
    http.HandleFunc("/delete-cookie", func(w http.ResponseWriter, r *http.Request) {
        // 删除 Cookie 的原理:设置过期时间为过去
        cookie := &http.Cookie{
            Name:    "user_id",
            Value:   "",
            Path:    "/",
            MaxAge:  -1, // 立即过期
            Expires: time.Now().Add(-1 * time.Hour),
        }
        http.SetCookie(w, cookie)
        
        fmt.Fprintln(w, "Cookie 已删除")
    })

    log.Println("服务器启动在 :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

2. 使用第三方库实现 Session 管理

package main

import (
    "encoding/gob"
    "fmt"
    "log"
    "net/http"
    "time"
    
    "github.com/gorilla/sessions"
)

// 定义 Session 存储
var store = sessions.NewCookieStore([]byte("your-secret-key"))

func init() {
    // 注册自定义类型以便序列化
    gob.Register(time.Time{})
    
    // 配置 Session
    store.Options = &sessions.Options{
        Path:     "/",
        MaxAge:   3600 * 8, // 8小时
        HttpOnly: true,
        Secure:   true,
    }
}

func main() {
    http.HandleFunc("/login", loginHandler)
    http.HandleFunc("/dashboard", authMiddleware(dashboardHandler))
    http.HandleFunc("/logout", logoutHandler)
    
    log.Println("服务器启动在 :8081")
    log.Fatal(http.ListenAndServe(":8081", nil))
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
    // 获取或创建 Session
    session, _ := store.Get(r, "session-id")
    
    // 设置 Session 值
    session.Values["authenticated"] = true
    session.Values["user_id"] = 1001
    session.Values["username"] = "gopher"
    session.Values["login_time"] = time.Now()
    
    // 保存 Session
    err := session.Save(r, w)
    if err != nil {
        http.Error(w, "Session 保存失败", http.StatusInternalServerError)
        return
    }
    
    fmt.Fprintf(w, "登录成功!用户 ID: %d", session.Values["user_id"].(int))
}

func dashboardHandler(w http.ResponseWriter, r *http.Request) {
    // 从 context 获取用户信息(中间件已验证)
    userID := r.Context().Value("user_id").(int)
    username := r.Context().Value("username").(string)
    loginTime := r.Context().Value("login_time").(time.Time)
    
    fmt.Fprintf(w, `欢迎回来,%s!
用户 ID: %d
登录时间: %s
当前时间: %s`, 
        username, userID, 
        loginTime.Format("2006-01-02 15:04:05"),
        time.Now().Format("2006-01-02 15:04:05"))
}

func logoutHandler(w http.ResponseWriter, r *http.Request) {
    // 获取 Session
    session, _ := store.Get(r, "session-id")
    
    // 删除 Session
    session.Options.MaxAge = -1
    session.Save(r, w)
    
    fmt.Fprintln(w, "已成功登出")
}

// 认证中间件
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        session, _ := store.Get(r, "session-id")
        
        // 检查是否已认证
        auth, ok := session.Values["authenticated"].(bool)
        if !ok || !auth {
            http.Error(w, "请先登录", http.StatusUnauthorized)
            return
        }
        
        // 将用户信息添加到请求上下文
        ctx := r.Context()
        ctx = setContextValue(ctx, "user_id", session.Values["user_id"])
        ctx = setContextValue(ctx, "username", session.Values["username"])
        ctx = setContextValue(ctx, "login_time", session.Values["login_time"])
        
        // 调用下一个处理器
        next.ServeHTTP(w, r.WithContext(ctx))
    }
}

// 辅助函数:向上下文添加值
type contextKey string

const (
    userIDKey    contextKey = "user_id"
    usernameKey  contextKey = "username"
    loginTimeKey contextKey = "login_time"
)

func setContextValue(ctx context.Context, key interface{}, value interface{}) context.Context {
    return context.WithValue(ctx, key, value)
}

3. 使用 JWT 实现无状态认证

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "strings"
    "time"
    
    "github.com/golang-jwt/jwt/v5"
)

// JWT 密钥
var jwtSecret = []byte("your-256-bit-secret")

// 声明 Claims
type Claims struct {
    UserID   int    `json:"user_id"`
    Username string `json:"username"`
    jwt.RegisteredClaims
}

func main() {
    http.HandleFunc("/jwt/login", jwtLoginHandler)
    http.HandleFunc("/jwt/profile", jwtAuthMiddleware(jwtProfileHandler))
    
    log.Println("JWT 服务器启动在 :8082")
    log.Fatal(http.ListenAndServe(":8082", nil))
}

func jwtLoginHandler(w http.ResponseWriter, r *http.Request) {
    // 模拟用户验证
    username := r.FormValue("username")
    password := r.FormValue("password")
    
    if username != "admin" || password != "password" {
        http.Error(w, "用户名或密码错误", http.StatusUnauthorized)
        return
    }
    
    // 创建 JWT Claims
    expirationTime := time.Now().Add(24 * time.Hour)
    claims := &Claims{
        UserID:   1001,
        Username: username,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expirationTime),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "golang-web-app",
        },
    }
    
    // 创建 Token
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    tokenString, err := token.SignedString(jwtSecret)
    if err != nil {
        http.Error(w, "Token 生成失败", http.StatusInternalServerError)
        return
    }
    
    // 返回 Token
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "token":      tokenString,
        "expires_at": expirationTime,
        "user_id":    claims.UserID,
        "username":   claims.Username,
    })
}

func jwtProfileHandler(w http.ResponseWriter, r *http.Request) {
    // 从上下文获取 Claims
    claims := r.Context().Value("claims").(*Claims)
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "user_id":    claims.UserID,
        "username":   claims.Username,
        "issued_at":  claims.IssuedAt.Time,
        "expires_at": claims.ExpiresAt.Time,
    })
}

// JWT 认证中间件
func jwtAuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // 从 Authorization 头获取 Token
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "缺少 Authorization 头", http.StatusBadRequest)
            return
        }
        
        // 提取 Token(Bearer 格式)
        parts := strings.SplitN(authHeader, " ", 2)
        if !(len(parts) == 2 && parts[0] == "Bearer") {
            http.Error(w, "Authorization 格式错误", http.StatusBadRequest)
            return
        }
        
        // 解析和验证 Token
        claims := &Claims{}
        token, err := jwt.ParseWithClaims(parts[1], claims, func(token *jwt.Token) (interface{}, error) {
            // 验证签名算法
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("意外的签名方法: %v", token.Header["alg"])
            }
            return jwtSecret, nil
        })
        
        if err != nil {
            http.Error(w, "Token 无效: "+err.Error(), http.StatusUnauthorized)
            return
        }
        
        if !token.Valid {
            http.Error(w, "Token 已过期", http.StatusUnauthorized)
            return
        }
        
        // 将 Claims 添加到上下文
        ctx := setContextValue(r.Context(), "claims", claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    }
}

🏋️ 实践练习

练习 1:基础 Cookie 操作(简单)

创建一个简单的 Web 应用,实现以下功能:

  1. 访问 /color 时设置一个主题颜色 Cookie
  2. 访问 /show 时读取并显示存储的颜色
  3. 访问 /reset 时删除颜色 Cookie

预期输出示例

# 访问 /color
Cookie 设置成功!

# 访问 /show
当前主题颜色: blue

# 访问 /reset
Cookie 已删除

练习 2:Session 用户认证(中等)

实现一个简单的登录系统:

  1. 创建登录页面,接收用户名和密码
  2. 登录成功后存储用户信息到 Session
  3. 创建受保护的 /profile 页面,只有登录用户可以访问
  4. 实现登出功能

预期输出示例

# 访问 /login (POST)
登录成功!欢迎回来,gopher

# 访问 /profile
个人资料页:用户ID: 1001, 用户名: gopher

# 访问 /logout
已成功登出

练习 3:JWT 认证系统(困难)

扩展 JWT 认证系统,实现以下功能:

  1. 添加注册接口 /register
  2. 实现 Token 刷新机制
  3. 添加基于角色的访问控制(admin/user)
  4. 实现 Token 黑名单(登出功能)

预期输出示例

// POST /register
{
  "message": "注册成功",
  "user_id": 1002,
  "token": "eyJhbGciOiJIUzI1NiIs..."
}

// GET /admin/dashboard (需要 admin 角色)
{
  "message": "管理面板",
  "admin_data": ["统计信息", "用户管理"]
}

⚠️ 常见错误

1. Cookie 安全性错误

// ❌ 错误做法
cookie := &http.Cookie{
    Name:  "session",
    Value: "abc123",
}

// ✅ 正确做法
cookie := &http.Cookie{
    Name:     "session",
    Value:    "abc123",
    HttpOnly: true,  // 防止 XSS
    Secure:   true,  // 仅 HTTPS
    SameSite: http.SameSiteStrictMode,  // 防止 CSRF
}

2. Session 存储不当

// ❌ 错误:使用不安全的存储
store := sessions.NewFilesystemStore("", []byte("secret"))

// ✅ 正确:使用加密存储
store := sessions.NewCookieStore(secureKey)

3. JWT 密钥管理错误

// ❌ 错误:硬编码密钥
jwtSecret := []byte("my-secret-key")

// ✅ 正确:从环境变量读取
jwtSecret := []byte(os.Getenv("JWT_SECRET"))

4. Session 固定攻击

// ❌ 错误:登录后不更新 Session ID
session, _ := store.Get(r, "session-id")
// ... 设置认证状态

// ✅ 正确:登录后创建新的 Session
oldSession.Options.MaxAge = -1  // 销毁旧 Session
session, _ := store.New(r, "new-session-id")

5. 未验证 JWT 签名算法

// ❌ 错误:接受任何签名算法
token, _ := jwt.Parse(tokenString,

练习编辑器

go
Loading...

继续学习

完成本课后,建议继续学习下一课「路由库:Chi/Gin」 以巩固所学知识。