GO学习笔记 | 第七章节 JWT、Redis 入门与 K8s 部署实战| Session 深入与 JWT 入门

代码仓库地址:Darling-123456/go_learning: go学习过程记录

核心内容:多实例 Session 共享、面向接口编程实践、Session 刷新机制、JWT 结构原理与登录校验
前置知识:Gin Session 中间件、Cookie/Session 机制、分层架构


一、Debug 技巧:分层断点定位

1.1 请求经过的完整链路

1
2
3
4
5
6
浏览器请求 → 日志确认到达
→ Middleware 1 → Middleware 2 → Middleware 3
→ handler(业务入口)
→ service(业务逻辑)
→ repository(存储抽象)
→ dao(数据库操作)

1.2 定位问题的策略

  1. 先看 Gin 日志:确认后端是否收到请求(有请求一定有日志)
  2. 在 handler 入口打断点:看是否到达业务逻辑(没到就是中间件拦截了)
  3. 逐层打断点:web → service → repository → dao,每层入口一个断点,看哪层没进去
  4. 分支全覆盖:每个 if/else 分支都打断点,确认走了哪个分支

定位问题本质上是经验——踩坑多了,看错误信息就知道在哪。亲手写的代码定位更快。


二、Session Store:数据存哪?

image-20260625135446388

2.1 回顾 Session 的存储结构

image-20260625135502856

1
2
3
4
5
Cookie(my_session = abc123)
↓ 指向
Store(真正存数据的地方)
↓ 存着
业务数据(如 userId=123)

Cookie 里永远只放 sessionID,真正的数据在 Store 里。

2.2 五种 Store 实现

Gin Session 插件提供了多种 Store:

Store 数据存哪 适用场景
cookie Cookie 本身 数据不敏感、量很小(基本不用)
memstore 进程内存 开发 / 测试 / 单实例
redis Redis 生产多实例(最常用)
gorm 数据库(MySQL/PG 等) 没有 Redis 时的备选
mongodb / postgres 对应数据库 特殊场景

image-20260625135922873

2.3 Memstore:单实例可用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/memstore"
)

// 生成安全的密钥对(32 或 64 字节)
store := memstore.NewStore(
[]byte("32-or-64-byte-encryption-key"),
[]byte("32-or-64-byte-authentication-key"),
)
store.Options(sessions.Options{
HttpOnly: true,
Secure: false, // 开发环境 false,生产必须 true
MaxAge: 3600,
})
server.Use(sessions.Sessions("mysession", store))

密钥用随机生成器生成,越长越安全。公网生成也没关系——别人不知道你用在哪。

2.4 多实例部署的问题

image-20260625140705485

1
2
实例A(登录了,memstore 有 session)
实例B(没有 session,判定未登录)

Memstore 的数据只在当前进程内存里,实例 A 和实例 B 不互通。 用户请求被负载均衡到实例 B 时,就变成「未登录」。

解决方案:用 Redis 做集中式 Session 存储——所有实例共享同一个 Redis。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# docker-compose.yaml
version: '3.0'
# 我这个 docker compose 由几个服务组成
services:
mysql8:
image: mysql:8.0
restart: always
command: --default-authentication-plugin=mysql_native_password
environment:
MYSQL_ROOT_PASSWORD: 040725ge
volumes:
# - 初始化脚本
- ./script/mysql/:/docker-entrypoint-initdb.d/
ports:
# - 外部访问用 13316
- "13316:3306"

redis:
image: 'redis:latest'
environment:
- ALLOW_EMPTY_PASSWORD=yes
ports:
- '6379:6379'

image-20260625151044943

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func initWebServer() *gin.Engine {
server := gin.Default()
// CORS 配置,允许前端跨域请求
server.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
//步骤一
//store := cookie.NewStore([]byte("your-secret-key"))
store, err := redis.NewStore(16, "tcp",
"localhost:6379", "root", "",
[]byte("qOYZLAuWmwkxAKG6bijwru9ghNNS9rHc"),
[]byte("8Mv11Olt6x3DX97rUE1exp9XISEMSZJl"))
if err != nil {
panic(err)
}
server.Use(sessions.Sessions("mysession", store))
//步骤三 链式调用
server.Use(middleware.NewLoginMiddlewareBuilder().
IgnorePaths("/users/signup").
IgnorePaths("/users/login").Build())

return server
}

