Files
oci-portal/internal/api/middleware.go
T
2026-07-30 12:23:05 +08:00

160 lines
4.9 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"oci-portal/internal/model"
"oci-portal/internal/service"
)
// usernameKey 是鉴权通过后写入 gin.Context 的用户名键;
// tokenVerKey/tokenJtiKey 是鉴权时观察到的令牌快照,敏感事务提交前复核。
const (
usernameKey = "username"
tokenVerKey = "tokenVer"
tokenJtiKey = "tokenJti"
)
// RequireAuth 校验 Authorization: Bearer 令牌(签名、有效期与令牌版本),
// 通过后把用户名与令牌快照(版本/jti)放进上下文——快照供敏感事务在
// 行锁下复核,防「请求挂起期间令牌被撤销,恢复后仍完成变更」的在途绕过。
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, proof, err := auth.ParseTokenProof(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
c.Set(usernameKey, username)
c.Set(tokenVerKey, proof.Ver)
c.Set(tokenJtiKey, proof.Jti)
c.Next()
}
}
// tokenProofOf 取出鉴权时的令牌快照,交给敏感 service 事务复核。
func tokenProofOf(c *gin.Context) service.TokenProof {
v, _ := c.Get(tokenVerKey)
ver, _ := v.(uint)
return service.TokenProof{Ver: ver, Jti: c.GetString(tokenJtiKey)}
}
// 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/PATCH/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 / 错误串撑爆列宽;
// 截断点回退到 rune 边界,避免产生非法 UTF-8(严格 MySQL/PG 会拒写整行)。
func truncateLogField(s string, max int) string {
if len(s) <= max {
return s
}
for max > 0 && !utf8.RuneStart(s[max]) {
max--
}
return s[:max]
}
// isWriteMethod 判断是否需要留痕的写方法;PATCH 承载 OAuth / 安全设置等
// 敏感配置变更,必须与 POST/PUT/DELETE 一样进入审计。
func isWriteMethod(m string) bool {
return m == http.MethodPost || m == http.MethodPut ||
m == http.MethodPatch || 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
}