Featured image of post Gin 中间件实战:JWT 认证与请求日志异步落盘 MongoDB

Gin 中间件实战:JWT 认证与请求日志异步落盘 MongoDB

在 Gin 中实现 JWT 认证中间件,并将请求日志通过 channel 异步写入 MongoDB

审计日志一条不能丢

宠物医疗 SaaS 的 B/S 版后端需要统一的认证和审计。所有 API 请求都要校验 JWT,把用户和医院上下文提取出来;同时合规要求所有请求日志落盘,请求方法、路径、参数、响应状态码、耗时、操作者,一样不能少,保留半年备查。

同步写日志会拖慢接口响应。日志结构嵌套深、量又大,放 MySQL 里查询和归档都别扭,我选了 MongoDB,通过 channel 异步写,不阻塞主请求。

认证与审计日志的处理链

把身份塞进 Context

JWT 中间件用 golang-jwt/jwt/v5,从 Authorization 头解析 token,校验签名和过期时间,再把用户 ID、医院 ID、角色注入 gin.Context,后面的 handler 直接取用:

 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
type Claims struct {
    UserID   int64  `json:"uid"`
    HospID   int64  `json:"hid"`
    RoleCode string `json:"rol"`
    jwt.RegisteredClaims
}

func JWTAuth(signingKey []byte) gin.HandlerFunc {
    return func(c *gin.Context) {
        tokenStr := c.GetHeader("Authorization")
        if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
            tokenStr = tokenStr[7:]
        }
        if tokenStr == "" {
            c.AbortWithStatusJSON(401, gin.H{"code": 401, "msg": "missing token"})
            return
        }

        claims := &Claims{}
        token, err := jwt.ParseWithClaims(tokenStr, 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 signingKey, nil
        })
        if err != nil || !token.Valid {
            c.AbortWithStatusJSON(401, gin.H{"code": 401, "msg": "invalid token"})
            return
        }

        c.Set("uid", claims.UserID)
        c.Set("hid", claims.HospID)
        c.Set("rol", claims.RoleCode)
        c.Next()
    }
}

校验的时候要连签名算法一起校验,代码里那个 HMAC 断言就是干这个的,防 alg 混淆攻击。

channel 满了就写文件

日志中间件用 ResponseWriter 包装器捕获响应状态码和响应体大小,通过一个带缓冲 channel 把日志投递给后台 writer。

这里有个和埋点上报不一样的前提。埋点丢几条无所谓,审计日志一条不能丢。所以同样是非阻塞投递,channel 满时的动作不同:不丢弃,降级写本地文件。

 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
type AccessLog struct {
    TraceID    string        `bson:"trace_id"`
    UserID     int64         `bson:"user_id"`
    HospID     int64         `bson:"hosp_id"`
    Method     string        `bson:"method"`
    Path       string        `bson:"path"`
    Query      string        `bson:"query"`
    ClientIP   string        `bson:"client_ip"`
    StatusCode int           `bson:"status_code"`
    Latency    time.Duration `bson:"latency"`
    ReqSize    int           `bson:"req_size"`
    RespSize   int           `bson:"resp_size"`
    ErrMsg     string        `bson:"err_msg,omitempty"`
    CreatedAt  time.Time     `bson:"created_at"`
}

type bodyWriter struct {
    gin.ResponseWriter
    size int
}

func (w *bodyWriter) Write(b []byte) (int, error) {
    n, err := w.ResponseWriter.Write(b)
    w.size += n
    return n, err
}

func AccessLogMiddleware(logCh chan<- *AccessLog, fallback *os.File) gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        bw := &bodyWriter{ResponseWriter: c.Writer}
        c.Writer = bw

        c.Next()

        entry := &AccessLog{
            TraceID:    c.GetString("trace_id"),
            Method:     c.Request.Method,
            Path:       c.Request.URL.Path,
            Query:      c.Request.URL.RawQuery,
            ClientIP:   c.ClientIP(),
            StatusCode: c.Writer.Status(),
            Latency:    time.Since(start),
            ReqSize:    int(c.Request.ContentLength),
            RespSize:   bw.size,
            CreatedAt:  start,
        }
        if uid, ok := c.Get("uid"); ok {
            entry.UserID = uid.(int64)
        }
        if hid, ok := c.Get("hid"); ok {
            entry.HospID = hid.(int64)
        }
        if len(c.Errors) > 0 {
            entry.ErrMsg = c.Errors.String()
        }

        select {
        case logCh <- entry:
        default:
            // channel 满,降级写本地文件,保证审计日志不丢
            json.NewEncoder(fallback).Encode(entry)
        }
    }
}

后台 writer 攒批写 MongoDB,用的是 BulkWrite

 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
func LogWriter(ctx context.Context, coll *mongo.Collection, ch <-chan *AccessLog) {
    batch := make([]mongo.WriteModel, 0, 200)
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    flush := func() {
        if len(batch) == 0 {
            return
        }
        _, err := coll.BulkWrite(ctx, batch)
        if err != nil {
            log.Printf("mongo bulk write error: %v", err)
        }
        batch = batch[:0]
    }

    for {
        select {
        case e := <-ch:
            m := mongo.NewInsertOneModel().SetDocument(e)
            batch = append(batch, m)
            if len(batch) >= 200 {
                flush()
            }
        case <-ticker.C:
            flush()
        case <-ctx.Done():
            flush()
            return
        }
    }
}

四个坑

第一个坑是请求体。最开始我直接把 c.Request.Body 读出来记日志,读完之后后续 handler 再读就是空的:body 是个流,读一次就没了。要用 io.NopCloserbytes.Buffer 复制一份放回去。另外大 body 必须截断,只记前 1KB,不然日志体积很快失控。

第二个是敏感字段。密码、身份证、手机号不能明文进日志。我在序列化前对 query 和 body 里的 passwordid_cardphone 做了掩码。这事必须在入口做,等数据入了库再补救就晚了,合规也不认。

第三个是 MongoDB 写入延迟。BulkWrite 攒到 200 条批量写,平时延迟很稳,但副本集发生主从切换时写入会短暂失败。flush 失败就把批次写回本地文件,一个补传任务定期扫描重放,保证审计数据最终不丢。

第四个是 JWT 的注销。JWT 无状态,token 在过期前没法主动作废。我们在 Redis 里维护黑名单:退出登录或改密码时把 jti 加进去,TTL 设成剩余有效期。代价是每个请求多一次 Redis 查询,我觉得值。

后来

这两个中间件后来成了我所有 Go Web 项目的基础组件。回头看,主流程都不难,难的全是边角:body 读完要放回,日志要异步但不能丢,token 要能注销,敏感字段要在入口脱敏。边角处理好了,才算能用。

封面图:Tawheed Manzoor / Flickr · CC BY 2.0

Built with Hugo
Theme Stack designed by Jimmy