168 lines
7.9 KiB
Go
168 lines
7.9 KiB
Go
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")
|
|
}
|
|
}
|