Require password lineage and make recovery retries metadata-only

Why: Historical full sessions could reach self-profile actions, and a lost recovery response had no safe idempotent retry contract.

Evidence: SELF receipts /tmp/iam-u3-full-password-profile.json, /tmp/iam-u3-recovery-idempotent-sqlite.json, /tmp/iam-u3-recovery-idempotent-pg.json, /tmp/iam-u3-account-sse-pass.json, /tmp/iam-u3-backend-idempotent-all.json, and /tmp/iam-u3-backend-idempotent-pg.json.

Impact: Profile actions require a password-authenticated full cookie; duplicate owner confirmation or rotation returns status without plaintext; an active SSE stream stops before its next business frame after disable.

Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
This commit is contained in:
2026-10-14 04:58:31 +00:00
parent 7229abe50d
commit 3e01614939
8 changed files with 199 additions and 33 deletions
+35
View File
@@ -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()
+78
View File
@@ -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")
}
}
+5
View File
@@ -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
}
+10 -2
View File
@@ -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)
}
@@ -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")
}
+28 -13
View File
@@ -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 == "" {
+14 -8
View File
@@ -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
+22 -10
View File
@@ -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")
}