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:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user