把步骤一的cookie实现的session换成了redis

1
2
3
4
5
6
#启动docker里面的redis的命令,记得把webook-redis-1换成你自己容器里面的redis的名字
docker exec -it webook-redis-1 redis-cli
#查看容器名字
docker ps
#redis检测现在正在发生什么的命令
monitor

image-20260625150530253

这是登录过程中的redis的样子的体现


三、面向接口编程:第一次真正感受到好处

image-20260625151551525

Store 是接口,可以随便换

1
2
3
4
5
6
7
8
9
10
11
// sessions.Sessions 接收的是 sessions.Store 接口
// 不是具体实现!

// 开发环境:内存
store := memstore.NewStore(key, authKey)

// 生产环境:Redis(只改这一行!)
store, _ := redis.NewStore(10, "tcp", "localhost:6379", "", key, authKey)

// 其他代码完全不动
server.Use(sessions.Sessions("mysession", store))

换了 Store 之后

  • handler 代码:不用改
  • service 代码:不用改
  • 登录校验中间件:不用改
  • 只在 main.go 的初始化里换一行

这就是面向接口编程的核心价值——依赖接口而非具体实现,底层可以随意替换。


四、Session 配置详解

image-20260625164817493

里面的字段都是cookie的字段

1
2
3
4
5
6
7
8
sess.Options(sessions.Options{
Path: "/",
Domain: "",
MaxAge: 3600, // 过期时间(秒)
Secure: true, // 仅 HTTPS 传输(生产必开)
HttpOnly: true, // JS 不可读(生产必开)
SameSite: http.SameSiteLaxMode,
})

关键提醒

  • Secure: true 开启后,HTTP 协议下 Cookie 不会生效——开发环境先用 false
  • MaxAge 设为负数 → Cookie 立即过期 → 实现退出登录

4.2 退出登录

把maxage设置为负数就是退出登录

1
2
3
4
5
6
7
// 退出登录
func (u *UserHandler) Logout(ctx *gin.Context) {
sess := sessions.Default(ctx)
sess.Options(sessions.Options{MaxAge: -1})
sess.Save()
ctx.String(http.StatusOK, "退出登录")
}

五、Session 刷新(Sliding Expiration)

image-20260625163543320

image-20260625165502881

5.1 需求

image-20260625170817021

image-20260625170916733

image-20260625171010454

  • 登录态保持 30 分钟
  • 用户活跃使用时自动续期
  • 实现思路:在登录校验中间件里检测上次刷新时间,超过阈值就刷新

5.2 实现方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package middleware

