package api import ( "encoding/json" "net/http" "strings" "time" "github.com/gin-gonic/gin" "oci-portal/internal/model" "oci-portal/internal/service" ) // usernameKey 是鉴权通过后写入 gin.Context 的用户名键。 const usernameKey = "username" // RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本), // 通过后把用户名放进上下文。 func RequireAuth(auth *service.AuthService) gin.HandlerFunc { return func(c *gin.Context) { token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ") if !ok || token == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) return } username, err := auth.ParseToken(c.Request.Context(), token) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) return } c.Set(usernameKey, username) c.Next() } } // bodyLimit 给请求体套上限(S-03):超限读取由 MaxBytesReader 截断报错, // 绑定失败返回 400;webhook 入口另有独立 256KiB 自限,不经此中间件。 func bodyLimit(n int64) gin.HandlerFunc { return func(c *gin.Context) { if c.Request.Body != nil { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, n) } c.Next() } } // systemLogMiddleware 把写请求(POST/PUT/DELETE)异步记入系统日志,只读请求跳过。 // 只记方法、路径等元数据,绝不读请求体——请求体可能含私钥 / 口令等敏感数据。 func systemLogMiddleware(logs *service.SystemLogService) gin.HandlerFunc { return func(c *gin.Context) { if !isWriteMethod(c.Request.Method) { c.Next() return } start := time.Now() tee := &teeWriter{ResponseWriter: c.Writer} c.Writer = tee c.Next() logs.Record(model.SystemLog{ Username: c.GetString(usernameKey), Method: c.Request.Method, Path: requestPath(c), Status: c.Writer.Status(), DurationMs: time.Since(start).Milliseconds(), ClientIP: requestIP(c), UserAgent: truncateLogField(c.Request.UserAgent(), 256), ErrMsg: errMsgOf(c.Writer.Status(), tee.head), }) } } // teeWriter 旁路捕获响应体前 512 字节,供失败留痕提取 error 文案;不改写响应。 type teeWriter struct { gin.ResponseWriter head []byte } func (w *teeWriter) Write(b []byte) (int, error) { if room := 512 - len(w.head); room > 0 { if len(b) < room { room = len(b) } w.head = append(w.head, b[:room]...) } return w.ResponseWriter.Write(b) } // errMsgOf 从失败响应(>=400)的 JSON 中提取 error 字段;错误响应均由 // respondError 系列生成、不含敏感信息,成功响应一律不记。 func errMsgOf(status int, head []byte) string { if status < 400 || len(head) == 0 { return "" } var body struct { Error string `json:"error"` } if json.Unmarshal(head, &body) != nil || body.Error == "" { return "" } return truncateLogField(body.Error, 256) } // truncateLogField 按字节截断留痕字段,防超长 UA / 错误串撑爆列宽。 func truncateLogField(s string, max int) string { if len(s) <= max { return s } return s[:max] } // isWriteMethod 判断是否需要留痕的写方法。 func isWriteMethod(m string) bool { return m == http.MethodPost || m == http.MethodPut || m == http.MethodDelete } // requestPath 优先取注册的路由模板(含 :id 占位符,避免把敏感 query 记入日志), // 未匹配到路由时退回原始路径;webhook 前缀的原始路径把 secret 段脱敏。 func requestPath(c *gin.Context) string { if p := c.FullPath(); p != "" { return p } return sanitizeLogPath(c.Request.URL.Path) } // sanitizeLogPath 把回传 webhook 路径的最后一段(secret)替换为 ***; // 正常留痕走路由模板不经此处,这里是未匹配路由时的防御兜底。 func sanitizeLogPath(path string) string { const prefix = "/api/v1/webhooks/" if !strings.HasPrefix(path, prefix) { return path } if i := strings.LastIndex(path, "/"); i >= len(prefix) { return path[:i+1] + "***" } return path }