package middleware import ( "bufio" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "net" "net/http" "net/url" "runtime/debug" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" "git.yeij.top/AskaEth/Scoreboard/server/internal/logging" ) // HeaderRequestID 是客户端可复用的幂等/追踪请求头。 const HeaderRequestID = "X-Request-Id" // maxRequestIDLen 限制客户端 request_id 长度:它会被下游当作幂等缓存键,超长会撑内存。 const maxRequestIDLen = 128 // RequestID 复用客户端传入的 request_id,没有或过长则生成一个并写回响应头。 func RequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.Header.Get(HeaderRequestID) if len(id) > maxRequestIDLen { id = "" } if id == "" { buf := make([]byte, 8) if _, err := rand.Read(buf); err == nil { id = hex.EncodeToString(buf) } else { id = time.Now().UTC().Format("20060102150405.000000") } } // 回写请求头,让下游(幂等缓存)读到同一个受控 id。 r.Header.Set(HeaderRequestID, id) w.Header().Set(HeaderRequestID, id) next.ServeHTTP(w, r.WithContext(contextWithRequestID(r.Context(), id))) }) } func contextWithRequestID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, ctxKeyRequestID, id) } // Recover 捕获 handler panic,返回统一 500,避免进程被单个请求带崩。 func Recover(logger *slog.Logger) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { logger.Error("请求处理 panic", "err", rec, "path", r.URL.Path, "requestId", GetRequestID(r.Context()), "stack", string(debug.Stack()), ) writeError(w, http.StatusInternalServerError, "INTERNAL", "服务端异常", nil) } }() next.ServeHTTP(w, r) }) } } // Log 记录访问日志(方法、路径、状态、耗时、request_id)。 func Log(logger *slog.Logger) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(sw, r) logger.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "dur", time.Since(start), "requestId", GetRequestID(r.Context()), "ip", ClientIP(r), ) }) } } // Authenticate 解析会话 Cookie;没有或无效时按 guest 放行(只读默认身份)。 // 会话查询失败必须留痕:否则系统库故障会静默把所有人降级成 guest。 func Authenticate(svc *auth.Service, logger *slog.Logger) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u := &auth.User{Role: auth.RoleGuest} ctx := r.Context() if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" { found, sess, err := svc.Lookup(ctx, c.Value) switch { case err == nil: u = found // 记下会话:实时层靠它复查长连接背后的会话是否还有效(撤销后要断开)。 ctx = WithSession(ctx, sess) case errors.Is(err, auth.ErrSessionNotFound): // 会话缺失/过期是正常情况,静默按 guest 放行。 default: logger.Error("会话查询失败", "err", err, "path", r.URL.Path, "requestId", GetRequestID(r.Context())) } } next.ServeHTTP(w, r.WithContext(WithUser(ctx, u))) }) } } // RequireRole 要求至少 need 权限;未登录或权限不足一律 403。 // 用于「guest 也不该看」的只读接口(系统配置),以便与未登录写操作区分。 func RequireRole(logger *slog.Logger, need auth.Role) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !User(r.Context()).Role.Allows(need) { intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "无权访问该资源", map[string]any{"required": need.String()}) return } next.ServeHTTP(w, r) }) } } // RequireWrite 要求至少 need 权限的写操作;guest 返回 401(需登录),角色不足返回 403。 func RequireWrite(logger *slog.Logger, need auth.Role) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u := User(r.Context()) if u.Role == auth.RoleGuest { intercept(logger, w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil) return } if !u.Role.Allows(need) { intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "无权执行该操作", map[string]any{"required": need.String()}) return } next.ServeHTTP(w, r) }) } } // MustChangePassword 阻止「必须修改密码」的账号执行写操作。 // 只放行改密与登出:否则用户会被永久锁在无法改密的死循环里(见 devdocs/08 第 1 节)。 func MustChangePassword(logger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u := User(r.Context()) if u.Role == auth.RoleGuest || !u.MustChangePassword { next.ServeHTTP(w, r) return } switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: next.ServeHTTP(w, r) return } switch r.URL.Path { case "/api/auth/password", "/api/auth/logout": next.ServeHTTP(w, r) return } intercept(logger, w, r, http.StatusForbidden, "AUTH_REQUIRED", "请先修改初始密码", nil) }) } // CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。 func CSRF(logger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: next.ServeHTTP(w, r) return } // 登录是 guest 请求,拿不到 CSRF Token;跨站表单仍能把受害者登进攻击者的账号, // 因此对带 Origin/Referer 的登录请求单独做同源校验。 if r.Method == http.MethodPost && r.URL.Path == "/api/auth/login" && !sameOrigin(r) { intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败,请刷新页面重试", nil) return } if User(r.Context()).Role == auth.RoleGuest { // 未登录请求由 RequireWrite 处理,无需 CSRF。 next.ServeHTTP(w, r) return } var cookie string if c, err := r.Cookie(auth.CookieCSRF); err == nil { cookie = c.Value } if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) { intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败,请刷新页面重试", nil) return } next.ServeHTTP(w, r) }) } // sameOrigin 判断请求是否来自本站。浏览器跨站 POST 一定带 Origin(或 Referer), // 两个头都没有时(curl、非浏览器客户端)放行,避免误伤脚本调用。 func sameOrigin(r *http.Request) bool { raw := r.Header.Get("Origin") if raw == "" { raw = r.Header.Get("Referer") } if raw == "" { return true } parsed, err := url.Parse(raw) if err != nil || parsed.Host == "" { return false } return strings.EqualFold(parsed.Host, r.Host) } // RateLimitWrite 对已登录用户的写操作限流。 // 阈值只写在 auth.DefaultWritePerMinute 一处,这里不复述具体数字—— // 写死一个副本,改常量时必然漏改(本轮就从 30 提到 120 而漏了这句注释)。 func RateLimitWrite(svc *auth.Service, logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: next.ServeHTTP(w, r) return } u := User(r.Context()) if u.Role != auth.RoleGuest && !svc.AllowWrite(u.ID) { intercept(logger, w, r, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil) return } next.ServeHTTP(w, r) }) } } // statusWriter 记录响应状态码,供日志与审计判断是否成功。 type statusWriter struct { http.ResponseWriter status int written bool } func (w *statusWriter) WriteHeader(code int) { if !w.written { w.status = code w.written = true } w.ResponseWriter.WriteHeader(code) } func (w *statusWriter) Write(b []byte) (int, error) { if !w.written { w.written = true } return w.ResponseWriter.Write(b) } // Hijack 透传底层连接:WebSocket 升级依赖 http.Hijacker, // 包装 ResponseWriter 时必须显式转发,否则握手会退化成 501。 func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { hj, ok := w.ResponseWriter.(http.Hijacker) if !ok { return nil, nil, http.ErrNotSupported } return hj.Hijack() } // Flush 透传,保证流式响应不被缓冲。 func (w *statusWriter) Flush() { if f, ok := w.ResponseWriter.(http.Flusher); ok { f.Flush() } } // writeError 输出统一错误结构;中间件不依赖 httpapi,避免包循环。 // intercept 记录一次中间件层的拦截,再写出统一响应。 // 这几个中间件都在 handler 之前就返回响应,不经 httpapi 的 writeAPIError—— // 不在这里记的话,这些拦截完全没有痕迹。收成一个函数是为了「只要写了响应就一定有日志」。 // 与 httpapi 的拦截日志同一形状:WARN 给原因与拦截点,DEBUG 补上下文,两类都进 interceptions/。 func intercept(logger *slog.Logger, w http.ResponseWriter, r *http.Request, status int, code, message string, detail any) { if logger == nil { logger = slog.Default() } u := User(r.Context()) actor := actorLabel(u) role := u.Role.String() reqID := GetRequestID(r.Context()) // skip=2:0 是本包 Origin 自己的帧,1 是 intercept,2 才是做校验的那个中间件函数。 origin := logging.Origin(2) logger.Warn("请求被拦截", "reason", message, "origin", origin, "code", code, "method", r.Method, "path", r.URL.Path, "actor", actor, "role", role, "requestId", reqID, "ip", ClientIP(r), logging.ChannelKey, logging.ChannelInterception) logger.Debug("拦截上下文", "code", code, "detail", detail, "origin", origin, "method", r.Method, "path", r.URL.Path, "ip", ClientIP(r), "actor", actor, "role", role, "requestId", reqID, logging.ChannelKey, logging.ChannelInterception) writeError(w, status, code, message, detail) } // actorLabel 是日志里的操作人:未登录记 guest,登录后记「用户名#id」,比裸 id 好认。 func actorLabel(u *auth.User) string { if u == nil || u.ID == 0 { return "guest" } return fmt.Sprintf("%s#%d", u.Username, u.ID) } func writeError(w http.ResponseWriter, status int, code, message string, detail any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) body := map[string]any{ "ok": false, "error": map[string]any{ "code": code, "message": message, }, } if detail != nil { body["error"].(map[string]any)["detail"] = detail } _ = json.NewEncoder(w).Encode(body) }