diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 95b1c663..54757d08 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -143,8 +143,8 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { return } - sess := s.getOrCreateSession(sessionID, "", "", "", "", "", "", "", "") - if sess == nil || sess.UserCtx == nil { + sess, err := s.getOrCreateSession(0, sessionID, "", "", "", "", "", "", "", "") + if err != nil || sess == nil || sess.UserCtx == nil { writeSafeError(w, r, http.StatusNotFound, "session or project context not found") return } @@ -169,7 +169,8 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { } req.WorkDir = "" - sess := s.getOrCreateSession( + sess, err := s.getOrCreateSession( + user.ID, req.SessionID, req.ProjectName, req.CustomerName, @@ -180,7 +181,17 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - sess.UserID = user.ID + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) + return + } s.hydrateVisibleHistory(r.Context(), sess, user.ID) if req.ProjectID > 0 { sess.ProjectID = req.ProjectID @@ -982,6 +993,10 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusServiceUnavailable, "context manager not initialized") return } + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } var req upsertChatSessionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -992,9 +1007,17 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) writeSafeError(w, r, http.StatusBadRequest, "missing session_id") return } + req.UserID = user.ID - summary, err := s.ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title) + summary, err := s.ctxMgr.EnsureChatSessionOwned(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.Title) if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权操作该会话", + }) + return + } writeSafeError(w, r, http.StatusInternalServerError, "upsert chat session failed: "+err.Error()) return } diff --git a/internal/server/server.go b/internal/server/server.go index 630b3276..ba3e5fc3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -391,8 +391,9 @@ func (s *Server) cleanStaleSessionsLocked(ttl time.Duration) int { return cleaned } -// getOrCreateSession 获取或动态构建会话 +// getOrCreateSession 获取或动态构建会话。callerUserID>0 时校验持久层归属,不匹配返回 ErrSessionOwnerMismatch。 func (s *Server) getOrCreateSession( + callerUserID int64, sessionID string, projectName string, customerName string, @@ -402,25 +403,38 @@ func (s *Server) getOrCreateSession( workDir string, userName string, userRole string, -) *chat.Session { +) (*chat.Session, error) { if sessionID == "" { sessionID = fmt.Sprintf("sess_%d", time.Now().UnixNano()) } + // 归属校验必须先于任何缓存命中/持久层灌入 + if callerUserID > 0 && s.ctxMgr != nil { + if owner, err := s.ctxMgr.GetSessionOwnerID(context.Background(), sessionID); err == nil && owner > 0 && owner != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } + } + s.sessionsMu.RLock() existing, exists := s.sessions[sessionID] s.sessionsMu.RUnlock() if exists && existing != nil { + if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } existing.LastAccessedAt = time.Now() - return existing + return existing, nil } s.sessionsMu.Lock() defer s.sessionsMu.Unlock() // 双重检查 if existing, exists := s.sessions[sessionID]; exists && existing != nil { + if callerUserID > 0 && existing.UserID > 0 && existing.UserID != callerUserID { + return nil, ctxmgr.ErrSessionOwnerMismatch + } existing.LastAccessedAt = time.Now() - return existing + return existing, nil } // 惰性内存防泄漏保护 (会话数超过 200 时清理空闲超 120 分钟会话) @@ -549,9 +563,14 @@ func (s *Server) getOrCreateSession( } } + // 新建会话时立即绑定调用者。NewSession 历史占位 UserID=1 不能当作已绑定归属。 + if callerUserID > 0 && (restored == nil || restored.UserID <= 0) { + sess.UserID = callerUserID + } + sess.LastAccessedAt = time.Now() s.sessions[sessionID] = sess - return sess + return sess, nil } func (s *Server) hydrateVisibleHistory(ctx context.Context, sess *chat.Session, viewerUserID int64) { diff --git a/internal/server/sse.go b/internal/server/sse.go index c51730af..fbc03db0 100644 --- a/internal/server/sse.go +++ b/internal/server/sse.go @@ -173,7 +173,8 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { req.WorkDir = "" // 会话与项目绑定必须在 SSE headers/Flush 之前完成授权(与 tasks/run/stream 对齐) - sess := s.getOrCreateSession( + sess, err := s.getOrCreateSession( + user.ID, req.SessionID, req.ProjectName, req.CustomerName, @@ -184,7 +185,17 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { req.UserName, req.UserRole, ) - sess.UserID = req.UserID + if err != nil { + if errors.Is(err, ctxmgr.ErrSessionOwnerMismatch) { + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "SESSION_FORBIDDEN", + "message": "无权访问该会话", + }) + return + } + writeSafeError(w, r, http.StatusInternalServerError, "resolve session failed: "+err.Error()) + return + } s.hydrateVisibleHistory(r.Context(), sess, user.ID) if req.ProjectID > 0 { sess.ProjectID = req.ProjectID @@ -306,7 +317,6 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) { } // 4. 同 session 串行执行后发起流式对话 - var err error execErr := sess.WithExecLock(func() error { var sendErr error _, sendErr = sess.SendMessageStreamTurn(ctx, req.Message, listener, req.IncludeDetails, turnOpts)