审计日志一条不能丢
宠物医疗 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.NopCloser 加 bytes.Buffer 复制一份放回去。另外大 body 必须截断,只记前 1KB,不然日志体积很快失控。
第二个是敏感字段。密码、身份证、手机号不能明文进日志。我在序列化前对 query 和 body 里的 password、id_card、phone 做了掩码。这事必须在入口做,等数据入了库再补救就晚了,合规也不认。
第三个是 MongoDB 写入延迟。BulkWrite 攒到 200 条批量写,平时延迟很稳,但副本集发生主从切换时写入会短暂失败。flush 失败就把批次写回本地文件,一个补传任务定期扫描重放,保证审计数据最终不丢。
第四个是 JWT 的注销。JWT 无状态,token 在过期前没法主动作废。我们在 Redis 里维护黑名单:退出登录或改密码时把 jti 加进去,TTL 设成剩余有效期。代价是每个请求多一次 Redis 查询,我觉得值。
后来
这两个中间件后来成了我所有 Go Web 项目的基础组件。回头看,主流程都不难,难的全是边角:body 读完要放回,日志要异步但不能丢,token 要能注销,敏感字段要在入口脱敏。边角处理好了,才算能用。
封面图:Tawheed Manzoor / Flickr · CC BY 2.0