diff --git a/cmd/agent-system/account_io_test.go b/cmd/agent-system/account_io_test.go index 7601a0a..9306498 100644 --- a/cmd/agent-system/account_io_test.go +++ b/cmd/agent-system/account_io_test.go @@ -43,15 +43,14 @@ func cliStore(t *testing.T) (string, *store.Store) { } return dir, s } -func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) { +func TestCreateAndInitializeAccountDoNotRequireCredentialOutput(t *testing.T) { for _, sub := range []string{"create", "initialize-account"} { t.Run(sub, func(t *testing.T) { dir, s := cliStore(t) if _, e := s.BootstrapFirstAdminWrite(store.AccountWrite{Owner: "root", Name: "Root", Actor: "test"}); e != nil { t.Fatal(e) } - out := filepath.Join(t.TempDir(), "secret") - args := []string{sub, "--data", dir, "--owner", "ada", "--out-file", out} + args := []string{sub, "--data", dir, "--owner", "ada"} if sub == "initialize-account" { a, e := s.CreatePendingAccount("root", "ada", store.AccountProfile{Name: "Ada"}) if e != nil { @@ -61,14 +60,7 @@ func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) { } rc, _ := accountCLI(t, args...) if rc != 0 { - t.Fatalf("credential command failed: %d", rc) - } - info, e := os.Stat(out) - if e != nil { - t.Fatal("credential output file missing") - } - if info.Mode().Perm() != 0600 { - t.Fatal("credential output permissions are not private") + t.Fatalf("command failed without credential output file: %d", rc) } }) } diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 437e791..d10cfe7 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -10,7 +10,6 @@ import ( "fmt" "os" "strings" - "time" "agent-system-backend/internal/app" "agent-system-backend/internal/config" @@ -448,7 +447,7 @@ func runUser(args []string) int { name := fs.String("name", "", "显示名") password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)") passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码") - outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)") + outFile := fs.String("out-file", "", "凭据交付文件(仅历史兼容子命令可用)") instanceID := fs.String("instance-id", "", "严格核验库内服务身份") admin := fs.Bool("admin", false, "管理员(现已拒绝:仅超级管理员可在服务端任命管理员)") role := fs.String("role", "user", "角色 user|admin(admin 现已拒绝)") @@ -458,10 +457,10 @@ func runUser(args []string) int { fs.Int("ttl-minutes", 30, "旧 issue-login-key 参数(现已拒绝)") fs.Bool("revoke-all-credentials", false, "旧 issue-login-key 参数(现已拒绝)") keyID := fs.String("id", "", "revoke-login-key 凭据编号") - out := fs.String("out", "", "凭据保护交付文件(0600 JSON)") + out := fs.String("out", "", "凭据交付文件(JSON;仅历史兼容子命令可用)") fs.Parse(args[1:]) if sub == "reset-password" || sub == "issue-login-key" { - fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery") + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password") return 2 // reject before DSN/password reads, storage opens or output reservation } if sub == "create" && (*password != "" || *passwordStdin || *generatePassword) { @@ -493,24 +492,14 @@ func runUser(args []string) int { } *dsn = resolvedDSN *password = resolvedPassword - credentialAction := sub == "create" || sub == "initialize-account" - if credentialAction && *outFile == "" && *out == "" { - fmt.Fprintln(os.Stderr, "user: --out or --out-file is required for protected credential delivery") + if (sub == "create" || sub == "initialize-account") && (*outFile != "" || *out != "") { + fmt.Fprintln(os.Stderr, "user:", "local activation redemption is removed; do not use --out/--out-file. Ask an administrator to issue POST /api/admin/users/{owner}/one-time-password") return 2 } - if !credentialAction && (*outFile != "" || *out != "") { + if sub != "create" && sub != "initialize-account" && (*outFile != "" || *out != "") { fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据") return 2 } - var delivery *credentialOutput - if credentialAction { - delivery, err = reserveCredentialOutput(*outFile, *out) - if err != nil { - fmt.Fprintln(os.Stderr, "user:", err) - return 2 - } - defer delivery.abort() - } switch sub { case "create": if *owner == "" { @@ -526,15 +515,12 @@ func runUser(args []string) int { fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role) return 2 } - pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword) + _, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword) if err != nil { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } - if err := delivery.deliver("login-key", *owner, pw, nil); err != nil { - fmt.Fprintln(os.Stderr, "user create:", err) - return 1 - } + fmt.Fprintf(os.Stderr, "owner=%s created as pending; ask an administrator to issue POST /api/admin/users/%s/one-time-password\n", *owner, *owner) fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID) return 0 case "initialize-account": @@ -542,18 +528,15 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation 必填") return 2 } - plain, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration) + _, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration) if err != nil { fmt.Fprintln(os.Stderr, "user initialize-account:", err) return 1 } - if err := delivery.deliver("activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { - fmt.Fprintln(os.Stderr, "user initialize-account:", err) - return 1 - } + fmt.Fprintf(os.Stderr, "owner=%s remains pending; ask an administrator to issue POST /api/admin/users/%s/one-time-password\n", targetOwner, targetOwner) return 0 case "reset-password": - fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED") + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password") return 2 case "list": users, err := app.ListUserAccounts(*dsn, *data, *openExisting) @@ -565,7 +548,7 @@ func runUser(args []string) int { fmt.Println(string(out)) return 0 case "issue-login-key": - fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED") + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password") return 2 case "revoke-login-key": if *keyID == "" { diff --git a/cmd/agent-system/user_contract_test.go b/cmd/agent-system/user_contract_test.go index 9c8fb0d..5a5131b 100644 --- a/cmd/agent-system/user_contract_test.go +++ b/cmd/agent-system/user_contract_test.go @@ -69,7 +69,7 @@ func TestUserCLIAdministratorAppointmentLeavesExistingStoreUnchanged(t *testing. if _, err := s.GetUser("mallory"); err == nil { t.Fatal("rejected CLI appointment created an account") } - rc, _ := accountCLI(t, "create", "--data", dir, "--owner", "ada", "--out-file", filepath.Join(t.TempDir(), "ada")) + rc, _ := accountCLI(t, "create", "--data", dir, "--owner", "ada") if rc != 0 { t.Fatalf("ordinary local create failed: %d", rc) } diff --git a/internal/server/account_admin_unauthenticated_test.go b/internal/server/account_admin_unauthenticated_test.go index bd95473..3db8c9e 100644 --- a/internal/server/account_admin_unauthenticated_test.go +++ b/internal/server/account_admin_unauthenticated_test.go @@ -23,8 +23,7 @@ func TestUnauthenticatedModeBlocksAccountAdministration(t *testing.T) { {http.MethodPatch, "/api/admin/users/usr_x/profile", `{"expectedProfileVersion":1,"patch":{"name":"x"}}`}, {http.MethodPut, "/api/admin/users/usr_x/state", `{"enabled":false}`}, {http.MethodPut, "/api/admin/users/usr_x/admin", `{"expectedStateVersion":1,"isAdmin":true}`}, - {http.MethodPost, "/api/admin/users/root/password", `{"password":"Another-Pass-1"}`}, - {http.MethodPost, "/api/admin/users/usr_x/recovery-proposals", `{}`}, + {http.MethodPost, "/api/admin/users/root/one-time-password", `{}`}, {http.MethodGet, "/api/admin/permissions/catalog", ""}, {http.MethodGet, "/api/admin/roles", ""}, {http.MethodPost, "/api/admin/roles", `{"name":"x"}`}, diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go index 5817175..78a86cf 100644 --- a/internal/server/account_contract_test.go +++ b/internal/server/account_contract_test.go @@ -211,41 +211,34 @@ func TestAdminPasswordCreateAndResetHTTP(t *testing.T) { t.Fatalf("alice login: %d %s", login.Code, login.Body.String()) } aliceSession := cookieOf(login) - key, _, err := st.IssueLoginKey("alice", store.LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } - if rejected := do(h, "POST", "/api/admin/users/alice/password", `{"password":"client-supplied"}`, admin); rejected.Code != http.StatusBadRequest { - t.Fatalf("client reset password accepted: %d %s", rejected.Code, rejected.Body.String()) + if rejected := do(h, "POST", "/api/admin/users/alice/one-time-password", `{"password":"client-supplied"}`, admin); rejected.Code != http.StatusBadRequest { + t.Fatalf("client one-time password body accepted: %d %s", rejected.Code, rejected.Body.String()) } if _, ok := st.VerifyLogin("alice", "alice-initial-9"); !ok { - t.Fatal("rejected reset changed the password") + t.Fatal("rejected one-time password request changed the password") } - reset := do(h, "POST", "/api/admin/users/alice/password", `{}`, admin) - if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" { - t.Fatalf("admin reset: %d %s", reset.Code, reset.Body.String()) + issued := do(h, "POST", "/api/admin/users/alice/one-time-password", `{}`, admin) + if issued.Code != http.StatusOK || issued.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("admin one-time password issue: %d %s", issued.Code, issued.Body.String()) } - var resetBody struct { - TemporaryPassword string `json:"temporaryPassword"` + var issuedBody struct { + OneTimePassword string `json:"oneTimePassword"` MustChangePassword bool `json:"mustChangePassword"` } - if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil { + if err := json.Unmarshal(issued.Body.Bytes(), &issuedBody); err != nil { t.Fatal(err) } - if resetBody.TemporaryPassword == "" || resetBody.TemporaryPassword == "alice-initial-9" || !resetBody.MustChangePassword { - t.Fatalf("reset projection: %+v", resetBody) + if issuedBody.OneTimePassword == "" || issuedBody.OneTimePassword == "alice-initial-9" || !issuedBody.MustChangePassword { + t.Fatalf("issue projection: %+v", issuedBody) } - if rec := do(h, "GET", "/api/me", "", aliceSession); rec.Code != http.StatusUnauthorized { - t.Fatalf("reset did not revoke session: %d %s", rec.Code, rec.Body.String()) + if rec := do(h, "GET", "/api/me", "", aliceSession); rec.Code != http.StatusOK { + t.Fatalf("issuing one-time password revoked a normal session: %d %s", rec.Code, rec.Body.String()) } - if _, ok := st.VerifyLogin("alice", "alice-initial-9"); ok { - t.Fatal("old password remained usable after reset") + if _, ok := st.VerifyLogin("alice", "alice-initial-9"); !ok { + t.Fatal("issuing one-time password changed normal password") } - if state, ok := st.VerifyLogin("alice", resetBody.TemporaryPassword); !ok || !state.MustChangePassword { - t.Fatalf("temporary password login: ok=%v state=%+v", ok, state) - } - if _, _, err := st.RedeemLoginKey(key); err == nil { - t.Fatal("admin reset left unused login key redeemable") + if rec := do(h, "POST", "/api/auth/login", `{"owner":"alice","password":"`+issuedBody.OneTimePassword+`"}`, ""); rec.Code != http.StatusOK { + t.Fatalf("one-time password login: %d %s", rec.Code, rec.Body.String()) } } diff --git a/internal/server/accounts.go b/internal/server/accounts.go index ad1982a..62cbab5 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -168,34 +168,47 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { return } st, ver, ok := s.st.VerifyLoginWithFallback(owner, req.Password, s.Auth.fallbackCredential()) - if !ok { - loginFail(owner) - http.Error(w, "invalid owner or password", http.StatusUnauthorized) - return - } - loginClear(owner) - // 待改密/未设密:建受限会话(只能设密);否则正常会话。会话创建绑定 - // 认证版本——密码验证在途期间账户被恢复/重置时本请求不得再建会话。 - scope := store.SessionScopeFull - maxAge := 7 * 24 * 3600 - if st.MustChangePassword || !st.HasPassword { - scope = store.SessionScopeSetPasswordOnly - maxAge = 10 * 60 - } - token, err := s.st.CreateSessionVerified(owner, scope, time.Duration(maxAge)*time.Second, ver) - if err != nil { - if errors.Is(err, store.ErrConflict) { - http.Error(w, "credentials changed concurrently; retry login", http.StatusUnauthorized) + if ok { + loginClear(owner) + // 待改密/未设密:建受限会话(只能设密);否则正常会话。会话创建绑定 + // 认证版本——密码验证在途期间账户被恢复/重置时本请求不得再建会话。 + scope := store.SessionScopeFull + maxAge := 7 * 24 * 3600 + if st.MustChangePassword || !st.HasPassword { + scope = store.SessionScopeSetPasswordOnly + maxAge = 10 * 60 + } + token, err := s.st.CreateSessionVerifiedWithPasswordLoginCleanup(owner, scope, time.Duration(maxAge)*time.Second, ver) + if err != nil { + if errors.Is(err, store.ErrConflict) { + http.Error(w, "credentials changed concurrently; retry login", http.StatusUnauthorized) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) return } - http.Error(w, err.Error(), http.StatusInternalServerError) + setSessionCookie(w, r, token, maxAge) + out := authStateJSON(st) + out["sessionScope"] = scope + out["passwordChangeMode"] = store.PasswordChangeModeCurrentRequired + writeJSON(w, http.StatusOK, out) return } - setSessionCookie(w, r, token, maxAge) - out := authStateJSON(st) - out["sessionScope"] = scope - out["passwordChangeMode"] = store.PasswordChangeModeCurrentRequired - writeJSON(w, http.StatusOK, out) + token, err := s.st.LoginWithOneTimePassword(owner, req.Password) + if err == nil { + loginClear(owner) + setSessionCookie(w, r, token, int(10*60)) + current, _ := s.st.GetUserAuthState(owner) + out := authStateJSON(current) + out["mustChangePassword"] = true + out["sessionScope"] = store.SessionScopeSetPasswordOnly + out["passwordChangeMode"] = store.PasswordChangeModeOneTimePassword + out["reloginRequired"] = false + writeJSON(w, http.StatusOK, out) + return + } + loginFail(owner) + http.Error(w, "invalid owner or password", http.StatusUnauthorized) } // logout:注销当前会话。 @@ -249,6 +262,8 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { switch mode { case store.PasswordChangeModeGrantSetup: _, completeErr = s.st.CompleteSetPasswordChecked(tok, hash) + case store.PasswordChangeModeOneTimePassword: + _, completeErr = s.st.CompleteOneTimePasswordChecked(tok, hash) case store.PasswordChangeModeCurrentRequired: _, completeErr = s.st.CompleteForcedPasswordCheckedWithFallback(tok, req.Current, hash, s.Auth.fallbackCredential()) default: @@ -543,9 +558,8 @@ func (s *Server) setAdminAccountAdmin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, accountProfileJSON(account)) } -// resetAdminUserPassword:管理员生成随机临时密码。单事务:must_change=1、bump -// auth_version、作废未用登录 Key、吊销全部会话、审计。 -func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { +// issueAdminOneTimePassword is the only administrator forgotten-password action. +func (s *Server) issueAdminOneTimePassword(w http.ResponseWriter, r *http.Request) { actor, ok := s.requireAdmin(w, r) if !ok { return @@ -561,13 +575,14 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) http.Error(w, err.Error(), http.StatusInternalServerError) return } - if err := s.st.AdminResetUserPasswordChecked(actor, r.PathValue("owner"), hash); err != nil { + meta, err := s.st.IssueOneTimePassword(actor, r.PathValue("owner"), hash) + if err != nil { writeStoreErr(w, err) return } w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, map[string]any{ - "owner": r.PathValue("owner"), "temporaryPassword": password, "mustChangePassword": true, + "owner": r.PathValue("owner"), "id": meta.ID, "oneTimePassword": password, "mustChangePassword": true, }) } diff --git a/internal/server/auth.go b/internal/server/auth.go index 84f2471..9fc61ef 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -78,8 +78,8 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler { return } p := r.URL.Path - // 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。 - if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" || p == "/api/auth/recovery-grants/redeem" { + // 豁免:静态前端与登录。 + if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" { next.ServeHTTP(w, r) return } @@ -91,9 +91,9 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler { http.Error(w, "conflicting credentials", http.StatusUnauthorized) return } - // 一次性登录 Key 不得经 Bearer / ?key= 使用。 + // 一次性口令不得经 Bearer / ?key= 使用。 if strings.HasPrefix(key, "alk_") { - http.Error(w, "unauthorized: one-time login keys must use /api/auth/login-key/redeem", http.StatusUnauthorized) + http.Error(w, "unauthorized: one-time passwords must use /api/auth/login", http.StatusUnauthorized) return } var owner string diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index aab440f..503c72b 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -424,33 +424,33 @@ func TestAdminAccountFlow(t *testing.T) { t.Fatalf("non-admin create must 403: %d", rec.Code) } - // 管理员重置总是生成临时密码,销毁 alice 的旧会话并强制首登改密。 - reset := session("POST", "/api/admin/users/alice/password", `{}`) + // 管理员签发一次性口令:不改正常密码、不销毁旧会话。 + reset := session("POST", "/api/admin/users/alice/one-time-password", `{}`) if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" { - t.Fatalf("admin reset: %d %s", reset.Code, reset.Body.String()) + t.Fatalf("admin one-time password issue: %d %s", reset.Code, reset.Body.String()) } var resetBody struct { - TemporaryPassword string `json:"temporaryPassword"` + TemporaryPassword string `json:"oneTimePassword"` } if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil || resetBody.TemporaryPassword == "" { - t.Fatalf("admin reset response: %+v %v", resetBody, err) + t.Fatalf("admin one-time password response: %+v %v", resetBody, err) } - if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { - t.Fatalf("reset must revoke alice session: %d", rec.Code) + if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK { + t.Fatalf("one-time issue must keep alice session: %d", rec.Code) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("old password remained usable after reset: %d %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("normal password login should still be full: %d %s", rec.Code, rec.Body.String()) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"`+resetBody.TemporaryPassword+`"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { - t.Fatalf("temporary password login: %d %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("unused one-time password should be invalidated by normal login: %d %s", rec.Code, rec.Body.String()) } // API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。 diff --git a/internal/server/auth_test_helpers_test.go b/internal/server/auth_test_helpers_test.go new file mode 100644 index 0000000..69ed970 --- /dev/null +++ b/internal/server/auth_test_helpers_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +// bootAdmin creates an administrator and completes the initial forced change. +func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string { + t.Helper() + bootHash, err := store.HashPassword("boot-pass-00") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", bootHash, true); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"boot-pass-00"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("boot login: %d %s", rec.Code, rec.Body.String()) + } + tok := cookieOf(rec) + if tok == "" { + t.Fatal("missing session cookie") + } + req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"current":"boot-pass-00","next":"admin-final-9"}`)) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("boot set password: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("relogin: %d %s", rec.Code, rec.Body.String()) + } + return cookieOf(rec) +} + +func cookieOf(rec *httptest.ResponseRecorder) string { + for _, ck := range rec.Result().Cookies() { + if ck.Name == SessionCookie { + return ck.Value + } + } + return "" +} + +func reqWithCookie(method, path, body, cookie string) *http.Request { + var rdr *bytes.Reader + if body != "" { + rdr = bytes.NewReader([]byte(body)) + } else { + rdr = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, rdr) + if cookie != "" { + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: cookie}) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + return req +} + +func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, reqWithCookie(method, path, body, cookie)) + return rec +} diff --git a/internal/server/login_key_test.go b/internal/server/login_key_test.go deleted file mode 100644 index c999e5c..0000000 --- a/internal/server/login_key_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package server - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "agent-system-backend/internal/store" -) - -func TestRedeemLoginKeyRestrictedSession(t *testing.T) { - srv, st := newAuthTestServer(t) - h := srv.Handler() - if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { - t.Fatal(err) - } - account, err := st.CreatePendingAccount("admin", "carol", store.AccountProfile{}) - if err != nil { - t.Fatal(err) - } - plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) - if err != nil { - t.Fatal(err) - } - body, _ := json.Marshal(map[string]string{"key": plain}) - req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", bytes.NewReader(body)) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String()) - } - cookie := rec.Result().Cookies() - var sess string - for _, c := range cookie { - if c.Name == SessionCookie { - sess = c.Value - } - } - if sess == "" { - t.Fatal("missing session cookie") - } - // 业务拒绝 - req = httptest.NewRequest("GET", "/api/projects", nil) - req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess}) - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusForbidden { - t.Fatalf("projects want 403, got %d", rec.Code) - } - // me 允许 - req = httptest.NewRequest("GET", "/api/me", nil) - req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess}) - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("me: %d", rec.Code) - } - // 设密后要求重新登录 - pwBody, _ := json.Marshal(map[string]string{"next": "carol-final-99"}) - req = httptest.NewRequest("POST", "/api/auth/password", bytes.NewReader(pwBody)) - req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess}) - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(`"reloginRequired":true`)) { - t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) - } -} diff --git a/internal/server/login_recovery_test.go b/internal/server/login_recovery_test.go deleted file mode 100644 index c70f6ad..0000000 --- a/internal/server/login_recovery_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package server - -// 登录恢复流程 HTTP 合同(2026-10-13):管理员签发一次性登录 Key → 用户兑换 → -// 受限会话强制设密 → 重新密码登录;must-change 首登同样建受限会话。 - -import ( - "bytes" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "agent-system-backend/internal/store" -) - -// bootAdmin:建管理员并完成首登改密,返回已放行的会话 cookie 值。 -func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string { - t.Helper() - bootHash, err := store.HashPassword("boot-pass-00") - if err != nil { - t.Fatal(err) - } - if err := st.CreateAccount("admin", "", bootHash, true); err != nil { - t.Fatal(err) - } - req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"boot-pass-00"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("boot login: %d %s", rec.Code, rec.Body.String()) - } - tok := cookieOf(rec) - if tok == "" { - t.Fatal("missing session cookie") - } - req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"current":"boot-pass-00","next":"admin-final-9"}`)) - req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("boot set password: %d %s", rec.Code, rec.Body.String()) - } - req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("relogin: %d %s", rec.Code, rec.Body.String()) - } - return cookieOf(rec) -} - -func cookieOf(rec *httptest.ResponseRecorder) string { - for _, ck := range rec.Result().Cookies() { - if ck.Name == SessionCookie { - return ck.Value - } - } - return "" -} - -func reqWithCookie(method, path, body, cookie string) *http.Request { - var rdr *bytes.Reader - if body != "" { - rdr = bytes.NewReader([]byte(body)) - } else { - rdr = bytes.NewReader(nil) - } - req := httptest.NewRequest(method, path, rdr) - if cookie != "" { - req.AddCookie(&http.Cookie{Name: SessionCookie, Value: cookie}) - } - if body != "" { - req.Header.Set("Content-Type", "application/json") - } - return req -} - -func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder { - rec := httptest.NewRecorder() - h.ServeHTTP(rec, reqWithCookie(method, path, body, cookie)) - return rec -} - -// 管理员签发 → 列表无明文 → 兑换 → 受限 → 设密 → 新密码登录。 -func TestAdminLoginKeyRecoveryFlow(t *testing.T) { - srv, st := newAuthTestServer(t) - h := srv.Handler() - admin := bootAdmin(t, h, st) - hash, err := store.HashPassword("victim-old-password") - if err != nil { - t.Fatal(err) - } - if err := st.CreateAccount("victim", "", hash, false); err != nil { - t.Fatal(err) - } - legacy, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) - if err != nil { - t.Fatal(err) - } - if rec := do(h, "POST", "/api/admin/users/victim/login-keys", `{}`, admin); rec.Code != http.StatusGone { - t.Fatalf("administrator secret issuance must be closed: %d %s", rec.Code, rec.Body.String()) - } - if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK { - t.Fatal("legacy administrator key redeemed after cutover") - } - if rec := do(h, "GET", "/api/admin/users/victim/login-keys", "", admin); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), legacy) { - t.Fatalf("metadata lookup leaked secret: %d %s", rec.Code, rec.Body.String()) - } -} - -func TestAdminLoginKeyReissueAndRevoke(t *testing.T) { - srv, st := newAuthTestServer(t) - h := srv.Handler() - admin := bootAdmin(t, h, st) - if err := st.CreateAccount("victim", "", "", false); err != nil { - t.Fatal(err) - } - legacy, meta, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) - if err != nil { - t.Fatal(err) - } - if rec := do(h, "DELETE", "/api/admin/users/other/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusNotFound { - t.Fatalf("path owner mismatch revoked key: %d %s", rec.Code, rec.Body.String()) - } - if rec := do(h, "DELETE", "/api/admin/users/victim/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusOK { - t.Fatalf("legacy metadata revoke: %d %s", rec.Code, rec.Body.String()) - } - if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK { - t.Fatal("revoked legacy key redeemed") - } -} - -// must-change 首登:登录即建受限会话(sessionScope 回传),设密后重新登录为 full。 -func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) { - srv, st := newAuthTestServer(t) - h := srv.Handler() - bootHash, err := store.HashPassword("temp-pass-00") - if err != nil { - t.Fatal(err) - } - if err := st.CreateAccount("oscar", "", bootHash, false); err != nil { - t.Fatal(err) - } - req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"temp-pass-00"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) { - t.Fatalf("must-change login must create restricted session: %d %s", rec.Code, rec.Body.String()) - } - tok := cookieOf(rec) - if rec := do(h, "GET", "/api/projects", "", tok); rec.Code != http.StatusForbidden { - t.Fatalf("business api before set: %d", rec.Code) - } - rec = do(h, "POST", "/api/auth/password", `{"current":"temp-pass-00","next":"oscar-final-9"}`, tok) - if rec.Code != http.StatusOK { - t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) - } - req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"oscar-final-9"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"full"`) { - t.Fatalf("full login after set: %d %s", rec.Code, rec.Body.String()) - } -} - -// 主动改密:必须带正确 current;成功保留当前会话。 -func TestActiveChangePasswordRequiresCurrent(t *testing.T) { - srv, st := newAuthTestServer(t) - h := srv.Handler() - _ = srv - if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { - t.Fatal(err) - } - account, err := st.CreatePendingAccount("admin", "petra", store.AccountProfile{}) - if err != nil { - t.Fatal(err) - } - plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) - if err != nil { - t.Fatal(err) - } - req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+plain+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - tok := cookieOf(rec) - rec = do(h, "POST", "/api/auth/password", `{"next":"petra-first-8"}`, tok) - if rec.Code != http.StatusOK { - t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String()) - } - // 重新登录得 full 会话。 - req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"petra","password":"petra-first-8"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - tok = cookieOf(rec) - // 缺 current / 错 current 拒绝。 - if rec := do(h, "POST", "/api/auth/password", `{"next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized { - t.Fatalf("active change without current: %d", rec.Code) - } - if rec := do(h, "POST", "/api/auth/password", `{"current":"wrong-current","next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized { - t.Fatalf("active change with wrong current: %d", rec.Code) - } - // 正确 current 成功且当前会话保留。 - rec = do(h, "POST", "/api/auth/password", `{"current":"petra-first-8","next":"petra-second-9"}`, tok) - if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"reloginRequired":true`) { - t.Fatalf("active change: %d %s", rec.Code, rec.Body.String()) - } - if rec := do(h, "GET", "/api/me", "", tok); rec.Code != http.StatusOK { - t.Fatalf("current session must survive active change: %d", rec.Code) - } -} diff --git a/internal/server/one_time_password_test.go b/internal/server/one_time_password_test.go new file mode 100644 index 0000000..9385787 --- /dev/null +++ b/internal/server/one_time_password_test.go @@ -0,0 +1,274 @@ +package server + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +func otpUser(t *testing.T, st *store.Store, owner, password string) { + t.Helper() + hash, err := store.HashPassword(password) + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount(owner, "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword(owner); err != nil { + t.Fatal(err) + } +} + +func issueOTP(t *testing.T, h http.Handler, admin, owner string) string { + t.Helper() + rec := do(h, "POST", "/api/admin/users/"+owner+"/one-time-password", `{}`, admin) + if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("issue one-time password: %d %s", rec.Code, rec.Body.String()) + } + var out struct { + Owner string `json:"owner"` + ID string `json:"id"` + OneTimePassword string `json:"oneTimePassword"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Owner != owner || out.ID == "" || out.OneTimePassword == "" { + t.Fatalf("issue response: %+v %v", out, err) + } + return out.OneTimePassword +} + +func loginBody(owner, password string) string { + b, _ := json.Marshal(map[string]string{"owner": owner, "password": password}) + return string(b) +} + +func TestAdminOneTimePasswordLoginForcesReplacement(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + otpUser(t, st, "olivia", "olivia-normal-1") + normal := do(h, "POST", "/api/auth/login", loginBody("olivia", "olivia-normal-1"), "") + if normal.Code != http.StatusOK { + t.Fatalf("normal login: %d %s", normal.Code, normal.Body.String()) + } + otp := issueOTP(t, h, admin, "olivia") + if rec := do(h, "GET", "/api/me", "", cookieOf(normal)); rec.Code != http.StatusOK { + t.Fatalf("issuance revoked a normal session: %d", rec.Code) + } + login := do(h, "POST", "/api/auth/login", loginBody("olivia", otp), "") + body := login.Body.String() + if login.Code != http.StatusOK || !strings.Contains(body, `"sessionScope":"set_password_only"`) || + !strings.Contains(body, `"passwordChangeMode":"one_time_password"`) || !strings.Contains(body, `"mustChangePassword":true`) { + t.Fatalf("one-time password login: %d %s", login.Code, body) + } + restricted := cookieOf(login) + if rec := do(h, "GET", "/api/projects", "", restricted); rec.Code != http.StatusForbidden { + t.Fatalf("restricted session reached business API: %d", rec.Code) + } + if rec := do(h, "GET", "/api/me", "", restricted); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"passwordChangeMode":"one_time_password"`) { + t.Fatalf("me on restricted session: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(h, "POST", "/api/auth/login", loginBody("olivia", otp), ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("one-time password reused: %d %s", rec.Code, rec.Body.String()) + } + done := do(h, "POST", "/api/auth/password", `{"next":"olivia-replaced-2"}`, restricted) + if done.Code != http.StatusOK || !strings.Contains(done.Body.String(), `"reloginRequired":true`) { + t.Fatalf("replacement: %d %s", done.Code, done.Body.String()) + } + if rec := do(h, "GET", "/api/me", "", cookieOf(normal)); rec.Code != http.StatusUnauthorized { + t.Fatalf("old normal session survived replacement: %d", rec.Code) + } + if rec := do(h, "POST", "/api/auth/login", loginBody("olivia", "olivia-normal-1"), ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("old normal password survived replacement: %d", rec.Code) + } + fresh := do(h, "POST", "/api/auth/login", loginBody("olivia", "olivia-replaced-2"), "") + if fresh.Code != http.StatusOK || !strings.Contains(fresh.Body.String(), `"sessionScope":"full"`) || !strings.Contains(fresh.Body.String(), `"mustChangePassword":false`) { + t.Fatalf("replaced password login: %d %s", fresh.Code, fresh.Body.String()) + } +} + +func TestNormalLoginInvalidatesAdminOneTimePasswordWithoutForcedChange(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + otpUser(t, st, "nora", "nora-normal-1") + otp := issueOTP(t, h, admin, "nora") + normal := do(h, "POST", "/api/auth/login", loginBody("nora", "nora-normal-1"), "") + if normal.Code != http.StatusOK || !strings.Contains(normal.Body.String(), `"sessionScope":"full"`) || !strings.Contains(normal.Body.String(), `"mustChangePassword":false`) { + t.Fatalf("normal login after issuance must stay full: %d %s", normal.Code, normal.Body.String()) + } + if rec := do(h, "GET", "/api/projects", "", cookieOf(normal)); rec.Code != http.StatusOK { + t.Fatalf("normal session gated: %d", rec.Code) + } + if rec := do(h, "POST", "/api/auth/login", loginBody("nora", otp), ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("one-time password survived normal login: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestNormalLoginRevokesExistingOneTimePasswordRestrictedSession(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + otpUser(t, st, "ivy", "ivy-normal-1") + otp := issueOTP(t, h, admin, "ivy") + restrictedLogin := do(h, "POST", "/api/auth/login", loginBody("ivy", otp), "") + if restrictedLogin.Code != http.StatusOK || !strings.Contains(restrictedLogin.Body.String(), `"passwordChangeMode":"one_time_password"`) { + t.Fatalf("otp restricted login: %d %s", restrictedLogin.Code, restrictedLogin.Body.String()) + } + restricted := cookieOf(restrictedLogin) + normal := do(h, "POST", "/api/auth/login", loginBody("ivy", "ivy-normal-1"), "") + if normal.Code != http.StatusOK || !strings.Contains(normal.Body.String(), `"sessionScope":"full"`) { + t.Fatalf("normal login: %d %s", normal.Code, normal.Body.String()) + } + if rec := do(h, "POST", "/api/auth/password", `{"next":"ivy-reset-2"}`, restricted); rec.Code != http.StatusUnauthorized { + t.Fatalf("restricted session survived normal login for password endpoint: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(h, "GET", "/api/me", "", restricted); rec.Code != http.StatusUnauthorized { + t.Fatalf("restricted session survived normal login for me endpoint: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAdminOneTimePasswordIssuanceBoundaries(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + otpUser(t, st, "bob", "bob-normal-01") + otpUser(t, st, "carl", "carl-normal-1") + bob := do(h, "POST", "/api/auth/login", loginBody("bob", "bob-normal-01"), "") + if bob.Code != http.StatusOK { + t.Fatalf("bob login: %d", bob.Code) + } + if rec := do(h, "POST", "/api/admin/users/carl/one-time-password", `{}`, cookieOf(bob)); rec.Code != http.StatusForbidden { + t.Fatalf("non-admin issued: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(h, "POST", "/api/admin/users/missing/one-time-password", `{}`, admin); rec.Code != http.StatusNotFound { + t.Fatalf("missing target: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(h, "POST", "/api/admin/users/carl/one-time-password", `{"password":"chosen"}`, admin); rec.Code != http.StatusBadRequest { + t.Fatalf("administrator-chosen password accepted: %d %s", rec.Code, rec.Body.String()) + } + otp := issueOTP(t, h, admin, "carl") + if rec := do(h, "POST", "/api/auth/login", loginBody("bob", otp), ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("one-time password used by another owner: %d", rec.Code) + } + if rec := do(h, "GET", "/api/projects", "", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous request: %d", rec.Code) + } + if rec := do(h, "POST", "/api/auth/login", loginBody("carl", otp), ""); rec.Code != http.StatusOK { + t.Fatalf("owner one-time login: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAdminOneTimePasswordInitializesPendingAccount(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + account, err := st.CreatePendingAccount("admin", "pending", store.AccountProfile{}) + if err != nil { + t.Fatal(err) + } + otp := issueOTP(t, h, admin, "pending") + login := do(h, "POST", "/api/auth/login", loginBody("pending", otp), "") + if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), `"passwordChangeMode":"one_time_password"`) || + !strings.Contains(login.Body.String(), `"sessionScope":"set_password_only"`) { + t.Fatalf("pending otp login: %d %s", login.Code, login.Body.String()) + } + restricted := cookieOf(login) + if rec := do(h, "POST", "/api/auth/password", `{"next":"pending-pass-2"}`, restricted); rec.Code != http.StatusOK { + t.Fatalf("pending completion: %d %s", rec.Code, rec.Body.String()) + } + fresh := do(h, "POST", "/api/auth/login", loginBody("pending", "pending-pass-2"), "") + if fresh.Code != http.StatusOK || !strings.Contains(fresh.Body.String(), `"sessionScope":"full"`) { + t.Fatalf("pending normal login: %d %s", fresh.Code, fresh.Body.String()) + } + view := do(h, "GET", "/api/admin/users/"+account.ID, "", admin) + if view.Code != http.StatusOK || !strings.Contains(view.Body.String(), `"initializationState":"initialized"`) || + !strings.Contains(view.Body.String(), `"passwordSet":true`) { + t.Fatalf("pending account state after completion: %d %s", view.Code, view.Body.String()) + } +} + +func TestAdminOneTimePasswordInitializesHistoricalPasswordlessAccount(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + if err := st.CreateAccount("hank", "", "", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("hank") + if err != nil || account.HasPassword || !account.Enabled { + t.Fatalf("historical passwordless fixture: %+v %v", account, err) + } + if rec := do(h, "POST", "/api/auth/login", loginBody("hank", ""), ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("passwordless account logged in without a credential: %d %s", rec.Code, rec.Body.String()) + } + otp := issueOTP(t, h, admin, "hank") + login := do(h, "POST", "/api/auth/login", loginBody("hank", otp), "") + body := login.Body.String() + if login.Code != http.StatusOK || !strings.Contains(body, `"sessionScope":"set_password_only"`) || + !strings.Contains(body, `"passwordChangeMode":"one_time_password"`) || !strings.Contains(body, `"mustChangePassword":true`) { + t.Fatalf("initialization login: %d %s", login.Code, body) + } + restricted := cookieOf(login) + if rec := do(h, "GET", "/api/projects", "", restricted); rec.Code != http.StatusForbidden { + t.Fatalf("initialization session reached business API: %d", rec.Code) + } + if rec := do(h, "GET", "/api/me/profile", "", restricted); rec.Code != http.StatusForbidden { + t.Fatalf("initialization session reached profile API: %d", rec.Code) + } + if rec := do(h, "POST", "/api/auth/password", `{"next":"short"}`, restricted); rec.Code != http.StatusBadRequest { + t.Fatalf("short initialization password accepted: %d", rec.Code) + } + done := do(h, "POST", "/api/auth/password", `{"next":"hank-real-pass-1"}`, restricted) + if done.Code != http.StatusOK || !strings.Contains(done.Body.String(), `"reloginRequired":true`) { + t.Fatalf("initialization password: %d %s", done.Code, done.Body.String()) + } + if rec := do(h, "GET", "/api/me", "", restricted); rec.Code != http.StatusUnauthorized { + t.Fatalf("initialization session survived completion: %d", rec.Code) + } + view := do(h, "GET", "/api/admin/users/"+account.ID, "", admin) + if view.Code != http.StatusOK || !strings.Contains(view.Body.String(), `"passwordSet":true`) || + !strings.Contains(view.Body.String(), `"initializationState":"initialized"`) { + t.Fatalf("admin view after initialization: %d %s", view.Code, view.Body.String()) + } + fresh := do(h, "POST", "/api/auth/login", loginBody("hank", "hank-real-pass-1"), "") + if fresh.Code != http.StatusOK || !strings.Contains(fresh.Body.String(), `"sessionScope":"full"`) || !strings.Contains(fresh.Body.String(), `"mustChangePassword":false`) { + t.Fatalf("real password login: %d %s", fresh.Code, fresh.Body.String()) + } + if rec := do(h, "GET", "/api/projects", "", cookieOf(fresh)); rec.Code != http.StatusOK { + t.Fatalf("initialized account gated: %d", rec.Code) + } +} + +func TestRecoveryProposalRoutesAreRemoved(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + account, err := st.GetAccountByOwner("admin") + if err != nil { + t.Fatal(err) + } + for _, route := range []struct{ method, path, body string }{ + {"POST", "/api/admin/users/" + account.ID + "/recovery-proposals", `{"expectedStateVersion":1,"idempotencyKey":"k"}`}, + {"GET", "/api/admin/users/" + account.ID + "/recovery-proposals", ""}, + {"GET", "/api/me/recovery-proposals", ""}, + {"POST", "/api/auth/recovery-proposals/rp_x/confirm", `{"expectedVersion":1}`}, + {"POST", "/api/auth/recovery-proposals/rp_x/rotate-grant", `{"expectedVersion":1,"expectedGeneration":1}`}, + {"POST", "/api/auth/recovery-grants/redeem", `{"key":"alk_x"}`}, + {"POST", "/api/auth/login-key/redeem", `{"key":"alk_x"}`}, + {"POST", "/api/admin/users/admin/login-keys", `{}`}, + {"GET", "/api/admin/users/admin/login-keys", ""}, + {"DELETE", "/api/admin/users/admin/login-keys/lk_x", ""}, + {"POST", "/api/admin/users/admin/password", `{}`}, + } { + if rec := do(h, route.method, route.path, route.body, admin); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("%s %s still served: %d %s", route.method, route.path, rec.Code, rec.Body.String()) + } + } + if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"alk_x"}`, ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("legacy redeem remained an unauthenticated entrance: %d", rec.Code) + } +} diff --git a/internal/server/recovery.go b/internal/server/recovery.go deleted file mode 100644 index 92eee7b..0000000 --- a/internal/server/recovery.go +++ /dev/null @@ -1,128 +0,0 @@ -package server - -import ( - "net/http" -) - -func (s *Server) createAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) { - actor, ok := s.requireAdmin(w, r) - if !ok { - return - } - var req struct { - ExpectedStateVersion int64 `json:"expectedStateVersion"` - IdempotencyKey string `json:"idempotencyKey"` - } - if err := decodeStrictJSON(r, &req); err != nil { - http.Error(w, "invalid recovery proposal", http.StatusBadRequest) - return - } - proposal, err := s.st.CreateRecoveryProposal(actor, r.PathValue("userId"), req.ExpectedStateVersion, req.IdempotencyKey) - if err != nil { - writeStoreErr(w, err) - return - } - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, proposal) -} - -func (s *Server) getAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) { - actor, ok := s.requireAdmin(w, r) - if !ok { - return - } - proposal, err := s.st.GetRecoveryProposalForAdmin(actor, r.PathValue("userId"), r.PathValue("id")) - writeJSONOr(w, proposal, err) -} - -func (s *Server) listAdminRecoveryProposals(w http.ResponseWriter, r *http.Request) { - actor, ok := s.requireAdmin(w, r) - if !ok { - return - } - proposals, err := s.st.ListRecoveryProposalsForAdmin(actor, r.PathValue("userId")) - writeJSONOr(w, map[string]any{"proposals": proposals}, err) -} - -func (s *Server) revokeAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) { - actor, ok := s.requireAdmin(w, r) - if !ok { - return - } - var req struct { - ExpectedVersion int64 `json:"expectedVersion"` - } - if err := decodeStrictJSON(r, &req); err != nil { - http.Error(w, "invalid recovery revocation", http.StatusBadRequest) - return - } - proposal, err := s.st.RevokeRecoveryProposal(actor, r.PathValue("userId"), r.PathValue("id"), req.ExpectedVersion) - writeJSONOr(w, proposal, err) -} - -func (s *Server) listMyRecoveryProposals(w http.ResponseWriter, r *http.Request) { - owner, ok := s.requireFullPasswordSession(w, r) - if !ok { - return - } - proposals, err := s.st.ListOwnerRecoveryProposals(owner) - writeJSONOr(w, map[string]any{"proposals": proposals}, err) -} - -func (s *Server) getMyRecoveryProposal(w http.ResponseWriter, r *http.Request) { - owner, ok := s.requireFullPasswordSession(w, r) - if !ok { - return - } - proposal, err := s.st.GetOwnerRecoveryProposal(owner, r.PathValue("id")) - writeJSONOr(w, proposal, err) -} - -func (s *Server) confirmRecoveryProposal(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireFullPasswordSession(w, r); !ok { - return - } - var req struct { - ExpectedVersion int64 `json:"expectedVersion"` - } - if err := decodeStrictJSON(r, &req); err != nil { - http.Error(w, "invalid recovery confirmation", http.StatusBadRequest) - return - } - plain, proposal, err := s.st.ConfirmRecoveryProposal(sessionToken(r), r.PathValue("id"), req.ExpectedVersion) - if err != nil { - writeStoreErr(w, err) - return - } - w.Header().Set("Cache-Control", "no-store") - 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) { - if _, ok := s.requireFullPasswordSession(w, r); !ok { - return - } - var req struct { - ExpectedVersion int64 `json:"expectedVersion"` - ExpectedGeneration int64 `json:"expectedGeneration"` - } - if err := decodeStrictJSON(r, &req); err != nil { - http.Error(w, "invalid recovery grant rotation", http.StatusBadRequest) - return - } - plain, proposal, err := s.st.RotateRecoveryGrant(sessionToken(r), r.PathValue("id"), req.ExpectedVersion, req.ExpectedGeneration) - if err != nil { - writeStoreErr(w, err) - return - } - w.Header().Set("Cache-Control", "no-store") - 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 deleted file mode 100644 index 9918ffc..0000000 --- a/internal/server/recovery_contract_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "strconv" - "strings" - "testing" - - "agent-system-backend/internal/store" -) - -func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { - srv, st := newAuthTestServer(t) - h := srv.Handler() - admin := bootAdmin(t, h, st) - pending, err := st.CreatePendingAccount("admin", "recovery-user", store.AccountProfile{}) - if err != nil { - t.Fatal(err) - } - activation, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration) - if err != nil { - t.Fatal(err) - } - setup := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "") - if setup.Code != http.StatusOK || !strings.Contains(setup.Body.String(), `"passwordChangeMode":"grant_setup"`) { - t.Fatalf("activation redeem: %d %s", setup.Code, setup.Body.String()) - } - if rec := do(h, "POST", "/api/auth/password", `{"next":"alice-initial-password"}`, cookieOf(setup)); rec.Code != http.StatusOK { - t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String()) - } - ownerLogin := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-initial-password"}`, "") - if ownerLogin.Code != http.StatusOK { - t.Fatalf("trusted owner login: %d %s", ownerLogin.Code, ownerLogin.Body.String()) - } - ownerCookie := cookieOf(ownerLogin) - account, err := st.GetAccount(pending.ID) - if err != nil { - t.Fatal(err) - } - proposalResponse := do(h, "POST", "/api/admin/users/"+pending.ID+"/recovery-proposals", - `{"expectedStateVersion":`+jsonNumber(account.StateVersion)+`,"idempotencyKey":"http-request-1"}`, admin) - if proposalResponse.Code != http.StatusOK || strings.Contains(proposalResponse.Body.String(), "grant") { - t.Fatalf("administrator proposal leaked grant: %d %s", proposalResponse.Code, proposalResponse.Body.String()) - } - var proposal store.RecoveryProposal - if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil || proposal.ID == "" { - t.Fatalf("proposal metadata: %+v %v", proposal, err) - } - if got := do(h, "GET", "/api/admin/users/"+pending.ID+"/recovery-proposals/"+proposal.ID, "", admin); got.Code != http.StatusOK || strings.Contains(got.Body.String(), "grant") { - t.Fatalf("administrator status leaked grant: %d %s", got.Code, got.Body.String()) - } - confirmed := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie) - if confirmed.Code != http.StatusOK || confirmed.Header().Get("Cache-Control") != "no-store" { - t.Fatalf("owner confirm: %d %s", confirmed.Code, confirmed.Body.String()) - } - var confirmation struct { - Proposal store.RecoveryProposal `json:"proposal"` - Grant string `json:"grant"` - } - 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()) - } - rotated := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant", - `{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie) - if rotated.Code != http.StatusOK { - t.Fatalf("owner rotate: %d %s", rotated.Code, rotated.Body.String()) - } - var rotation struct { - Grant string `json:"grant"` - } - 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") - } - redeemed := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+rotation.Grant+`"}`, "") - if redeemed.Code != http.StatusOK { - t.Fatalf("recovery redeem: %d %s", redeemed.Code, redeemed.Body.String()) - } - if done := do(h, "POST", "/api/auth/password", `{"next":"alice-recovered-password"}`, cookieOf(redeemed)); done.Code != http.StatusOK { - t.Fatalf("recovery finish: %d %s", done.Code, done.Body.String()) - } - if login := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-recovered-password"}`, ""); login.Code != http.StatusOK { - t.Fatalf("relogin: %d %s", login.Code, login.Body.String()) - } -} - -func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) } - -func TestLegacyAdministratorLoginKeyIssuanceEntrancesAreClosed(t *testing.T) { - srv, st := newAuthTestServer(t) - h := srv.Handler() - admin := bootAdmin(t, h, st) - hash, err := store.HashPassword("old-victim-pass") - if err != nil { - t.Fatal(err) - } - if err := st.CreateAccount("victim", "", hash, false); err != nil { - t.Fatal(err) - } - if err := st.ClearMustChangePassword("victim"); err != nil { - t.Fatal(err) - } - for _, route := range []struct{ path, body string }{ - {"/api/admin/users/victim/login-keys", `{"purpose":"recover"}`}, - {"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`}, - } { - rec := do(h, "POST", route.path, route.body, admin) - if rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "RECOVERY_CONTRACT_REPLACED") { - t.Fatalf("legacy route remained active: %s %d %s", route.path, rec.Code, rec.Body.String()) - } - } - if _, ok := st.VerifyLogin("victim", "old-victim-pass"); !ok { - t.Fatal("closed routes changed existing password") - } - if keys, err := st.ListLoginKeys("victim"); err != nil || len(keys) != 0 { - t.Fatalf("closed routes issued a grant: %+v %v", keys, err) - } -} - -func TestLegacyGrantAndRestrictedSessionCannotSkipCurrentPassword(t *testing.T) { - srv, st := newAuthTestServer(t) - h := srv.Handler() - hash, err := store.HashPassword("old-victim-pass") - if err != nil { - t.Fatal(err) - } - if err := st.CreateAccount("victim", "", hash, false); err != nil { - t.Fatal(err) - } - grant, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) - if err != nil { - t.Fatal(err) - } - if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+grant+`"}`, ""); rec.Code == http.StatusOK { - t.Fatal("legacy administrator grant created a restricted session") - } - if err := st.SetUserPassword("victim", hash); err != nil { - t.Fatal(err) - } - login := do(h, "POST", "/api/auth/login", `{"owner":"victim","password":"old-victim-pass"}`, "") - if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), `"passwordChangeMode":"current_required"`) { - t.Fatalf("legacy forced login mode: %d %s", login.Code, login.Body.String()) - } - cookie := cookieOf(login) - if rec := do(h, "POST", "/api/auth/password", `{"next":"victim-new-pass"}`, cookie); rec.Code == http.StatusOK { - t.Fatal("forced password session skipped current password") - } - if rec := do(h, "POST", "/api/auth/password", `{"current":"old-victim-pass","next":"victim-new-pass"}`, cookie); rec.Code != http.StatusOK { - t.Fatalf("legacy forced change with current failed: %d %s", rec.Code, rec.Body.String()) - } - if _, ok := st.VerifyLogin("victim", "victim-new-pass"); !ok { - t.Fatal("current-required password change did not complete") - } -} diff --git a/internal/server/recovery_multi_instance_test.go b/internal/server/recovery_multi_instance_test.go deleted file mode 100644 index f8dc950..0000000 --- a/internal/server/recovery_multi_instance_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package server - -import ( - "database/sql" - "encoding/json" - "net/http" - "os" - "path/filepath" - "strings" - "testing" - - "agent-system-backend/internal/store" -) - -func twinRecoveryStores(t *testing.T) (*store.Store, *store.Store) { - t.Helper() - dsn := os.Getenv("AGENT_PG_TEST_DSN") - if dsn == "" { - path := filepath.Join(t.TempDir(), "shared.db") - first, err := store.OpenSQLite(path) - if err != nil { - t.Fatal(err) - } - second, err := store.OpenSQLite(path) - if err != nil { - first.Close() - t.Fatal(err) - } - t.Cleanup(func() { second.Close(); first.Close() }) - return first, second - } - admin, err := sql.Open("pgx", dsn) - if err != nil { - t.Fatal(err) - } - if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil { - t.Fatal(err) - } - schema := "t_" + strings.ToLower(store.NewID("recovery")) - if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _, _ = admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`); admin.Close() }) - isolate := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public") - first, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "a-artifacts")) - if err != nil { - t.Fatal(err) - } - second, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "b-artifacts")) - if err != nil { - first.Close() - t.Fatal(err) - } - t.Cleanup(func() { second.Close(); first.Close() }) - return first, second -} - -func TestRecoveryAcrossTwoHTTPInstancesAndIndependentPools(t *testing.T) { - first, second := twinRecoveryStores(t) - t.Logf("recovery HTTP dialect=%s two independent stores", first.Dialect()) - hA := newAuthTestServerOnStore(t, first, t.TempDir()).Handler() - hB := newAuthTestServerOnStore(t, second, t.TempDir()).Handler() - adminCookie := bootAdmin(t, hA, first) - account, err := first.CreatePendingAccount("admin", "two-instance-owner", store.AccountProfile{}) - if err != nil { - t.Fatal(err) - } - activation, err := first.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) - if err != nil { - t.Fatal(err) - } - setup := do(hB, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "") - if setup.Code != http.StatusOK { - t.Fatalf("second instance activation redeem: %d %s", setup.Code, setup.Body.String()) - } - if done := do(hA, "POST", "/api/auth/password", `{"next":"two-instance-initial"}`, cookieOf(setup)); done.Code != http.StatusOK { - t.Fatalf("first instance initial set: %d %s", done.Code, done.Body.String()) - } - login := do(hA, "POST", "/api/auth/login", `{"owner":"two-instance-owner","password":"two-instance-initial"}`, "") - if login.Code != http.StatusOK { - t.Fatalf("first instance login: %d %s", login.Code, login.Body.String()) - } - ownerCookie := cookieOf(login) - current, err := second.GetAccount(account.ID) - if err != nil { - t.Fatal(err) - } - proposalResponse := do(hA, "POST", "/api/admin/users/"+account.ID+"/recovery-proposals", - `{"expectedStateVersion":`+jsonNumber(current.StateVersion)+`,"idempotencyKey":"two-instance-proposal"}`, adminCookie) - if proposalResponse.Code != http.StatusOK { - t.Fatalf("proposal: %d %s", proposalResponse.Code, proposalResponse.Body.String()) - } - var proposal store.RecoveryProposal - if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil { - t.Fatal(err) - } - confirmation := do(hB, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie) - if confirmation.Code != http.StatusOK { - t.Fatalf("second instance confirm: %d %s", confirmation.Code, confirmation.Body.String()) - } - var confirmed struct { - Grant string `json:"grant"` - } - if err := json.Unmarshal(confirmation.Body.Bytes(), &confirmed); err != nil || confirmed.Grant == "" { - t.Fatalf("grant response: %v", err) - } - redeemed := do(hA, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmed.Grant+`"}`, "") - if redeemed.Code != http.StatusOK { - t.Fatalf("first instance grant redeem: %d %s", redeemed.Code, redeemed.Body.String()) - } - if completed := do(hB, "POST", "/api/auth/password", `{"next":"two-instance-recovered"}`, cookieOf(redeemed)); completed.Code != http.StatusOK { - t.Fatalf("second instance recovery completion: %d %s", completed.Code, completed.Body.String()) - } - if old := do(hB, "GET", "/api/me", "", ownerCookie); old.Code != http.StatusUnauthorized { - t.Fatalf("old proof session survived: %d", old.Code) - } - if fresh := do(hA, "POST", "/api/auth/login", `{"owner":"two-instance-owner","password":"two-instance-recovered"}`, ""); fresh.Code != http.StatusOK { - t.Fatalf("recovered password login: %d %s", fresh.Code, fresh.Body.String()) - } - after, err := first.GetAccount(account.ID) - if err != nil || after.CredentialOrigin != store.CredentialOriginOwnerRecovery || after.ID != account.ID { - t.Fatalf("persistent identity or provenance changed: %+v %v", after, err) - } -} diff --git a/internal/server/server.go b/internal/server/server.go index 1f5fbdd..b86dd73 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -297,8 +297,6 @@ func (s *Server) Handler() http.Handler { // ---- 认证与会话(管理员发放制,R2)---- mux.HandleFunc("POST /api/auth/login", s.login) - mux.HandleFunc("POST /api/auth/login-key/redeem", s.redeemLoginKey) - mux.HandleFunc("POST /api/auth/recovery-grants/redeem", s.redeemLoginKey) mux.HandleFunc("POST /api/auth/logout", s.logout) mux.HandleFunc("POST /api/auth/password", s.changePassword) mux.HandleFunc("GET /api/admin/users", s.listAdminUsers) @@ -315,18 +313,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PATCH /api/admin/users/{userId}/profile", s.patchAdminAccountProfile) mux.HandleFunc("PUT /api/admin/users/{userId}/state", s.setAdminAccountState) mux.HandleFunc("PUT /api/admin/users/{userId}/admin", s.setAdminAccountAdmin) - mux.HandleFunc("POST /api/admin/users/{userId}/recovery-proposals", s.createAdminRecoveryProposal) - mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals", s.listAdminRecoveryProposals) - mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals/{id}", s.getAdminRecoveryProposal) - mux.HandleFunc("DELETE /api/admin/users/{userId}/recovery-proposals/{id}", s.revokeAdminRecoveryProposal) - mux.HandleFunc("GET /api/me/recovery-proposals", s.listMyRecoveryProposals) - mux.HandleFunc("GET /api/me/recovery-proposals/{id}", s.getMyRecoveryProposal) - mux.HandleFunc("POST /api/auth/recovery-proposals/{id}/confirm", s.confirmRecoveryProposal) - mux.HandleFunc("POST /api/auth/recovery-proposals/{id}/rotate-grant", s.rotateRecoveryGrant) - mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword) - mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey) - mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys) - mux.HandleFunc("DELETE /api/admin/users/{owner}/login-keys/{id}", s.revokeAdminLoginKey) + mux.HandleFunc("POST /api/admin/users/{owner}/one-time-password", s.issueAdminOneTimePassword) mux.HandleFunc("GET /api/admin/permissions/catalog", s.listPermissionCatalog) mux.HandleFunc("GET /api/admin/roles", s.listRoles) mux.HandleFunc("POST /api/admin/roles", s.createRole) @@ -2407,10 +2394,6 @@ func writeOKOr(w http.ResponseWriter, err error) { } func writeStoreErr(w http.ResponseWriter, err error) { - if errors.Is(err, store.ErrOwnerProofUnavailable) { - http.Error(w, "OWNER_PROOF_UNAVAILABLE", http.StatusConflict) - return - } if errors.Is(err, store.ErrAccountForbidden) { http.Error(w, err.Error(), http.StatusForbidden) return diff --git a/internal/server/sse_close_test.go b/internal/server/sse_close_test.go index 706604d..d079455 100644 --- a/internal/server/sse_close_test.go +++ b/internal/server/sse_close_test.go @@ -182,25 +182,3 @@ func TestSSEAPIKeyRevocationClosesStream(t *testing.T) { t.Fatal("stream not closed within recheck window after key revocation") } } - -// 兑换入口限流:同源连续失败达上限后返回 429(跨实例共担由 store 测试覆盖)。 -func TestRedeemRateLimited(t *testing.T) { - srv, _ := newAuthTestServer(t) - h := srv.Handler() - for i := 0; i < redeemMaxFails; i++ { - req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_not_a_real_key_at_all"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("attempt %d: want 401, got %d", i, rec.Code) - } - } - req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_other"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusTooManyRequests { - t.Fatalf("want 429 after %d failures, got %d", redeemMaxFails, rec.Code) - } -} diff --git a/internal/server/super_admin_authority_test.go b/internal/server/super_admin_authority_test.go index fe64ad0..123e7fa 100644 --- a/internal/server/super_admin_authority_test.go +++ b/internal/server/super_admin_authority_test.go @@ -204,8 +204,7 @@ func TestOrdinaryAdminHTTPCannotMutateSuperAdmin(t *testing.T) { for _, route := range []struct{ method, path, body string }{ {"PATCH", "/api/admin/users/" + root.ID + "/profile", `{"expectedProfileVersion":1,"patch":{"name":"renamed"}}`}, {"PUT", "/api/admin/users/" + root.ID + "/state", `{"expectedStateVersion":` + version + `,"enabled":false}`}, - {"POST", "/api/admin/users/admin/password", `{}`}, - {"POST", "/api/admin/users/" + root.ID + "/recovery-proposals", `{"expectedStateVersion":` + version + `,"idempotencyKey":"ops-recover-root"}`}, + {"POST", "/api/admin/users/admin/one-time-password", `{}`}, {"PUT", "/api/admin/users/" + root.ID + "/admin", `{"expectedStateVersion":` + version + `,"isAdmin":false}`}, {"PUT", "/api/admin/users/" + root.ID + "/role", `{"roleId":"` + roleID + `","expectedStateVersion":` + version + `,"idempotencyKey":"ops-role-root"}`}, {"PUT", "/api/admin/users/" + root.ID + "/system-grants", `{"expectedAuthorityEpoch":` + strconv.FormatInt(epoch, 10) + `,"idempotencyKey":"ops-grants-root","actions":[]}`}, @@ -217,7 +216,7 @@ func TestOrdinaryAdminHTTPCannotMutateSuperAdmin(t *testing.T) { for _, route := range []struct{ method, path, body string }{ {"PUT", "/api/admin/users/" + root.ID + "/state", `{"expectedStateVersion":` + version + `,"enabled":false}`}, {"PUT", "/api/admin/users/" + root.ID + "/admin", `{"expectedStateVersion":` + version + `,"isAdmin":false}`}, - {"POST", "/api/admin/users/admin/password", `{}`}, + {"POST", "/api/admin/users/admin/one-time-password", `{}`}, } { if rec := do(h, route.method, route.path, route.body, super); rec.Code != http.StatusForbidden { t.Errorf("super admin %s %s on itself: %d %s; want 403", route.method, route.path, rec.Code, rec.Body.String()) diff --git a/internal/server/test_store_helpers_test.go b/internal/server/test_store_helpers_test.go new file mode 100644 index 0000000..16513f6 --- /dev/null +++ b/internal/server/test_store_helpers_test.go @@ -0,0 +1,56 @@ +package server + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +// twinRecoveryStores keeps the historical helper name for tests that need two +// independent pools against one backing database. +func twinRecoveryStores(t *testing.T) (*store.Store, *store.Store) { + t.Helper() + dsn := os.Getenv("AGENT_PG_TEST_DSN") + if dsn == "" { + path := filepath.Join(t.TempDir(), "shared.db") + first, err := store.OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + second, err := store.OpenSQLite(path) + if err != nil { + first.Close() + t.Fatal(err) + } + t.Cleanup(func() { second.Close(); first.Close() }) + return first, second + } + admin, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil { + t.Fatal(err) + } + schema := "t_" + strings.ToLower(store.NewID("dual")) + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`); admin.Close() }) + isolate := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public") + first, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "a-artifacts")) + if err != nil { + t.Fatal(err) + } + second, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "b-artifacts")) + if err != nil { + first.Close() + t.Fatal(err) + } + t.Cleanup(func() { second.Close(); first.Close() }) + return first, second +} diff --git a/internal/store/account_state.go b/internal/store/account_state.go index 0d31b89..b25226d 100644 --- a/internal/store/account_state.go +++ b/internal/store/account_state.go @@ -104,9 +104,6 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, target.Owner, LoginKeyStatusUnused); err != nil { return Account{}, err } - if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), target.Owner); err != nil { - return Account{}, err - } } if err := s.bumpAccountDirectoryRevision(tx, true, false); err != nil { return Account{}, err diff --git a/internal/store/auth.go b/internal/store/auth.go index 8ab514c..922e262 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -461,6 +461,62 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex return token, tx.Commit() } +// CreateSessionVerifiedWithPasswordLoginCleanup creates a password session +// bound to expectVersion and atomically applies post-login OTP cleanup. +func (s *Store) CreateSessionVerifiedWithPasswordLoginCleanup(owner, scope string, ttl time.Duration, expectVersion int) (string, error) { + if scope == "" { + scope = SessionScopeFull + } + if ttl <= 0 { + ttl = sessionTTL + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := "sess_" + hex.EncodeToString(raw) + exp := time.Now().Add(ttl) + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } + var ver int + var enabled bool + var origin CredentialOrigin + versionQuery := s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + versionQuery += ` FOR UPDATE` + } + err = tx.QueryRow(versionQuery, owner).Scan(&ver, &enabled, &origin) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if !enabled || ver != expectVersion { + return "", ErrConflict + } + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin) + VALUES(?,?,?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver, AuthMethodPassword, origin); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND auth_method=? AND scope=?`), + owner, AuthMethodOneTimePassword, SessionScopeSetPasswordOnly); err != nil { + return "", err + } + return token, tx.Commit() +} + // SessionOwner 校验会话:命中且未过期返回 owner(尽力刷新 last_seen)。 func (s *Store) SessionOwner(token string) (string, bool) { owner, _, ok := s.SessionInfo(token) diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index a4c2213..b06705a 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -68,28 +68,30 @@ func (s *Store) SessionPasswordChangeMode(token string) (string, error) { if method == AuthMethodPassword || (method == AuthMethodLegacyUnknown && scope == SessionScopeFull) { return PasswordChangeModeCurrentRequired, nil } + if method == AuthMethodOneTimePassword && scope == SessionScopeSetPasswordOnly && grantID != "" { + var grantStatus string + var issuedVersion int + if err := s.db.QueryRow(s.q(`SELECT status,issued_auth_version FROM login_keys WHERE id=?`), grantID). + Scan(&grantStatus, &issuedVersion); err != nil { + return "", ErrAccountForbidden + } + if grantStatus != LoginKeyStatusConsumed || issuedVersion != userVersion { + return "", ErrAccountForbidden + } + return PasswordChangeModeOneTimePassword, nil + } if method == AuthMethodGrant && scope == SessionScopeSetPasswordOnly && grantID != "" && - (CredentialOrigin(origin) == CredentialOriginOwnerInitialization || CredentialOrigin(origin) == CredentialOriginOwnerRecovery) { - var grantStatus, grantExpiry, grantOrigin, proposalID string + CredentialOrigin(origin) == CredentialOriginOwnerInitialization { + var grantStatus, grantExpiry, grantOrigin string var issuedVersion int if err := s.db.QueryRow(s.q(`SELECT status,expires_at,grant_origin,proposal_id,issued_auth_version FROM login_keys WHERE id=?`), grantID). - Scan(&grantStatus, &grantExpiry, &grantOrigin, &proposalID, &issuedVersion); err != nil { + Scan(&grantStatus, &grantExpiry, &grantOrigin, new(string), &issuedVersion); err != nil { return "", ErrAccountForbidden } expiry, err := time.Parse(time.RFC3339Nano, grantExpiry) if err != nil || time.Now().After(expiry) || grantStatus != LoginKeyStatusConsumed || grantOrigin != origin || issuedVersion != userVersion { return "", ErrAccountForbidden } - if proposalID != "" { - var proposalStatus, proposalExpiry string - if err := s.db.QueryRow(s.q(`SELECT status,expires_at FROM recovery_proposals WHERE id=?`), proposalID).Scan(&proposalStatus, &proposalExpiry); err != nil { - return "", ErrAccountForbidden - } - expiry, err := time.Parse(time.RFC3339Nano, proposalExpiry) - if err != nil || time.Now().After(expiry) || proposalStatus != RecoveryProposalConfirmed { - return "", ErrAccountForbidden - } - } return PasswordChangeModeGrantSetup, nil } return "", ErrAccountForbidden @@ -370,10 +372,8 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if plain == "" || len(plain) < 5 || plain[:4] != "alk_" { return "", "", ErrNotFound } - // Resolve immutable routing fields before acquiring locks. The transaction - // locks user → proposal → grant, then rechecks the exact grant row. - var routeOwner, routeProposal string - err = s.db.QueryRow(s.q(`SELECT owner,proposal_id FROM login_keys WHERE key_hash=?`), hashKey(plain)).Scan(&routeOwner, &routeProposal) + var routeOwner string + err = s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE key_hash=?`), hashKey(plain)).Scan(&routeOwner) if errors.Is(err, sql.ErrNoRows) { return "", "", ErrNotFound } @@ -396,48 +396,29 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if !account.Enabled { return "", "", ErrAccountForbidden } - var proposalStatus, proposalOwner, proposalExp string - var proposalGeneration int64 - var proposalAuthVersion int - if routeProposal != "" { - proposalQuery := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`) - if s.Dialect() == "postgres" { - proposalQuery += ` FOR UPDATE` - } - if err := tx.QueryRow(proposalQuery, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExp, &proposalAuthVersion); err != nil { - return "", "", err - } - } - var id, o, status, exp, purpose, grantOrigin, proposalID string + var id, o, status, exp, purpose, grantOrigin string var generation int64 var issuedVersion int - grantQuery := s.q(`SELECT id,owner,status,expires_at,purpose,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE key_hash=?`) + grantQuery := s.q(`SELECT id,owner,status,expires_at,purpose,grant_origin,generation,issued_auth_version FROM login_keys WHERE key_hash=?`) if s.Dialect() == "postgres" { grantQuery += ` FOR UPDATE` } - err = tx.QueryRow(grantQuery, hashKey(plain)).Scan(&id, &o, &status, &exp, &purpose, &grantOrigin, &generation, &issuedVersion, &proposalID) + err = tx.QueryRow(grantQuery, hashKey(plain)).Scan(&id, &o, &status, &exp, &purpose, &grantOrigin, &generation, &issuedVersion) if errors.Is(err, sql.ErrNoRows) { return "", "", ErrNotFound } if err != nil { return "", "", err } - if o != routeOwner || proposalID != routeProposal || issuedVersion != account.AuthVersion { + if o != routeOwner || issuedVersion != account.AuthVersion { return "", "", ErrConflict } switch { case purpose == LoginKeyPurposeActivate && grantOrigin == string(CredentialOriginOwnerInitialization): - if proposalID != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { - return "", "", ErrAccountForbidden - } - case purpose == LoginKeyPurposeRecover && grantOrigin == string(CredentialOriginOwnerRecovery): - proposalExpiry, perr := time.Parse(time.RFC3339Nano, proposalExp) - if proposalID == "" || proposalOwner != o || proposalStatus != "confirmed" || proposalGeneration != generation || proposalAuthVersion != account.AuthVersion || - perr != nil || time.Now().After(proposalExpiry) { + if account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { return "", "", ErrAccountForbidden } default: - // Migrated administrator-issued keys have legacy_unknown provenance. return "", "", ErrAccountForbidden } expT, err := time.Parse(time.RFC3339Nano, exp) @@ -489,10 +470,6 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st if routeGrant == "" { return "", ErrAccountForbidden } - var routeProposal string - if err := s.db.QueryRow(s.q(`SELECT proposal_id FROM login_keys WHERE id=? AND owner=?`), routeGrant, owner).Scan(&routeProposal); err != nil { - return "", ErrAccountForbidden - } err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.grant_setup"}, func(tx *sql.Tx, _ string) error { accounts, err := s.lockAccountUsers(tx, owner) if err != nil { @@ -502,26 +479,14 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st if !account.Enabled { return ErrAccountForbidden } - var proposalStatus, proposalOwner, proposalExpiry string - var proposalGeneration int64 - var proposalVersion int - if routeProposal != "" { - query := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`) - if s.Dialect() == "postgres" { - query += ` FOR UPDATE` - } - if err := tx.QueryRow(query, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExpiry, &proposalVersion); err != nil { - return ErrAccountForbidden - } - } - grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE id=?`) + grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version FROM login_keys WHERE id=?`) if s.Dialect() == "postgres" { grantQuery += ` FOR UPDATE` } - var grantOwner, purpose, status, grantExpiry, origin, proposalID string + var grantOwner, purpose, status, grantExpiry, origin string var generation int64 var issuedVersion int - if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion, &proposalID); err != nil { + if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion); err != nil { return ErrAccountForbidden } sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`) @@ -540,18 +505,12 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st sessionExpires, err := time.Parse(time.RFC3339Nano, sessionExpiry) if err != nil || time.Now().After(sessionExpires) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || method != AuthMethodGrant || grantID != routeGrant || sessionVersion != account.AuthVersion || issuedVersion != account.AuthVersion || - grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin || proposalID != routeProposal { + grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin { return ErrAccountForbidden } switch { case purpose == LoginKeyPurposeActivate && origin == string(CredentialOriginOwnerInitialization): - if routeProposal != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { - return ErrAccountForbidden - } - case purpose == LoginKeyPurposeRecover && origin == string(CredentialOriginOwnerRecovery): - expires, perr := time.Parse(time.RFC3339Nano, proposalExpiry) - if routeProposal == "" || proposalOwner != owner || proposalStatus != "confirmed" || proposalGeneration != generation || - proposalVersion != account.AuthVersion || perr != nil || time.Now().After(expires) || !account.HasPassword { + if account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { return ErrAccountForbidden } default: @@ -573,14 +532,6 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return err } - if routeProposal != "" { - if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='completed',version=version+1 WHERE id=? AND status='confirmed'`), routeProposal); err != nil { - return err - } - } - if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND id<>? AND status IN ('pending','confirmed')`), owner, routeProposal); err != nil { - return err - } if err := s.insertAccountEvent(tx, owner, "trusted-grant", owner, "password_set", origin); err != nil { return err } @@ -647,9 +598,6 @@ func (s *Store) CompleteForcedPasswordCheckedWithFallback(token, current, passwo if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { return err } - if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { - return err - } if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return err } @@ -723,9 +671,6 @@ func (s *Store) ChangeUserPasswordActiveWithFallback(owner, current, passwordHas if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { return err } - if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { - return err - } if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil { return err } @@ -733,16 +678,13 @@ func (s *Store) ChangeUserPasswordActiveWithFallback(owner, current, passwordHas }) } -// AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、 -// bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。 +// AdminResetUserPassword 保留给历史内部调用路径;HTTP 管理面已改为一次性口令。 // The super-administrator account is never reset through this path (R5). func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error { return s.adminResetUserPassword(owner, passwordHash, actor, entrance, false) } -// AdminResetUserPasswordChecked is the account-management HTTP entry. The -// actor must still be an enabled administrator when re-read under the same -// owner-ordered locks as the target and the write (KTD3). +// AdminResetUserPasswordChecked 保留给历史内部调用路径。 func (s *Store) AdminResetUserPasswordChecked(actorOwner, owner, passwordHash string) error { return s.adminResetUserPassword(owner, passwordHash, actorOwner, "admin-http", true) } @@ -780,9 +722,6 @@ func (s *Store) adminResetUserPassword(owner, passwordHash, actor, entrance stri LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { return err } - if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { - return err - } if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return err } diff --git a/internal/store/one_time_password.go b/internal/store/one_time_password.go new file mode 100644 index 0000000..204514f --- /dev/null +++ b/internal/store/one_time_password.go @@ -0,0 +1,264 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "time" +) + +const ( + LoginKeyPurposeOneTimePassword = "one_time_password" + AuthMethodOneTimePassword = "one_time_password" + PasswordChangeModeOneTimePassword = "one_time_password" + oneTimePasswordTTL = 24 * time.Hour +) + +// ErrPasswordRequired marks endpoints that require a prepared hash. +var ErrPasswordRequired = errors.New("store: password hash required") + +// IssueOneTimePassword issues an administrator one-time login password without +// modifying the account's normal password. +func (s *Store) IssueOneTimePassword(actorOwner, owner, passwordHash string) (LoginKeyMeta, error) { + if passwordHash == "" { + return LoginKeyMeta{}, ErrPasswordRequired + } + if _, err := s.GetAccountByOwner(owner); err != nil { + return LoginKeyMeta{}, err + } + tx, err := s.db.Begin() + if err != nil { + return LoginKeyMeta{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner, owner) + if errors.Is(err, ErrNotFound) { + return LoginKeyMeta{}, ErrAccountForbidden + } + if err != nil { + return LoginKeyMeta{}, err + } + actor, target := locked[actorOwner], locked[owner] + if !actor.Enabled || !actor.IsAdmin || !target.Enabled { + return LoginKeyMeta{}, ErrAccountForbidden + } + if target.IsSuperAdmin { + return LoginKeyMeta{}, ErrAccountForbidden + } + if err := requireSuperAdminTargetAccess(actor, target); err != nil { + return LoginKeyMeta{}, err + } + initialization := !target.HasPassword + event, action := "one_time_password_issued", "account.one_time_password.issue" + if initialization { + event, action = "one_time_password_initialization_issued", "account.one_time_password.initialize" + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeActivate, LoginKeyStatusUnused); err != nil { + return LoginKeyMeta{}, err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused); err != nil { + return LoginKeyMeta{}, err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND auth_method=?`), owner, AuthMethodOneTimePassword); err != nil { + return LoginKeyMeta{}, err + } + now := time.Now().UTC() + meta := LoginKeyMeta{ + ID: newID("lk"), + Owner: owner, + Purpose: LoginKeyPurposeOneTimePassword, + Status: LoginKeyStatusUnused, + ExpiresAt: now.Add(oneTimePasswordTTL), + CreatedAt: now, + CreatedBy: actorOwner, + } + if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by,grant_origin,generation,issued_auth_version) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`), + meta.ID, owner, meta.Purpose, passwordHash, meta.Status, meta.ExpiresAt.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), + actorOwner, CredentialOriginAdminPassword, 0, target.AuthVersion); err != nil { + return LoginKeyMeta{}, err + } + if err := s.stampLoginKeyInstance(tx, meta.ID, ""); err != nil { + return LoginKeyMeta{}, err + } + if err := s.insertAccountEvent(tx, actorOwner, "admin-http", owner, event, meta.ID); err != nil { + return LoginKeyMeta{}, err + } + if err := s.auditAccountMutation(tx, owner, actorOwner, action); err != nil { + return LoginKeyMeta{}, err + } + if err := tx.Commit(); err != nil { + return LoginKeyMeta{}, err + } + return meta, nil +} + +// LoginWithOneTimePassword verifies and consumes an unused one-time password. +func (s *Store) LoginWithOneTimePassword(owner, password string) (string, error) { + if owner == "" || password == "" { + return "", ErrBadCredentials + } + rows, err := s.db.Query(s.q(`SELECT k.id,k.key_hash,k.expires_at FROM login_keys k JOIN users u ON u.owner=k.owner + WHERE k.owner=? AND k.purpose=? AND k.status=? AND u.enabled=?`), owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused, true) + if err != nil { + return "", err + } + type candidate struct{ id, hash, expires string } + var candidates []candidate + for rows.Next() { + var c candidate + if err := rows.Scan(&c.id, &c.hash, &c.expires); err != nil { + rows.Close() + return "", err + } + candidates = append(candidates, c) + } + if err := rows.Err(); err != nil { + rows.Close() + return "", err + } + rows.Close() + matched := "" + for _, c := range candidates { + expiry, err := time.Parse(time.RFC3339Nano, c.expires) + if err != nil || time.Now().After(expiry) { + continue + } + if VerifyPassword(c.hash, password) { + matched = c.id + break + } + } + if matched == "" { + return "", ErrBadCredentials + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := "sess_" + hex.EncodeToString(raw) + err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.one_time_password.login"}, func(tx *sql.Tx, _ string) error { + locked, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + account := locked[owner] + if !account.Enabled { + return ErrAccountForbidden + } + query := s.q(`SELECT owner,purpose,status,expires_at,issued_auth_version FROM login_keys WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var keyOwner, purpose, status, expires string + var issuedVersion int + if err := tx.QueryRow(query, matched).Scan(&keyOwner, &purpose, &status, &expires, &issuedVersion); err != nil { + return ErrConflict + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err != nil || time.Now().After(expiry) || keyOwner != owner || purpose != LoginKeyPurposeOneTimePassword || + status != LoginKeyStatusUnused || issuedVersion != account.AuthVersion { + return ErrConflict + } + res, err := tx.Exec(s.q(`UPDATE login_keys SET status=?,consumed_at=? WHERE id=? AND status=?`), + LoginKeyStatusConsumed, nowISO(), matched, LoginKeyStatusUnused) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin,grant_id) + VALUES(?,?,?,?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), time.Now().Add(restrictedSessionTTL).UTC().Format(time.RFC3339Nano), nowISO(), + SessionScopeSetPasswordOnly, account.AuthVersion, AuthMethodOneTimePassword, account.CredentialOrigin, matched); err != nil { + return err + } + return s.insertAccountEvent(tx, owner, "one-time-password", owner, "one_time_password_login", matched) + }) + if err != nil { + return "", err + } + return token, nil +} + +// CompleteOneTimePasswordChecked finalizes a one-time password restricted session. +func (s *Store) CompleteOneTimePasswordChecked(token, passwordHash string) (string, error) { + if passwordHash == "" { + return "", ErrPasswordRequired + } + var owner string + if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + err := s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.one_time_password"}, func(tx *sql.Tx, _ string) error { + locked, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + account := locked[owner] + if !account.Enabled { + return ErrAccountForbidden + } + sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,grant_id FROM sessions WHERE token_hash=?`) + grantQuery := s.q(`SELECT owner,purpose,status,issued_auth_version FROM login_keys WHERE id=?`) + if s.Dialect() == "postgres" { + sessionQuery += ` FOR UPDATE` + grantQuery += ` FOR UPDATE` + } + var sessionOwner, scope, expires, method, grantID string + var sessionVersion int + if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &expires, &sessionVersion, &method, &grantID); err != nil { + return ErrNotFound + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err != nil || time.Now().After(expiry) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || + method != AuthMethodOneTimePassword || sessionVersion != account.AuthVersion || grantID == "" { + return ErrAccountForbidden + } + var grantOwner, purpose, status string + var issuedVersion int + if err := tx.QueryRow(grantQuery, grantID).Scan(&grantOwner, &purpose, &status, &issuedVersion); err != nil { + return ErrAccountForbidden + } + if grantOwner != owner || purpose != LoginKeyPurposeOneTimePassword || status != LoginKeyStatusConsumed || issuedVersion != account.AuthVersion { + return ErrAccountForbidden + } + update := `UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1,credential_origin=? + WHERE owner=? AND auth_version=? AND enabled=?` + args := []any{passwordHash, nowISO(), false, CredentialOriginAdminPassword, owner, account.AuthVersion, true} + if !account.HasPassword { + update = `UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1,credential_origin=?, + initialization_state=?,state_version=state_version+1 WHERE owner=? AND auth_version=? AND enabled=? AND password_hash=''` + args = []any{passwordHash, nowISO(), false, CredentialOriginAdminPassword, InitializationInitialized, owner, account.AuthVersion, true} + } + res, err := tx.Exec(s.q(update), args...) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + return s.insertAccountEvent(tx, owner, "one-time-password", owner, "password_set", LoginKeyPurposeOneTimePassword+" "+grantID) + }) + return owner, err +} + +// InvalidateUnusedOneTimePasswords revokes outstanding unused one-time passwords. +func (s *Store) InvalidateUnusedOneTimePasswords(owner string) error { + _, err := s.db.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused) + return err +} diff --git a/internal/store/one_time_password_test.go b/internal/store/one_time_password_test.go new file mode 100644 index 0000000..8581f5f --- /dev/null +++ b/internal/store/one_time_password_test.go @@ -0,0 +1,139 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestIssueOneTimePasswordAndComplete(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "otp.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + hash, err := HashPassword("normal-pass-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("alice"); err != nil { + t.Fatal(err) + } + otpHash, err := HashPassword("otp-pass-1") + if err != nil { + t.Fatal(err) + } + meta, err := st.IssueOneTimePassword("admin", "alice", otpHash) + if err != nil || meta.ID == "" || meta.Purpose != LoginKeyPurposeOneTimePassword { + t.Fatalf("issue: %+v %v", meta, err) + } + tok, err := st.LoginWithOneTimePassword("alice", "otp-pass-1") + if err != nil || tok == "" { + t.Fatalf("otp login: %q %v", tok, err) + } + if _, err := st.LoginWithOneTimePassword("alice", "otp-pass-1"); err == nil { + t.Fatal("consumed otp reused") + } + newHash, err := HashPassword("new-pass-2") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteOneTimePasswordChecked(tok, newHash); err != nil { + t.Fatalf("complete otp: %v", err) + } + if _, _, ok := st.SessionInfo(tok); ok { + t.Fatal("otp session survived completion") + } + if _, ok := st.VerifyLogin("alice", "new-pass-2"); !ok { + t.Fatal("new password cannot login") + } + if _, ok := st.VerifyLogin("alice", "normal-pass-1"); ok { + t.Fatal("old password still valid") + } +} + +func TestOneTimePasswordNormalLoginInvalidatesUnused(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "otp_invalidate.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + hash, err := HashPassword("normal-pass-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("nora", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("nora"); err != nil { + t.Fatal(err) + } + otpHash, err := HashPassword("otp-pass-1") + if err != nil { + t.Fatal(err) + } + if _, err := st.IssueOneTimePassword("admin", "nora", otpHash); err != nil { + t.Fatal(err) + } + if _, _, ok := st.VerifyLoginWithVersion("nora", "normal-pass-1"); !ok { + t.Fatal("normal password no longer accepted") + } + if err := st.InvalidateUnusedOneTimePasswords("nora"); err != nil { + t.Fatal(err) + } + if _, err := st.LoginWithOneTimePassword("nora", "otp-pass-1"); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("otp remained valid: %v", err) + } +} + +func TestOneTimePasswordInitializesHistoricalPasswordlessAccount(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "otp_init.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy-empty", "", "", false); err != nil { + t.Fatal(err) + } + before, err := st.GetAccountByOwner("legacy-empty") + if err != nil || before.HasPassword || before.InitializationState != InitializationLegacy { + t.Fatalf("fixture: %+v %v", before, err) + } + otpHash, err := HashPassword("otp-pass-1") + if err != nil { + t.Fatal(err) + } + if _, err := st.IssueOneTimePassword("admin", "legacy-empty", otpHash); err != nil { + t.Fatal(err) + } + tok, err := st.LoginWithOneTimePassword("legacy-empty", "otp-pass-1") + if err != nil { + t.Fatal(err) + } + finalHash, err := HashPassword("final-pass-1") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteOneTimePasswordChecked(tok, finalHash); err != nil { + t.Fatal(err) + } + after, err := st.GetAccountByOwner("legacy-empty") + if err != nil || !after.HasPassword || after.InitializationState != InitializationInitialized { + t.Fatalf("account not initialized: %+v %v", after, err) + } + if _, ok := st.VerifyLogin("legacy-empty", "final-pass-1"); !ok { + t.Fatal("initialized account cannot login") + } +} diff --git a/internal/store/recovery_proposals.go b/internal/store/recovery_proposals.go deleted file mode 100644 index 3a0d012..0000000 --- a/internal/store/recovery_proposals.go +++ /dev/null @@ -1,456 +0,0 @@ -package store - -import ( - "crypto/rand" - "database/sql" - "encoding/hex" - "errors" - "fmt" - "strings" - "time" -) - -var ErrOwnerProofUnavailable = errors.New("store: no eligible preexisting owner proof") - -const ( - RecoveryProposalPending = "pending" - RecoveryProposalConfirmed = "confirmed" - RecoveryProposalCompleted = "completed" - RecoveryProposalRevoked = "revoked" - RecoveryProposalExpired = "expired" - recoveryProposalTTL = 24 * time.Hour - recoveryGrantTTL = 5 * time.Minute -) - -// RecoveryProposal is metadata only. No proof-session digest or grant material -// is included in the admin or owner read projection. -type RecoveryProposal struct { - ID string `json:"id"` - UserID string `json:"userId"` - Owner string `json:"owner"` - Status string `json:"status"` - Version int64 `json:"version"` - Generation int64 `json:"generation"` - CreatedBy string `json:"createdBy"` - CreatedAt time.Time `json:"createdAt"` - ExpiresAt time.Time `json:"expiresAt"` - AuthVersion int `json:"-"` -} - -func (s *Store) recoveryProposal(q accountQuerier, id string, lock bool) (RecoveryProposal, error) { - query := s.q(`SELECT id,user_id,owner,status,version,generation,created_by,created_at,expires_at,issued_auth_version - FROM recovery_proposals WHERE id=?`) - if lock && s.Dialect() == "postgres" { - query += ` FOR UPDATE` - } - var p RecoveryProposal - var created, expires string - err := q.QueryRow(query, id).Scan(&p.ID, &p.UserID, &p.Owner, &p.Status, &p.Version, &p.Generation, - &p.CreatedBy, &created, &expires, &p.AuthVersion) - if errors.Is(err, sql.ErrNoRows) { - return p, ErrNotFound - } - if err != nil { - return p, err - } - p.CreatedAt, err = time.Parse(time.RFC3339Nano, created) - if err != nil { - return p, err - } - p.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires) - if err != nil { - return p, err - } - return p, nil -} - -func newRecoveryGrant() (plain, id string, err error) { - var raw [32]byte - if _, err = rand.Read(raw[:]); err != nil { - return "", "", err - } - return "alk_" + hex.EncodeToString(raw[:]), newID("lk"), nil -} - -// CreateRecoveryProposal records precisely the currently eligible full -// password sessions. Later logins are not eligible proof for this proposal. -func (s *Store) CreateRecoveryProposal(actorOwner, userID string, expectedStateVersion int64, idempotencyKey string) (RecoveryProposal, error) { - if actorOwner == "" || userID == "" || expectedStateVersion < 1 || idempotencyKey == "" || len(idempotencyKey) > 128 || strings.TrimSpace(idempotencyKey) != idempotencyKey { - return RecoveryProposal{}, ErrConflict - } - pre, err := s.GetAccount(userID) - if err != nil { - return RecoveryProposal{}, err - } - tx, err := s.db.Begin() - if err != nil { - return RecoveryProposal{}, err - } - defer tx.Rollback() - locked, err := s.lockAccountUsers(tx, actorOwner, pre.Owner) - if errors.Is(err, ErrNotFound) { - return RecoveryProposal{}, ErrAccountForbidden - } - if err != nil { - return RecoveryProposal{}, err - } - actor, target := locked[actorOwner], locked[pre.Owner] - if !actor.Enabled || !actor.IsAdmin || !target.Enabled { - return RecoveryProposal{}, ErrAccountForbidden - } - if err := requireSuperAdminTargetAccess(actor, target); err != nil { - return RecoveryProposal{}, err - } - if target.ID != userID || target.StateVersion != expectedStateVersion { - return RecoveryProposal{}, ErrConflict - } - var existingID string - err = tx.QueryRow(s.q(`SELECT id FROM recovery_proposals WHERE created_by=? AND idempotency_key=?`), actorOwner, idempotencyKey).Scan(&existingID) - if err == nil { - existing, err := s.recoveryProposal(tx, existingID, false) - if err != nil || existing.UserID != userID { - return RecoveryProposal{}, ErrConflict - } - return existing, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return RecoveryProposal{}, err - } - now := time.Now().UTC() - active, err := tx.Query(s.q(`SELECT id,expires_at FROM recovery_proposals WHERE owner=? AND status IN (?,?)`), target.Owner, RecoveryProposalPending, RecoveryProposalConfirmed) - if err != nil { - return RecoveryProposal{}, err - } - var expiredIDs []string - for active.Next() { - var id, expires string - if err := active.Scan(&id, &expires); err != nil { - active.Close() - return RecoveryProposal{}, err - } - expiry, err := time.Parse(time.RFC3339Nano, expires) - if err != nil || now.Before(expiry) { - active.Close() - return RecoveryProposal{}, ErrConflict - } - expiredIDs = append(expiredIDs, id) - } - if err := active.Err(); err != nil { - active.Close() - return RecoveryProposal{}, err - } - active.Close() - for _, id := range expiredIDs { - if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status=?,version=version+1 WHERE id=?`), RecoveryProposalExpired, id); err != nil { - return RecoveryProposal{}, err - } - } - if !target.CredentialOrigin.TrustedForOwnerProof() || !target.HasPassword || target.MustChangePassword { - return RecoveryProposal{}, ErrOwnerProofUnavailable - } - rows, err := tx.Query(s.q(`SELECT token_hash,expires_at,credential_origin FROM sessions WHERE owner=? AND scope=? AND auth_method=? AND auth_version=?`), - target.Owner, SessionScopeFull, AuthMethodPassword, target.AuthVersion) - if err != nil { - return RecoveryProposal{}, err - } - var proofs []string - for rows.Next() { - var digest, expires, origin string - if err := rows.Scan(&digest, &expires, &origin); err != nil { - rows.Close() - return RecoveryProposal{}, err - } - expiry, err := time.Parse(time.RFC3339Nano, expires) - if err == nil && now.Before(expiry) && CredentialOrigin(origin) == target.CredentialOrigin { - proofs = append(proofs, digest) - } - } - if err := rows.Err(); err != nil { - rows.Close() - return RecoveryProposal{}, err - } - rows.Close() - if len(proofs) == 0 { - return RecoveryProposal{}, ErrOwnerProofUnavailable - } - p := RecoveryProposal{ID: newID("rp"), UserID: target.ID, Owner: target.Owner, Status: RecoveryProposalPending, - Version: 1, Generation: 0, CreatedBy: actorOwner, CreatedAt: now, ExpiresAt: now.Add(recoveryProposalTTL), AuthVersion: target.AuthVersion} - if _, err := tx.Exec(s.q(`INSERT INTO recovery_proposals(id,user_id,owner,status,version,generation,created_by,created_at,expires_at,issued_auth_version,idempotency_key) - VALUES(?,?,?,?,?,?,?,?,?,?,?)`), p.ID, p.UserID, p.Owner, p.Status, p.Version, p.Generation, p.CreatedBy, - p.CreatedAt.Format(time.RFC3339Nano), p.ExpiresAt.Format(time.RFC3339Nano), p.AuthVersion, idempotencyKey); err != nil { - if isUniqueViolation(err) { - return RecoveryProposal{}, ErrConflict - } - return RecoveryProposal{}, err - } - for _, digest := range proofs { - if _, err := tx.Exec(s.q(`INSERT INTO recovery_proposal_proofs(proposal_id,session_hash) VALUES(?,?)`), p.ID, digest); err != nil { - return RecoveryProposal{}, err - } - } - if err := s.insertAccountEvent(tx, actorOwner, "admin-http", target.Owner, "recovery_proposed", p.ID); err != nil { - return RecoveryProposal{}, err - } - if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.recovery.propose"); err != nil { - return RecoveryProposal{}, err - } - return p, tx.Commit() -} - -func (s *Store) eligibleRecoveryProof(tx *sql.Tx, account Account, proposal RecoveryProposal, token string) error { - query := s.q(`SELECT owner,scope,auth_method,credential_origin,auth_version,expires_at FROM sessions WHERE token_hash=?`) - if s.Dialect() == "postgres" { - query += ` FOR UPDATE` - } - var owner, scope, method, origin, expires string - var version int - err := tx.QueryRow(query, hashKey(token)).Scan(&owner, &scope, &method, &origin, &version, &expires) - if err != nil { - return ErrOwnerProofUnavailable - } - expiry, err := time.Parse(time.RFC3339Nano, expires) - if err != nil || time.Now().After(expiry) || owner != account.Owner || scope != SessionScopeFull || method != AuthMethodPassword || - CredentialOrigin(origin) != account.CredentialOrigin || !CredentialOrigin(origin).TrustedForOwnerProof() || version != account.AuthVersion { - return ErrOwnerProofUnavailable - } - var bound int - if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM recovery_proposal_proofs WHERE proposal_id=? AND session_hash=?`), proposal.ID, hashKey(token)).Scan(&bound); err != nil || bound != 1 { - return ErrOwnerProofUnavailable - } - return nil -} - -func (s *Store) issueConfirmedRecoveryGrant(tx *sql.Tx, p RecoveryProposal, account Account, plain, id string, now time.Time) error { - _, err := s.insertLoginKeyTx(tx, p.Owner, LoginKeyPurposeRecover, "owner-proof:"+p.Owner, plain, id, now, now.Add(recoveryGrantTTL), - grantBinding{origin: CredentialOriginOwnerRecovery, generation: p.Generation, version: account.AuthVersion, proposalID: p.ID}) - return err -} - -func (s *Store) confirmOrRotateRecovery(token, proposalID string, expectedVersion, expectedGeneration int64, rotate bool) (string, RecoveryProposal, error) { - pre, err := s.recoveryProposal(s.db, proposalID, false) - if err != nil { - return "", RecoveryProposal{}, err - } - tx, err := s.db.Begin() - if err != nil { - return "", RecoveryProposal{}, err - } - defer tx.Rollback() - locked, err := s.lockAccountUsers(tx, pre.Owner) - if err != nil { - return "", RecoveryProposal{}, err - } - account := locked[pre.Owner] - p, err := s.recoveryProposal(tx, proposalID, true) - if err != nil { - return "", RecoveryProposal{}, err - } - if !account.Enabled || account.ID != p.UserID || account.AuthVersion != p.AuthVersion || account.MustChangePassword || !account.CredentialOrigin.TrustedForOwnerProof() || - 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 - res, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status=?,version=version+1,generation=? WHERE id=? AND version=? AND generation=? AND status=?`), - newStatus, newGeneration, p.ID, p.Version, p.Generation, p.Status) - if err != nil { - return "", RecoveryProposal{}, err - } - if n, err := res.RowsAffected(); err != nil || n != 1 { - return "", RecoveryProposal{}, ErrConflict - } - p.Version++ - p.Generation = newGeneration - p.Status = newStatus - if err := s.issueConfirmedRecoveryGrant(tx, p, account, plain, id, now); err != nil { - return "", RecoveryProposal{}, err - } - action := "recovery_confirmed" - if rotate { - action = "recovery_grant_rotated" - } - if err := s.insertAccountEvent(tx, p.Owner, "owner-http", p.Owner, action, fmt.Sprintf("proposal=%s generation=%d", p.ID, p.Generation)); err != nil { - return "", RecoveryProposal{}, err - } - if err := s.auditAccountMutation(tx, p.Owner, p.Owner, "account.recovery."+action); err != nil { - return "", RecoveryProposal{}, err - } - if err := tx.Commit(); err != nil { - return "", RecoveryProposal{}, err - } - return plain, p, nil -} - -func (s *Store) ConfirmRecoveryProposal(token, proposalID string, expectedVersion int64) (string, RecoveryProposal, error) { - return s.confirmOrRotateRecovery(token, proposalID, expectedVersion, 0, false) -} - -func (s *Store) RotateRecoveryGrant(token, proposalID string, expectedVersion, expectedGeneration int64) (string, RecoveryProposal, error) { - return s.confirmOrRotateRecovery(token, proposalID, expectedVersion, expectedGeneration, true) -} - -func effectiveRecoveryProposal(p RecoveryProposal) RecoveryProposal { - if (p.Status == RecoveryProposalPending || p.Status == RecoveryProposalConfirmed) && time.Now().After(p.ExpiresAt) { - p.Status = RecoveryProposalExpired - } - return p -} - -// ListOwnerRecoveryProposals returns only non-secret metadata. The HTTP layer -// obtains owner from a live full password session, never a request parameter. -func (s *Store) ListOwnerRecoveryProposals(owner string) ([]RecoveryProposal, error) { - rows, err := s.db.Query(s.q(`SELECT id FROM recovery_proposals WHERE owner=? ORDER BY created_at DESC LIMIT 50`), owner) - if err != nil { - return nil, err - } - ids := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return nil, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return nil, err - } - rows.Close() - proposals := make([]RecoveryProposal, 0, len(ids)) - for _, id := range ids { - p, err := s.recoveryProposal(s.db, id, false) - if err != nil { - return nil, err - } - proposals = append(proposals, effectiveRecoveryProposal(p)) - } - return proposals, nil -} - -func (s *Store) GetRecoveryProposalForAdmin(actorOwner, userID, proposalID string) (RecoveryProposal, error) { - actor, err := s.GetAccountByOwner(actorOwner) - if err != nil || !actor.Enabled || !actor.IsAdmin { - return RecoveryProposal{}, ErrAccountForbidden - } - p, err := s.recoveryProposal(s.db, proposalID, false) - if err != nil { - return RecoveryProposal{}, err - } - if p.UserID != userID { - return RecoveryProposal{}, ErrNotFound - } - return effectiveRecoveryProposal(p), nil -} - -func (s *Store) ListRecoveryProposalsForAdmin(actorOwner, userID string) ([]RecoveryProposal, error) { - actor, err := s.GetAccountByOwner(actorOwner) - if err != nil || !actor.Enabled || !actor.IsAdmin { - return nil, ErrAccountForbidden - } - target, err := s.GetAccount(userID) - if err != nil { - return nil, err - } - proposals, err := s.ListOwnerRecoveryProposals(target.Owner) - if err != nil { - return nil, err - } - filtered := proposals[:0] - for _, proposal := range proposals { - if proposal.UserID == userID { - filtered = append(filtered, proposal) - } - } - return filtered, nil -} - -func (s *Store) GetOwnerRecoveryProposal(owner, proposalID string) (RecoveryProposal, error) { - p, err := s.recoveryProposal(s.db, proposalID, false) - if err != nil { - return RecoveryProposal{}, err - } - if p.Owner != owner { - return RecoveryProposal{}, ErrNotFound - } - return effectiveRecoveryProposal(p), nil -} - -// RevokeRecoveryProposal checks the path target, actor and expected version in -// the same transaction as revoking grant handles and derived restricted sessions. -func (s *Store) RevokeRecoveryProposal(actorOwner, userID, proposalID string, expectedVersion int64) (RecoveryProposal, error) { - pre, err := s.recoveryProposal(s.db, proposalID, false) - if err != nil { - return RecoveryProposal{}, err - } - if pre.UserID != userID || expectedVersion < 1 { - return RecoveryProposal{}, ErrNotFound - } - tx, err := s.db.Begin() - if err != nil { - return RecoveryProposal{}, err - } - defer tx.Rollback() - locked, err := s.lockAccountUsers(tx, actorOwner, pre.Owner) - if errors.Is(err, ErrNotFound) { - return RecoveryProposal{}, ErrAccountForbidden - } - if err != nil { - return RecoveryProposal{}, err - } - actor := locked[actorOwner] - if !actor.Enabled || !actor.IsAdmin { - return RecoveryProposal{}, ErrAccountForbidden - } - p, err := s.recoveryProposal(tx, proposalID, true) - if err != nil { - return RecoveryProposal{}, err - } - if p.UserID != userID { - return RecoveryProposal{}, ErrNotFound - } - if p.Version != expectedVersion || (p.Status != RecoveryProposalPending && p.Status != RecoveryProposalConfirmed) { - return RecoveryProposal{}, ErrConflict - } - if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE grant_id IN (SELECT id FROM login_keys WHERE proposal_id=?)`), p.ID); err != nil { - return RecoveryProposal{}, err - } - if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE proposal_id=? AND status=?`), LoginKeyStatusRevoked, p.ID, LoginKeyStatusUnused); err != nil { - return RecoveryProposal{}, err - } - res, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status=?,version=version+1 WHERE id=? AND version=?`), RecoveryProposalRevoked, p.ID, expectedVersion) - if err != nil { - return RecoveryProposal{}, err - } - if n, err := res.RowsAffected(); err != nil || n != 1 { - return RecoveryProposal{}, ErrConflict - } - if err := s.insertAccountEvent(tx, actorOwner, "admin-http", p.Owner, "recovery_revoked", p.ID); err != nil { - return RecoveryProposal{}, err - } - if err := s.auditAccountMutation(tx, p.Owner, actorOwner, "account.recovery.revoke"); err != nil { - return RecoveryProposal{}, err - } - if err := tx.Commit(); err != nil { - return RecoveryProposal{}, err - } - p.Status, p.Version = RecoveryProposalRevoked, p.Version+1 - return p, nil -} diff --git a/internal/store/recovery_proposals_test.go b/internal/store/recovery_proposals_test.go deleted file mode 100644 index db21961..0000000 --- a/internal/store/recovery_proposals_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package store - -import ( - "errors" - "sync" - "testing" - "time" -) - -func trustedRecoveryFixture(t *testing.T, st *Store) (Account, string) { - t.Helper() - if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { - t.Fatal(err) - } - account, err := st.CreatePendingAccount("admin", "alice", AccountProfile{}) - if err != nil { - t.Fatal(err) - } - activation, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) - if err != nil { - t.Fatal(err) - } - setup, _, err := st.RedeemLoginKey(activation) - if err != nil { - t.Fatal(err) - } - hash, err := HashPassword("initial-owner-password") - if err != nil { - t.Fatal(err) - } - if _, err := st.CompleteSetPasswordChecked(setup, hash); err != nil { - t.Fatal(err) - } - account, err = st.GetAccount(account.ID) - if err != nil { - t.Fatal(err) - } - _, version, valid := st.VerifyLoginWithVersion("alice", "initial-owner-password") - if !valid { - t.Fatal("trusted owner could not log in") - } - session, err := st.CreateSessionVerified("alice", SessionScopeFull, time.Hour, version) - if err != nil { - t.Fatal(err) - } - return account, session -} - -func TestOwnerRecoveryConcurrentConfirmAndDisableAcrossPools(t *testing.T) { - st := openTestStore(t) - other := reopenAccountModelStore(t, st) - t.Logf("owner recovery concurrency dialect=%s two independent pools", st.Dialect()) - account, proof := trustedRecoveryFixture(t, st) - proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "race-1") - if err != nil { - t.Fatal(err) - } - type result struct { - grant string - err error - } - start := make(chan struct{}) - confirmed := make(chan result, 1) - disabled := make(chan error, 1) - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - <-start - grant, _, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) - confirmed <- result{grant: grant, err: err} - }() - go func() { - defer wg.Done() - <-start - _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) - disabled <- err - }() - close(start) - wg.Wait() - if err := <-disabled; err != nil { - t.Fatalf("disable lost race: %v", err) - } - out := <-confirmed - if out.err == nil && out.grant == "" { - t.Fatal("confirm succeeded without a grant") - } - current, err := st.GetAccount(account.ID) - if err != nil || current.Enabled { - t.Fatalf("target remained effective after disable: %+v %v", current, err) - } - if out.grant != "" { - if _, _, err := other.RedeemLoginKey(out.grant); err == nil { - t.Fatal("grant survived concurrent disable") - } - } -} - -func TestOwnerRecoveryConcurrentConfirmHasOneWinnerAcrossPools(t *testing.T) { - st := openTestStore(t) - other := reopenAccountModelStore(t, st) - t.Logf("owner confirmation dialect=%s two independent pools", st.Dialect()) - account, proof := trustedRecoveryFixture(t, st) - proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "race-2") - if err != nil { - t.Fatal(err) - } - start := make(chan struct{}) - 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 - grant, _, err := conn.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) - results <- result{grant, err} - }(conn) - } - close(start) - wg.Wait() - close(results) - wins, metadataOnly, conflicts := 0, 0, 0 - for outcome := range results { - if outcome.err == nil && outcome.grant != "" { - wins++ - } else if outcome.err == nil && outcome.grant == "" { - metadataOnly++ - } else if errors.Is(outcome.err, ErrConflict) { - conflicts++ - } else { - t.Fatal(outcome.err) - } - } - if wins != 1 || metadataOnly+conflicts != 1 { - t.Fatalf("concurrent confirmation plaintext winners=%d metadata-only=%d conflicts=%d", wins, metadataOnly, conflicts) - } -} - -func TestOwnerProofRecoveryBindsPreexistingSessionAndRotatesGrant(t *testing.T) { - st := openTestStore(t) - t.Logf("owner recovery dialect=%s", st.Dialect()) - account, proof := trustedRecoveryFixture(t, st) - proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "request-1") - if err != nil || proposal.Status != RecoveryProposalPending || proposal.ID == "" { - t.Fatalf("proposal: %+v %v", proposal, err) - } - _, version, _ := st.VerifyLoginWithVersion("alice", "initial-owner-password") - lateSession, err := st.CreateSessionVerified("alice", SessionScopeFull, time.Hour, version) - if err != nil { - t.Fatal(err) - } - if _, _, err := st.ConfirmRecoveryProposal(lateSession, proposal.ID, proposal.Version); !errors.Is(err, ErrOwnerProofUnavailable) { - t.Fatalf("post-proposal session became proof: %v", err) - } - first, confirmed, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) - 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") - } - restricted, owner, err := st.RedeemLoginKey(second) - if err != nil || owner != "alice" { - t.Fatalf("redeem owner recovery: %v %q", err, owner) - } - newHash, err := HashPassword("recovered-owner-password") - if err != nil { - t.Fatal(err) - } - if _, err := st.CompleteSetPasswordChecked(restricted, newHash); err != nil { - t.Fatalf("finish recovery: %v", err) - } - current, err := st.GetAccount(account.ID) - if err != nil || current.ID != account.ID || current.CredentialOrigin != CredentialOriginOwnerRecovery { - t.Fatalf("recovery identity or lineage changed: %+v %v", current, err) - } - if _, ok := st.VerifyLogin("alice", "recovered-owner-password"); !ok { - t.Fatal("recovered password login failed") - } - if _, _, ok := st.SessionInfo(proof); ok { - t.Fatal("old proof session survived recovery") - } - if _, _, err := st.ConfirmRecoveryProposal(proof, proposal.ID, rotated.Version); err == nil { - t.Fatal("completed proposal confirmed twice") - } -} - -func TestLegacyLineageHasNoOnlineOwnerProof(t *testing.T) { - st := openTestStore(t) - if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { - t.Fatal(err) - } - legacy, err := HashPassword("legacy-password-1") - if err != nil { - t.Fatal(err) - } - if err := st.CreateAccount("legacy", "", legacy, false); err != nil { - t.Fatal(err) - } - if err := st.ClearMustChangePassword("legacy"); err != nil { - t.Fatal(err) - } - account, err := st.GetAccountByOwner("legacy") - if err != nil { - t.Fatal(err) - } - _, version, ok := st.VerifyLoginWithVersion("legacy", "legacy-password-1") - if !ok { - t.Fatal("legacy password login should remain usable") - } - if _, err := st.CreateSessionVerified("legacy", SessionScopeFull, time.Hour, version); err != nil { - t.Fatal(err) - } - if _, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "legacy-attempt"); !errors.Is(err, ErrOwnerProofUnavailable) { - t.Fatalf("legacy password session became proof: %v", err) - } -} diff --git a/internal/store/super_admin_authority_test.go b/internal/store/super_admin_authority_test.go index 7aa5ab3..78abdab 100644 --- a/internal/store/super_admin_authority_test.go +++ b/internal/store/super_admin_authority_test.go @@ -223,8 +223,12 @@ func TestOrdinaryAdminCannotMutateSuperAdmin(t *testing.T) { if _, _, err := st.IssueLoginKey("root", LoginKeyPurposeRecover, "cli", time.Minute); !errors.Is(err, ErrAccountForbidden) { t.Fatalf("login key issued for the super admin: %v", err) } - if _, err := st.CreateRecoveryProposal("ops", root.ID, root.StateVersion, "ops-recover-root"); !errors.Is(err, ErrAccountForbidden) { - t.Fatalf("ordinary admin opened recovery for the super admin: %v", err) + otpHash, err := HashPassword("otp-super-admin-blocked") + if err != nil { + t.Fatal(err) + } + if _, err := st.IssueOneTimePassword("ops", "root", otpHash); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin issued one-time password for the super admin: %v", err) } if _, err := st.InitializePendingAccountWithAudit(root.ID, root.StateVersion, root.InitializationGeneration, ""); !errors.Is(err, ErrAccountForbidden) { t.Fatalf("CLI initialization targeted the super admin: %v", err) diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go index 81a238f..3b00cdb 100644 --- a/pkg/identity/identity_test.go +++ b/pkg/identity/identity_test.go @@ -137,6 +137,9 @@ func TestDSNFileMode(t *testing.T) { if err := os.WriteFile(good, []byte("postgres://example\n"), 0o600); err != nil { t.Fatal(err) } + if err := os.Chmod(good, 0o600); err != nil { + t.Fatal(err) + } got, err := ResolveDSN("", good) if err != nil || got != "postgres://example" { t.Fatalf("0600 文件:%q %v", got, err) @@ -145,6 +148,9 @@ func TestDSNFileMode(t *testing.T) { if err := os.WriteFile(loose, []byte("postgres://x"), 0o644); err != nil { t.Fatal(err) } + if err := os.Chmod(loose, 0o644); err != nil { + t.Fatal(err) + } if _, err := ResolveDSN("", loose); err == nil { t.Fatal("0644 的 dsn 文件必须拒绝") }