import (
"net/http"
"time"

"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)

type LoginMiddlewareBuilder struct {
paths []string
}

func NewLoginMiddlewareBuilder() *LoginMiddlewareBuilder {
return &LoginMiddlewareBuilder{}
}

func (l *LoginMiddlewareBuilder) IgnorePaths(path string) *LoginMiddlewareBuilder {
l.paths = append(l.paths, path)
return l
}

func (l *LoginMiddlewareBuilder) Build() gin.HandlerFunc {
return func(ctx *gin.Context) {
for _, path := range l.paths {
if ctx.Request.URL.Path == path {
return
}
}
//不需要登录校验的路由
/*if ctx.Request.URL.Path == "/users/login" ||
ctx.Request.URL.Path == "/users/signup" {
return
}*/
sess := sessions.Default(ctx)

/*------*/

/*if sess == nil {
//说明没有登录
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}*/
id := sess.Get("userId")
if id == nil {
//说明没有登录
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}

updateTime := sess.Get("update_time")
sess.Set("userId", id)
sess.Options(sessions.Options{
MaxAge: 60 * 30,
})
now := time.Now().UnixMilli()
//说明还没刷新过,刚登陆
if updateTime == nil {
sess.Set("update_time", now)
sess.Save()
return
}

//Update_time是有的,也就是说不是刚登陆,每隔60秒刷一次
updateTimeVal, ok := updateTime.(int64)
if !ok {
ctx.AbortWithStatus(http.StatusInternalServerError)
return
}
if now-updateTimeVal > 60*1000 {
sess.Set("update_time", now)
sess.Save()
return
}
}
}

要点

  • 刷新不是登录本身的概念,是管理 Session 的概念——放在中间件里而非登录逻辑里
  • 每次刷新要重新保存所有数据(userId 也要重新放回去)
  • 刷新时要重新设置 MaxAge

六、JWT(JSON Web Token)

image-20260625180839198

6.1 为什么要学 JWT?

Session 需要服务端存储(内存/Redis),JWT 是无状态的——数据直接编码在 Token 里,服务端不需要存。

6.2 JWT 的结构

image-20260625183113667

1
2
3
4
eyJhbGciOiJIUzUxMiJ9.eyJ1c2VySWQiOjEyM30.签名部分
↓ ↓ ↓
Header Payload Signature
(Base64编码) (Base64编码数据) (防篡改签名)

. 分割的三段:

部分 内容 作用
Header(红色) 签名算法类型(如 HS512) 告诉如何验证
Payload(紫色) 真正的数据(如 userId:123 业务数据
Signature(蓝色) 用密钥对前两段签名 防篡改

防篡改原理:攻击者改了 Payload → 重新算签名 → 和蓝色段对不上 → 服务端直接拒绝。没有密钥就算不出正确的签名。

6.3 JWT 库选择

image-20260625183531129

Go 的标准 JWT 库:github.com/golang-jwt/jwt/v5

老师不建议用 Gin 的 JWT 插件——封装过于复杂,屏蔽了 JWT 核心理解。直接用原始 API 更清晰。

image-20260626152630191

1. Raw string

  • 含义:原始的 JWT 字符串
  • 对应标准:完整的 JWT,即类似 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3T1WWT51SzEqFRI 这种格式。
  • 用途:通常用于调试、日志记录,或者当需要对 Token 做特殊字符串处理时使用(不过正常情况下不需要用到它)。

2. Method SigningMethod

  • 含义:代表签名算法的接口对象。
  • 对应标准:JWT 头部的 alg(算法)字段。
  • 用途:包含了用于生成和验证签名的加密方法。常见的实现有 jwt.SigningMethodHS256(HMAC-SHA256)、jwt.SigningMethodRS256(RSA-SHA256)等。
  • 开发注意:在解析 Token 时,这个字段在验证前是空的,验证后库会根据 JWT 的头部自动赋予正确的值。

3. Header map[string]interface{}

  • 含义:JWT 的头部数据,是一个 Go 的 Map。
  • 对应标准:JWT 的第一部分。
  • 用途:包含 alg(加密算法)和 typ(类型,通常是 "JWT"),以及你也可以在此放入自定义的头部信息。

4. Claims Claims

  • 含义:JWT 的载荷(Payload)数据。这里的 Claims 是一个接口类型。
  • 对应标准:JWT 的第二部分(加密前的 JSON 数据)。
  • 用途:这是业务核心。开发者需要自己定义结构体来实现这个 Claims 接口(比如包含 UserIdUsername 以及标准时间 exp 等)。你验证完 Token 后,业务数据就从这里获取

5. Signature []byte

  • 含义签名数据,以 []byte 字节切片的形式存储。
  • 对应标准:JWT 的第三部分(加密后的二进制数据)。
  • 用途:这是 JWT 防篡改的核心。当解析 Token 时,库会拿着你的密钥,用 Method 里的算法,把 HeaderClaims 重新加密算出一个签名,然后对比这个 Signature 是否一致。

6. Valid bool

  • 含义:代表当前 Token 是否有效
  • 用途
    • 如果 JWT 解析成功,签名验证通过,并且没有过期,该字段为 true
    • 如果签名对不上、或者 Token 过期、或者解析失败,该字段为 false
  • 开发注意:通常你在拦截器(Middleware)里拿到 Token 对象后,必须检查 token.Valid 是否为 true,才能允许后续的请求通过。

6.4 签名算法选择

1
2
3
4
5
6
7
8
9
// 对称加密(推荐入门)
jwt.SigningMethodHS256 // HMAC-SHA256, key 32 字节
jwt.SigningMethodHS384 // HMAC-SHA384, key 48 字节
jwt.SigningMethodHS512 // HMAC-SHA512, key 64 字节

// 非对称加密(更安全,生产推荐)
jwt.SigningMethodRS256 // RSA-SHA256
jwt.SigningMethodES256 // ECDSA-SHA256
jwt.SigningMethodEdDSA // Ed25519
  • HS 系列:密钥相同,简单;用 HS512 即可
  • RS/ES 系列:公私钥分离,更安全但更复杂

6.5 登录时生成 JWT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// JWT实现的登录
func (u *UserHandler) LoginJWT(ctx *gin.Context) {
type LoginReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
var req LoginReq
if err := ctx.Bind(&req); err != nil {
return
}
user, err := u.svc.Login(ctx, req.Email, req.Password)
if err == service.ErrInvalidUserPassword {
ctx.String(http.StatusOK, "用户名或密码不对")
return
}
if err != nil {
ctx.String(http.StatusOK, "系统错误")
return
}

//jwt登录
// 创建 JWT Token
token := jwt.New(jwt.SigningMethodHS512)
// 用密钥签名
tokenStr, err := token.SignedString([]byte("qOYZLAuWmwkxAKG6bijwru9ghNNS9rHc"))
if err != nil {
ctx.String(http.StatusInternalServerError, "系统错误")
return
}
ctx.Header("x-jwt-token", tokenStr)
fmt.Println(tokenStr)
fmt.Println(user)
ctx.String(http.StatusOK, "登录成功")
}

6.6 JWT 登录校验中间件

这个还没有携带uid等数据,只是完成了简单登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package middleware

import (
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)

// jwt的登录校验
type LoginJWTMiddlewareBuilder struct {
paths []string
}

func NewLoginJWTMiddlewareBuilder() *LoginJWTMiddlewareBuilder {
return &LoginJWTMiddlewareBuilder{}
}

func (l *LoginJWTMiddlewareBuilder) IgnorePaths(path string) *LoginJWTMiddlewareBuilder {
l.paths = append(l.paths, path)
return l
}

func (l *LoginJWTMiddlewareBuilder) Build() gin.HandlerFunc {
return func(ctx *gin.Context) {
for _, path := range l.paths {
if ctx.Request.URL.Path == path {
return
}
}
// Step 1: 从 Authorization 请求头取 Token
//用JWT进行校验
tokenHeader := ctx.GetHeader("Authorization")
if tokenHeader == "" {
//没登录 返回401
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
// Step 2: 解析 "Bearer <token>" 格式
//按照空格切割
segs := strings.Split(tokenHeader, ".")
if len(segs) != 2 {
//没登录 有人瞎搞
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
tokenStr := segs[1]
// Step 3: 验证签名
token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
return []byte("qOYZLAuWmwkxAKG6bijwru9ghNNS9rHc"), nil
})
if err != nil {
//没登录
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
//err为nil,token不为nil
if token == nil || !token.Valid {
//没登录
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
}
}

6.7 JWT携带数据

前置:定义 Claims 结构体

为什么需要自定义 Claims?

jwt.MapClaimsuserId,但它是 map[string]interface{}——取值时需要知道 key 和类型,没有编译期检查。用自定义结构体解决。

1
2
3
4
type UserClaims struct {
jwt.RegisteredClaims // 嵌入标准 claims(ExpiresAt、IssuedAt 等)
Uid int64 // 自定义字段:用户 ID
}

jwt.RegisteredClaims 提供了 ExpiresAtUid 是自己要携带的业务数据。

前置:改造 CORS 中间件

之前 CORS 配置里没允许 Authorization 头,需要加上:

1
2
3
4
5
6
7
server.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{"POST", "GET", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "Authorization"}, // ← 加上Authorization!
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))

步骤一:登录时签发 —— 把 userId 塞进 token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//  middleware/login_jwt.go
func (u *UserHandler) LoginJWT(ctx *gin.Context) {
// 1. 校验邮箱密码(和 Session 版一样)
user, err := u.svc.Login(ctx, req.Email, req.Password)

// 2. 创建 claims,把 userId 塞进去
claims := UserClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), // 1 分钟过期
},
Uid: user.Id, // ← 核心:把 userId 写进 token
}

