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) { // 文案必须指向真因:这条拦截最常见的场景不是「浏览器缓存了旧页面」, // 而是反向代理改写了 Host(浏览器带外部域名、服务端只看到回源地址), // 照旧写「请刷新页面重试」会把现场引到永远试不好的方向。 // detail 带上实际比对的两个值:现场照着改一行反代配置就能好。 source := r.Header.Get("Origin") if source == "" { source = r.Header.Get("Referer") } intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败:本站 Origin 与服务端收到的 Host 不一致(反向代理通常改写 Host,需让它原样透传 Host 或透传 X-Forwarded-Host 并把代理加入可信代理)", map[string]any{"source": source, "host": r.Host, "forwardedHost": r.Header.Get("X-Forwarded-Host")}) 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) { // 「请刷新页面重试」是错的:页面加载走的是 /auth/me,而它此前不会补发丢失的 CSRF Cookie, // 刷新一百次也还是 403。当前唯一有效的自愈路径是退出后重新登录(登录会重新下发两枚 Cookie)。 // 配套修复见 internal/httpapi/handlers_config.go 的 me:缺票时补发一枚新的。 intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败,请退出后重新登录", nil) return } next.ServeHTTP(w, r) }) } // sameOrigin 判断请求是否来自本站。浏览器跨站 POST 一定带 Origin(或 Referer), // 两个头都没有时(curl、非浏览器客户端)放行,避免误伤脚本调用。 // // 反代改写 Host 的部署(nginx `proxy_set_header Host 127.0.0.1:19540;`)下,浏览器带的 Origin 是 // 外部域名、r.Host 是回源地址,两者永远不相等 → 所有人登录 403。这里在直连对端命中可信代理时, // 额外采信 X-Forwarded-Host(代理透传的原 Host)。 // // 为什么这不等于「把同源校验放水到任意 Origin」: // 1. Origin 由浏览器自动生成,跨站页面改不了它——攻击者页面发来的请求,Origin 仍是攻击者的域名; // 2. X-Forwarded-Host 同样只有代理或非浏览器客户端能设:跨站 fetch/XHR 带自定义头会触发预检, // 本服务不返回任何 Access-Control-* 头,预检必失败;HTML 表单压根设不了自定义头; // 3. 能自行编造任意请求头的客户端(curl / 脚本)本来就没有受害者的 Cookie,CSRF 对它不成立, // 给它放宽不改变任何安全结论; // 4. 还要求直连对端在可信代理白名单里(与 ClientIP 同口径),挡住「绕过代理直连、自己塞头」的路径; // 5. 采信的值仍必须与 Origin 相等,不是无条件放行——校验的本质(Origin 属于本站)没有变, // 只是「本站」由 r.Host 一个候选变成 r.Host 或代理声明的原 Host。 // // 保守取舍:只做全等比较,不做「忽略端口」这类归一化。 // nginx 的 $host 会去掉端口,部署在非默认端口时请用 `X-Forwarded-Host $http_host`。 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 } if strings.EqualFold(parsed.Host, r.Host) { return true } if !isTrustedProxy(net.ParseIP(remoteHost(r.RemoteAddr))) { return false } // 多值时取第一个:代理链上离客户端最近的那一跳写的才是浏览器真正用过的 Host。 forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]) return forwarded != "" && strings.EqualFold(parsed.Host, forwarded) } // 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) }