diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go index beaf59f..d994af4 100644 --- a/internal/server/account_contract_test.go +++ b/internal/server/account_contract_test.go @@ -70,6 +70,41 @@ func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) { } } +func TestSelfProfileRequiresPasswordAuthenticatedFullSession(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + hash, err := store.HashPassword("self-profile-password") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy-self", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("legacy-self"); err != nil { + t.Fatal(err) + } + legacyCookie, err := st.CreateSession("legacy-self") + if err != nil { + t.Fatal(err) + } + if rec := do(h, "GET", "/api/me", "", legacyCookie); rec.Code != http.StatusOK { + t.Fatalf("historical full session lost ordinary identity: %d", rec.Code) + } + if rec := do(h, "GET", "/api/me/profile", "", legacyCookie); rec.Code != http.StatusForbidden { + t.Fatalf("unknown session lineage read profile: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(h, "PATCH", "/api/me/profile", `{"expectedProfileVersion":1,"patch":{"name":"injected"}}`, legacyCookie); rec.Code != http.StatusForbidden { + t.Fatalf("unknown session lineage edited profile: %d %s", rec.Code, rec.Body.String()) + } + login := do(h, "POST", "/api/auth/login", `{"owner":"legacy-self","password":"self-profile-password"}`, "") + if login.Code != http.StatusOK { + t.Fatalf("password login: %d %s", login.Code, login.Body.String()) + } + if rec := do(h, "GET", "/api/me/profile", "", cookieOf(login)); rec.Code != http.StatusOK { + t.Fatalf("verified password session could not read profile: %d %s", rec.Code, rec.Body.String()) + } +} + func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() diff --git a/internal/server/account_sse_test.go b/internal/server/account_sse_test.go new file mode 100644 index 0000000..a706bc6 --- /dev/null +++ b/internal/server/account_sse_test.go @@ -0,0 +1,78 @@ +package server + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + core "agent-system-core" +) + +func TestAccountDisableStopsNextSSEBusinessFrame(t *testing.T) { + srv, st := newAuthTestServer(t) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("sse-user", "", "user-hash", false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("sse-user"); err != nil { + t.Fatal(err) + } + user, err := st.GetAccountByOwner("sse-user") + if err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("sse-user", "stream") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("sse-user", project.ID, "stream", "") + if err != nil { + t.Fatal(err) + } + selected := make(chan core.StreamEvent, 1) + srv.Subscribe = func(string) (<-chan core.StreamEvent, func()) { return selected, func() {} } + token, err := st.CreateSession("sse-user") + if err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(srv.Handler()) + defer httpServer.Close() + request, err := http.NewRequest(http.MethodGet, httpServer.URL+"/api/events?conversationId="+conv.ID, nil) + if err != nil { + t.Fatal(err) + } + request.AddCookie(&http.Cookie{Name: SessionCookie, Value: token}) + client := &http.Client{Timeout: 3 * time.Second} + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + reader := bufio.NewReader(response.Body) + first, err := reader.ReadString('\n') + if err != nil || !strings.HasPrefix(first, "retry:") { + t.Fatalf("SSE did not open: %q %v", first, err) + } + if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion, false); err != nil { + t.Fatal(err) + } + selected <- core.StreamEvent{ConversationID: conv.ID, Seq: 1, Type: core.EvToolOutput, + Payload: json.RawMessage(`{"text":"must-not-stream"}`)} + tail, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("revoked stream did not close: %v", err) + } + if strings.Contains(string(tail), "must-not-stream") { + t.Fatal("disabled account received the next SSE business frame") + } + if _, _, ok := st.SessionInfo(token); ok { + t.Fatal("disabled account session still authenticates") + } +} diff --git a/internal/server/accounts.go b/internal/server/accounts.go index c1c17df..ae45be5 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -370,6 +370,11 @@ func (s *Server) requireFullPasswordSession(w http.ResponseWriter, r *http.Reque http.Error(w, "full password session required", http.StatusForbidden) return "", false } + method, err := s.st.SessionAuthMethod(token) + if err != nil || method != store.AuthMethodPassword { + http.Error(w, "password-authenticated session required", http.StatusForbidden) + return "", false + } return owner, true } diff --git a/internal/server/recovery.go b/internal/server/recovery.go index f3ba03e..92eee7b 100644 --- a/internal/server/recovery.go +++ b/internal/server/recovery.go @@ -95,7 +95,11 @@ func (s *Server) confirmRecoveryProposal(w http.ResponseWriter, r *http.Request) return } w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain}) + out := map[string]any{"proposal": proposal} + if plain != "" { + out["grant"] = plain + } + writeJSON(w, http.StatusOK, out) } func (s *Server) rotateRecoveryGrant(w http.ResponseWriter, r *http.Request) { @@ -116,5 +120,9 @@ func (s *Server) rotateRecoveryGrant(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain}) + out := map[string]any{"proposal": proposal} + if plain != "" { + out["grant"] = plain + } + writeJSON(w, http.StatusOK, out) } diff --git a/internal/server/recovery_contract_test.go b/internal/server/recovery_contract_test.go index aa5aadb..ad983bf 100644 --- a/internal/server/recovery_contract_test.go +++ b/internal/server/recovery_contract_test.go @@ -61,6 +61,9 @@ func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { if err := json.Unmarshal(confirmed.Body.Bytes(), &confirmation); err != nil || confirmation.Grant == "" { t.Fatalf("confirmation secret response: %v", err) } + if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) { + t.Fatalf("duplicate confirmation replayed grant: %d %s", duplicate.Code, duplicate.Body.String()) + } if got := do(h, "GET", "/api/me/recovery-proposals/"+proposal.ID, "", ownerCookie); got.Code != http.StatusOK || strings.Contains(got.Body.String(), confirmation.Grant) { t.Fatalf("owner status replayed grant: %d %s", got.Code, got.Body.String()) } @@ -75,6 +78,10 @@ func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { if err := json.Unmarshal(rotated.Body.Bytes(), &rotation); err != nil || rotation.Grant == "" || rotation.Grant == confirmation.Grant { t.Fatalf("rotation grant: %v", err) } + if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant", + `{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) { + t.Fatalf("duplicate rotation replayed grant: %d %s", duplicate.Code, duplicate.Body.String()) + } if old := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmation.Grant+`"}`, ""); old.Code == http.StatusOK { t.Fatal("old recovery grant remained usable") } diff --git a/internal/store/auth.go b/internal/store/auth.go index 484e021..1b68b5d 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -165,19 +165,19 @@ const sessionTTL = 7 * 24 * time.Hour // AdminUser 管理员用户列表的展示视图(不含哈希)。 type AdminUser struct { - ID string `json:"userId"` - Owner string `json:"owner"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - StateVersion int64 `json:"stateVersion"` - ProfileVersion int64 `json:"profileVersion"` - InitializationState string `json:"initializationState"` - InitializationGeneration int64 `json:"initializationGeneration"` - IsAdmin bool `json:"isAdmin"` - MustChangePassword bool `json:"mustChangePassword"` - HasPassword bool `json:"hasPassword"` - PasswordSetAt *time.Time `json:"passwordSetAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"userId"` + Owner string `json:"owner"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + StateVersion int64 `json:"stateVersion"` + ProfileVersion int64 `json:"profileVersion"` + InitializationState string `json:"initializationState"` + InitializationGeneration int64 `json:"initializationGeneration"` + IsAdmin bool `json:"isAdmin"` + MustChangePassword bool `json:"mustChangePassword"` + HasPassword bool `json:"hasPassword"` + PasswordSetAt *time.Time `json:"passwordSetAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` } // HashPassword 生成 argon2id PHC 串(m=64MiB,t=2,p=2;随机盐)。 @@ -410,6 +410,21 @@ func (s *Store) SessionOwner(token string) (string, bool) { return owner, ok } +// SessionAuthMethod reports persisted authentication lineage for an already +// checked cookie. Callers must still validate current scope/version/effective +// state with SessionInfo before granting any capability. +func (s *Store) SessionAuthMethod(token string) (string, error) { + if token == "" { + return "", ErrNotFound + } + var method string + err := s.db.QueryRow(s.q(`SELECT auth_method FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&method) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return method, err +} + // SessionInfo 返回 owner 与 scope。 func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) { if token == "" { diff --git a/internal/store/recovery_proposals.go b/internal/store/recovery_proposals.go index 58efd41..3679d14 100644 --- a/internal/store/recovery_proposals.go +++ b/internal/store/recovery_proposals.go @@ -225,10 +225,6 @@ func (s *Store) confirmOrRotateRecovery(token, proposalID string, expectedVersio if err != nil { return "", RecoveryProposal{}, err } - plain, id, err := newRecoveryGrant() - if err != nil { - return "", RecoveryProposal{}, err - } tx, err := s.db.Begin() if err != nil { return "", RecoveryProposal{}, err @@ -244,15 +240,25 @@ func (s *Store) confirmOrRotateRecovery(token, proposalID string, expectedVersio return "", RecoveryProposal{}, err } if !account.Enabled || account.ID != p.UserID || account.AuthVersion != p.AuthVersion || account.MustChangePassword || !account.CredentialOrigin.TrustedForOwnerProof() || - p.Version != expectedVersion || p.Generation != expectedGeneration || time.Now().After(p.ExpiresAt) { - return "", RecoveryProposal{}, ErrConflict - } - if (!rotate && p.Status != RecoveryProposalPending) || (rotate && p.Status != RecoveryProposalConfirmed) { + time.Now().After(p.ExpiresAt) { return "", RecoveryProposal{}, ErrConflict } if err := s.eligibleRecoveryProof(tx, account, p, token); err != nil { return "", RecoveryProposal{}, err } + if p.Status == RecoveryProposalConfirmed && p.Version == expectedVersion+1 && p.Generation == expectedGeneration+1 { + // A successful response may have been lost. Never replay the one-time + // plaintext; the same qualified owner receives metadata only. + return "", p, nil + } + if p.Version != expectedVersion || p.Generation != expectedGeneration || + (!rotate && p.Status != RecoveryProposalPending) || (rotate && p.Status != RecoveryProposalConfirmed) { + return "", RecoveryProposal{}, ErrConflict + } + plain, id, err := newRecoveryGrant() + if err != nil { + return "", RecoveryProposal{}, err + } now := time.Now().UTC() newGeneration := p.Generation + 1 newStatus := RecoveryProposalConfirmed diff --git a/internal/store/recovery_proposals_test.go b/internal/store/recovery_proposals_test.go index 767ed3a..db21961 100644 --- a/internal/store/recovery_proposals_test.go +++ b/internal/store/recovery_proposals_test.go @@ -106,32 +106,38 @@ func TestOwnerRecoveryConcurrentConfirmHasOneWinnerAcrossPools(t *testing.T) { t.Fatal(err) } start := make(chan struct{}) - results := make(chan error, 2) + type result struct { + grant string + err error + } + results := make(chan result, 2) var wg sync.WaitGroup for _, conn := range []*Store{st, other} { wg.Add(1) go func(conn *Store) { defer wg.Done() <-start - _, _, err := conn.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) - results <- err + grant, _, err := conn.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) + results <- result{grant, err} }(conn) } close(start) wg.Wait() close(results) - wins, conflicts := 0, 0 - for err := range results { - if err == nil { + wins, metadataOnly, conflicts := 0, 0, 0 + for outcome := range results { + if outcome.err == nil && outcome.grant != "" { wins++ - } else if errors.Is(err, ErrConflict) { + } else if outcome.err == nil && outcome.grant == "" { + metadataOnly++ + } else if errors.Is(outcome.err, ErrConflict) { conflicts++ } else { - t.Fatal(err) + t.Fatal(outcome.err) } } - if wins != 1 || conflicts != 1 { - t.Fatalf("concurrent confirmation wins=%d conflicts=%d", wins, conflicts) + if wins != 1 || metadataOnly+conflicts != 1 { + t.Fatalf("concurrent confirmation plaintext winners=%d metadata-only=%d conflicts=%d", wins, metadataOnly, conflicts) } } @@ -155,10 +161,16 @@ func TestOwnerProofRecoveryBindsPreexistingSessionAndRotatesGrant(t *testing.T) if err != nil || first == "" || confirmed.Status != RecoveryProposalConfirmed { t.Fatalf("confirm: %+v %v", confirmed, err) } + if replay, state, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version); err != nil || replay != "" || state.Version != confirmed.Version { + t.Fatalf("duplicate confirmation replayed secret or changed state: %+v %v", state, err) + } second, rotated, err := st.RotateRecoveryGrant(proof, proposal.ID, confirmed.Version, confirmed.Generation) if err != nil || second == first || rotated.Generation != confirmed.Generation+1 { t.Fatalf("rotate: %+v %v", rotated, err) } + if replay, state, err := st.RotateRecoveryGrant(proof, proposal.ID, confirmed.Version, confirmed.Generation); err != nil || replay != "" || state.Version != rotated.Version { + t.Fatalf("duplicate rotation replayed secret or changed state: %+v %v", state, err) + } if _, _, err := st.RedeemLoginKey(first); err == nil { t.Fatal("rotated grant remained redeemable") }