// 3. 签名生成 token
token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims)
tokenStr, _ := token.SignedString([]byte("密钥"))

// 4. 返回给前端
ctx.Header("x-jwt-token", tokenStr)
ctx.String(http.StatusOK, "登录成功")
}

token 解码后长这样:

1
2
3
Header:  {"alg":"HS512","typ":"JWT"}
Payload: {"Uid":1,"exp":1719480000} ← userId 就在里面
Signature: 密钥签名

和 Session 的核心区别:Session 版是把 userId 存 Redis,JWT 版是直接写在 token 里给前端。

步骤二:中间件验签 —— 从 token 里取出 userId

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (l *LoginJWTMiddlewareBuilder) Build() gin.HandlerFunc {
return func(ctx *gin.Context) {
// 1. 从 Authorization 头取 token
tokenHeader := ctx.GetHeader("Authorization") // "Bearer eyJhbG..."
segs := strings.SplitN(tokenHeader, " ", 2)
tokenStr := segs[1]

// 2. 验签 + 解析,claims 会被自动填充
claims := &web.UserClaims{}
token, _ := jwt.ParseWithClaims(tokenStr, claims,
func(token *jwt.Token) (interface{}, error) {
return []byte("密钥"), nil
},
)

// 3. 把 claims 存入 Gin 的 context,后续 handler 直接拿
ctx.Set("claims", claims)
ctx.Set("userId", claims.Uid) // ← 业务代码直接用
}
}

