234 lines
8.3 KiB
Go
234 lines
8.3 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-system-backend/internal/store"
|
|
)
|
|
|
|
const envSuperAdminPassword = "Env-Fallback-Pass-1"
|
|
|
|
func newSuperAdminServer(t *testing.T) (http.Handler, *Server, *store.Store) {
|
|
t.Helper()
|
|
srv, st := newAuthTestServer(t)
|
|
hash, err := store.HashPassword(envSuperAdminPassword)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.BootstrapSuperAdmin("root", hash); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.Auth.SetSuperAdminFallback("root", envSuperAdminPassword)
|
|
for _, owner := range []string{"root", "alice", "legacy-admin", "ghost"} {
|
|
loginClear(owner)
|
|
}
|
|
t.Cleanup(func() {
|
|
for _, owner := range []string{"root", "alice", "legacy-admin", "ghost"} {
|
|
loginClear(owner)
|
|
}
|
|
})
|
|
return srv.Handler(), srv, st
|
|
}
|
|
|
|
func postLogin(h http.Handler, owner, password string) *httptest.ResponseRecorder {
|
|
body, _ := json.Marshal(map[string]string{"owner": owner, "password": password})
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, reqWithCookie("POST", "/api/auth/login", string(body), ""))
|
|
return rec
|
|
}
|
|
|
|
func postPasswordChange(h http.Handler, cookie, current, next string) *httptest.ResponseRecorder {
|
|
body, _ := json.Marshal(map[string]string{"current": current, "next": next})
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, reqWithCookie("POST", "/api/auth/password", string(body), cookie))
|
|
return rec
|
|
}
|
|
|
|
func mustLogin(t *testing.T, h http.Handler, owner, password string) string {
|
|
t.Helper()
|
|
rec := postLogin(h, owner, password)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("login %s: %d %s", owner, rec.Code, rec.Body.String())
|
|
}
|
|
if strings.Contains(rec.Body.String(), envSuperAdminPassword) {
|
|
t.Fatal("login response leaked the environment password")
|
|
}
|
|
var session *http.Cookie
|
|
for _, ck := range rec.Result().Cookies() {
|
|
if ck.Name == SessionCookie {
|
|
session = ck
|
|
}
|
|
}
|
|
if session == nil || session.Value == "" || !session.HttpOnly {
|
|
t.Fatalf("login must issue an HttpOnly session cookie: %+v", session)
|
|
}
|
|
return session.Value
|
|
}
|
|
|
|
func TestSuperAdminEnvironmentPasswordLogin(t *testing.T) {
|
|
h, _, _ := newSuperAdminServer(t)
|
|
rec := postLogin(h, "root", envSuperAdminPassword)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("initial environment password login: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out["owner"] != "root" || out["sessionScope"] != store.SessionScopeFull || out["isAdmin"] != true {
|
|
t.Fatalf("unexpected login projection: %v", out)
|
|
}
|
|
mustLogin(t, h, "root", envSuperAdminPassword)
|
|
}
|
|
|
|
func TestSuperAdminPasswordChangeKeepsFallback(t *testing.T) {
|
|
h, _, _ := newSuperAdminServer(t)
|
|
cookie := mustLogin(t, h, "root", envSuperAdminPassword)
|
|
if rec := postPasswordChange(h, cookie, envSuperAdminPassword, "db-password-1"); rec.Code != http.StatusOK {
|
|
t.Fatalf("change with environment password as current: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := postPasswordChange(h, cookie, "db-password-1", "db-password-2"); rec.Code != http.StatusOK {
|
|
t.Fatalf("change with database password as current: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := postLogin(h, "root", "db-password-1"); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("old database password must be rejected: %d", rec.Code)
|
|
}
|
|
mustLogin(t, h, "root", "db-password-2")
|
|
fallbackCookie := mustLogin(t, h, "root", envSuperAdminPassword)
|
|
|
|
// The fallback may also be presented as the current password.
|
|
if rec := postPasswordChange(h, fallbackCookie, envSuperAdminPassword, "db-password-3"); rec.Code != http.StatusOK {
|
|
t.Fatalf("fallback as current password: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := postLogin(h, "root", "db-password-2"); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("replaced database password must be rejected: %d", rec.Code)
|
|
}
|
|
mustLogin(t, h, "root", "db-password-3")
|
|
mustLogin(t, h, "root", envSuperAdminPassword)
|
|
}
|
|
|
|
func TestSuperAdminFallbackSessionBoundToAuthVersion(t *testing.T) {
|
|
h, _, _ := newSuperAdminServer(t)
|
|
fallbackCookie := mustLogin(t, h, "root", envSuperAdminPassword)
|
|
changer := mustLogin(t, h, "root", envSuperAdminPassword)
|
|
if rec := postPasswordChange(h, changer, envSuperAdminPassword, "db-password-1"); rec.Code != http.StatusOK {
|
|
t.Fatalf("change: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, reqWithCookie("GET", "/api/me", "", fallbackCookie))
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("fallback session must be revoked when the auth version advances: %d", rec.Code)
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, reqWithCookie("GET", "/api/me", "", changer))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("changing session must survive: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSuperAdminFallbackRejectedForOtherOwners(t *testing.T) {
|
|
h, srv, st := newSuperAdminServer(t)
|
|
for _, acct := range []struct {
|
|
owner string
|
|
admin bool
|
|
}{{"alice", false}, {"legacy-admin", true}} {
|
|
hash, err := store.HashPassword("own-password-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount(acct.owner, "", hash, acct.admin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword(acct.owner); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, owner := range []string{"alice", "legacy-admin", "ghost"} {
|
|
if rec := postLogin(h, owner, envSuperAdminPassword); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("fallback accepted for %q: %d", owner, rec.Code)
|
|
}
|
|
}
|
|
// Even a fallback configured for an unmarked owner never authenticates it.
|
|
srv.Auth.SetSuperAdminFallback("alice", envSuperAdminPassword)
|
|
if rec := postLogin(h, "alice", envSuperAdminPassword); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("fallback accepted for unmarked configured owner: %d", rec.Code)
|
|
}
|
|
if rec := postLogin(h, "root", envSuperAdminPassword); rec.Code != http.StatusOK {
|
|
t.Fatalf("root database password must still work: %d", rec.Code)
|
|
}
|
|
cookie := mustLogin(t, h, "alice", "own-password-1")
|
|
if rec := postPasswordChange(h, cookie, envSuperAdminPassword, "own-password-2"); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("fallback accepted as current password for ordinary account: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSuperAdminFallbackRejectedWhenDisabled(t *testing.T) {
|
|
h, _, st := newSuperAdminServer(t)
|
|
hash, err := store.HashPassword("ops-password-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount("ops", "", hash, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword("ops"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root, err := st.GetAccountByOwner("root")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.SetAccountEnabled("ops", root.ID, root.StateVersion, false); !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("ordinary admin disabled the super admin: %v", err)
|
|
}
|
|
if _, err := st.DB().Exec(`UPDATE users SET enabled=? WHERE owner=?`, false, "root"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := postLogin(h, "root", envSuperAdminPassword); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("disabled super admin authenticated with the fallback: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSuperAdminFallbackRespectsLoginThrottle(t *testing.T) {
|
|
h, _, _ := newSuperAdminServer(t)
|
|
for i := 0; i < loginMaxFails; i++ {
|
|
if rec := postLogin(h, "root", "wrong-password-x"); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d: %d", i, rec.Code)
|
|
}
|
|
}
|
|
if rec := postLogin(h, "root", envSuperAdminPassword); rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("throttled fallback attempt must be rejected: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSuperAdminFallbackDisabledWithoutAuth(t *testing.T) {
|
|
a := NewAuthenticator(nil, false)
|
|
a.SetSuperAdminFallback("root", envSuperAdminPassword)
|
|
if a.fallbackCredential() != nil {
|
|
t.Fatal("fallback must be inactive when authentication is disabled")
|
|
}
|
|
a.Enabled = true
|
|
verify := a.fallbackCredential()
|
|
if verify == nil || !verify("root", envSuperAdminPassword) {
|
|
t.Fatal("configured fallback must verify for its owner")
|
|
}
|
|
for _, c := range [][2]string{{"alice", envSuperAdminPassword}, {"root", "other-password"}, {"root", ""}} {
|
|
if verify(c[0], c[1]) {
|
|
t.Fatalf("fallback matched %q", c[0])
|
|
}
|
|
}
|
|
a.SetSuperAdminFallback("root", "")
|
|
if a.fallbackCredential() != nil {
|
|
t.Fatal("empty password must disable the fallback")
|
|
}
|
|
var nilAuth *Authenticator
|
|
if nilAuth.fallbackCredential() != nil {
|
|
t.Fatal("nil authenticator has no fallback")
|
|
}
|
|
}
|