验签通过后,claims.Uid 就是登录时塞进去的 user.Id

步骤三:Handler 使用 —— 直接取 claims

1
2
3
4
5
6
func (u *UserHandler) ProfileJWT(ctx *gin.Context) {
c, _ := ctx.Get("claims")
claims := c.(*UserClaims) // 类型断言
println(claims.Uid) // 拿到当前登录用户的 ID
// ... 查数据库,返回用户信息 ...
}

不用查 Redis,不用查 Session,中间件已经把 userId 放好了,直接拿。

完整链路

1
2
3
4
5
6
7
8
9
10
11
登录:POST /users/login
→ 查数据库验密码
→ claims{Uid: 1, ExpiresAt: 1分钟后}
→ 签名 → token 字符串
→ x-jwt-token 响应头返回

后续请求:GET /users/profile
→ Authorization: Bearer eyJhbG...
→ 中间件验签 → claims{Uid: 1}
→ ctx.Set("userId", 1)
→ ProfileJWT → ctx.Get("claims") → 拿到 userId

JWT 刷新

1
2
3
4
5
6
7
8
9
// 中间件里:剩余不足 50 秒时自动续期
if claims.ExpiresAt.Sub(now) < time.Second*50 {
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Minute))
tokenStr, _ = token.SignedString([]byte("密钥"))
if err!=nil{
log.println("jwt token刷新失败")
}
ctx.Header("x-jwt-token", tokenStr) // 新 token 返回
}

前端从响应头拿新 token,替换旧的,实现”滑动过期”。


和 Session 版的关键区别:JWT 的 userId 在 token 里(客户端自证),不需要 Redis 存状态。

6.8 JWT vs Session

Session JWT
状态 有状态(服务端存数据) 无状态(数据在 Token 里)
扩展性 需要共享存储(Redis) 天然支持多实例
安全性 Session ID 被盗 = 身份被盗 Token 被盗 = 身份被盗(有效期短可缓解)
数据量 随意存 不适合大量数据(Token 会很长)
吊销 服务端删掉即可 需要黑名单机制

实际项目中经常混用:JWT 存基本信息 + Session 存敏感数据。

image-20260627162643827

6.9 JWT登录全流程

image-20260627104116664

image-20260627104134470

image-20260627161759683


七、核心概念速查

概念 一句话解释
Store 接口 Session 数据的存储抽象,内存/Redis/数据库都能实现
Memstore 基于进程内存,单实例可用,多实例数据不互通
面向接口 依赖接口不依赖实现,换 Store 只改一行初始化代码
MaxAge Cookie/Session 过期时间,设负数即删除
滑动刷新 用户活跃时自动延长 Session 有效期
JWT 三段式 Header.Payload.Signature,. 分割
Signature 用密钥对前两段签名,保证数据未被篡改
Bearer 前缀 JWT 放在 Authorization 头的标准格式