agent-exec transport 2eb00297accbad8eeae1744d70a55363/91b105b7880c
This commit is contained in:
@@ -782,6 +782,7 @@ func Run(opt Options) error {
|
||||
// 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。
|
||||
srv.Auth = server.NewAuthenticator(st, authOn)
|
||||
if authOn {
|
||||
srv.Auth.SetSuperAdminFallback(opt.SuperAdmin.Owner, opt.SuperAdmin.Password())
|
||||
log.Printf("auth: API key required (create with: agent-system apikey create --owner <name>)")
|
||||
}
|
||||
srv.Subscribe = func(conversationID string) (<-chan core.StreamEvent, func()) {
|
||||
|
||||
@@ -167,7 +167,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
st, ver, ok := s.st.VerifyLoginWithVersion(owner, req.Password)
|
||||
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)
|
||||
@@ -250,7 +250,7 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
case store.PasswordChangeModeGrantSetup:
|
||||
_, completeErr = s.st.CompleteSetPasswordChecked(tok, hash)
|
||||
case store.PasswordChangeModeCurrentRequired:
|
||||
_, completeErr = s.st.CompleteForcedPasswordChecked(tok, req.Current, hash)
|
||||
_, completeErr = s.st.CompleteForcedPasswordCheckedWithFallback(tok, req.Current, hash, s.Auth.fallbackCredential())
|
||||
default:
|
||||
http.Error(w, "unsupported password change mode", http.StatusForbidden)
|
||||
return
|
||||
@@ -274,7 +274,7 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
if err := s.st.ChangeUserPasswordActive(owner, req.Current, hash, tok); err != nil {
|
||||
if err := s.st.ChangeUserPasswordActiveWithFallback(owner, req.Current, hash, tok, s.Auth.fallbackCredential()); err != nil {
|
||||
if errors.Is(err, store.ErrBadCredentials) {
|
||||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,8 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -29,12 +31,43 @@ const (
|
||||
type Authenticator struct {
|
||||
st *store.Store
|
||||
Enabled bool
|
||||
|
||||
// superAdminOwner/superAdminDigest 是配置的超级管理员 fallback 凭据(KTD2):
|
||||
// 只存口令 SHA-256 摘要于进程内存,不落库、不进日志与响应。
|
||||
superAdminOwner string
|
||||
superAdminDigest [sha256.Size]byte
|
||||
}
|
||||
|
||||
func NewAuthenticator(st *store.Store, enabled bool) *Authenticator {
|
||||
return &Authenticator{st: st, Enabled: enabled}
|
||||
}
|
||||
|
||||
// SetSuperAdminFallback installs the configured super administrator's
|
||||
// environment password as an additional credential for that owner only.
|
||||
// An empty owner or password leaves the fallback disabled.
|
||||
func (a *Authenticator) SetSuperAdminFallback(owner, password string) {
|
||||
if owner == "" || password == "" {
|
||||
a.superAdminOwner, a.superAdminDigest = "", [sha256.Size]byte{}
|
||||
return
|
||||
}
|
||||
a.superAdminOwner, a.superAdminDigest = owner, sha256.Sum256([]byte(password))
|
||||
}
|
||||
|
||||
// fallbackCredential returns the verifier handed to store credential checks;
|
||||
// nil when authentication is off or no fallback is configured. The store
|
||||
// additionally requires the persisted super-admin marker and enabled state.
|
||||
func (a *Authenticator) fallbackCredential() store.FallbackCredential {
|
||||
if a == nil || !a.Enabled || a.superAdminOwner == "" {
|
||||
return nil
|
||||
}
|
||||
wantOwner, wantDigest := a.superAdminOwner, a.superAdminDigest
|
||||
return func(owner, password string) bool {
|
||||
got := sha256.Sum256([]byte(password))
|
||||
passwordOK := subtle.ConstantTimeCompare(got[:], wantDigest[:]) == 1
|
||||
return owner == wantOwner && passwordOK
|
||||
}
|
||||
}
|
||||
|
||||
// SessionCookie 会话 cookie 名(HttpOnly;SSE 的 EventSource 同源自动携带)。
|
||||
const SessionCookie = "as_session"
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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); 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")
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -325,10 +325,32 @@ func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) {
|
||||
// 时必须以 CreateSessionVerified 传回该版本,堵「验证在途 → 恢复/改密完成 →
|
||||
// 旧请求又建会话」的竞态窗口。
|
||||
func (s *Store) VerifyLoginWithVersion(owner, password string) (UserAuthState, int, bool) {
|
||||
return s.VerifyLoginWithFallback(owner, password, nil)
|
||||
}
|
||||
|
||||
// FallbackCredential reports whether password equals the in-memory fallback
|
||||
// credential configured for owner (KTD2). Implementations must compare in
|
||||
// constant time and must never persist or log the secret.
|
||||
type FallbackCredential func(owner, password string) bool
|
||||
|
||||
// verifyAccountCredential checks the database hash first, then the fallback.
|
||||
// The fallback is honoured only for an account carrying the persisted
|
||||
// super-admin marker, so it can never authenticate any other account.
|
||||
func verifyAccountCredential(hash string, isSuperAdmin bool, owner, password string, fallback FallbackCredential) bool {
|
||||
if hash != "" && VerifyPassword(hash, password) {
|
||||
return true
|
||||
}
|
||||
return isSuperAdmin && fallback != nil && fallback(owner, password)
|
||||
}
|
||||
|
||||
// VerifyLoginWithFallback 同 VerifyLoginWithVersion,另对已启用且带超级管理员标记的
|
||||
// 账户接受 fallback 凭据(R6);数据库密码优先。返回的认证版本语义不变。
|
||||
func (s *Store) VerifyLoginWithFallback(owner, password string, fallback FallbackCredential) (UserAuthState, int, bool) {
|
||||
var hash string
|
||||
var ver int
|
||||
err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version FROM users WHERE owner=? AND enabled=?`), owner, true).Scan(&hash, &ver)
|
||||
if err != nil || hash == "" || !VerifyPassword(hash, password) {
|
||||
var isSuperAdmin bool
|
||||
err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version,is_super_admin FROM users WHERE owner=? AND enabled=?`), owner, true).Scan(&hash, &ver, &isSuperAdmin)
|
||||
if err != nil || !verifyAccountCredential(hash, isSuperAdmin, owner, password, fallback) {
|
||||
return UserAuthState{}, 0, false
|
||||
}
|
||||
st, err := s.GetUserAuthState(owner)
|
||||
|
||||
@@ -593,6 +593,12 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st
|
||||
// password-authenticated account. It requires the current password even when
|
||||
// the session is restricted by must_change_password.
|
||||
func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash string) (string, error) {
|
||||
return s.CompleteForcedPasswordCheckedWithFallback(token, current, passwordHash, nil)
|
||||
}
|
||||
|
||||
// CompleteForcedPasswordCheckedWithFallback additionally accepts the
|
||||
// super administrator's in-memory fallback as the current password (R6).
|
||||
func (s *Store) CompleteForcedPasswordCheckedWithFallback(token, current, passwordHash string, fallback FallbackCredential) (string, error) {
|
||||
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) {
|
||||
@@ -626,7 +632,7 @@ func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash strin
|
||||
if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
if !verifyAccountCredential(hash, account.IsSuperAdmin, owner, current, fallback) {
|
||||
return ErrBadCredentials
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1
|
||||
@@ -658,16 +664,22 @@ func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash strin
|
||||
// ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观
|
||||
// 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。
|
||||
func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error {
|
||||
return s.ChangeUserPasswordActiveWithFallback(owner, current, passwordHash, keepToken, nil)
|
||||
}
|
||||
|
||||
// ChangeUserPasswordActiveWithFallback 同 ChangeUserPasswordActive,另接受超级管理员
|
||||
// 的进程内 fallback 作为当前密码(R6);只更新数据库哈希,fallback 不受影响(KTD2)。
|
||||
func (s *Store) ChangeUserPasswordActiveWithFallback(owner, current, passwordHash, keepToken string, fallback FallbackCredential) error {
|
||||
return s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.change"}, func(tx *sql.Tx, _ string) error {
|
||||
var hash string
|
||||
var ver int
|
||||
var mustChange, enabled bool
|
||||
accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`)
|
||||
var mustChange, enabled, isSuperAdmin bool
|
||||
accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled,is_super_admin FROM users WHERE owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
accountQuery += ` FOR UPDATE`
|
||||
}
|
||||
err := tx.QueryRow(accountQuery, owner).
|
||||
Scan(&hash, &ver, &mustChange, &enabled)
|
||||
Scan(&hash, &ver, &mustChange, &enabled, &isSuperAdmin)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -681,7 +693,7 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken
|
||||
// 强制态必须走受限设密路径,不在此处用旧密码换新密码。
|
||||
return ErrConflict
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
if !verifyAccountCredential(hash, isSuperAdmin, owner, current, fallback) {
|
||||
return ErrBadCredentials
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`),
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rootFallback(owner, password string) bool {
|
||||
return owner == "root" && password == superAdminTestPassword
|
||||
}
|
||||
|
||||
func anyOwnerFallback(_, password string) bool {
|
||||
return password == superAdminTestPassword
|
||||
}
|
||||
|
||||
func bootstrapRoot(t *testing.T, st *Store) {
|
||||
t.Helper()
|
||||
if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminFallbackSurvivesDatabasePasswordChanges(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
bootstrapRoot(t, st)
|
||||
if _, _, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback); !ok {
|
||||
t.Fatal("initial environment password must authenticate")
|
||||
}
|
||||
if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ChangeUserPasswordActiveWithFallback("root", "db-password-1", superAdminHash(t, "db-password-2"), "", rootFallback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-1", rootFallback); ok {
|
||||
t.Fatal("replaced database password must stop working")
|
||||
}
|
||||
if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-2", rootFallback); !ok {
|
||||
t.Fatal("current database password must authenticate")
|
||||
}
|
||||
state, ver, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback)
|
||||
if !ok || state.Owner != "root" || state.MustChangePassword {
|
||||
t.Fatalf("environment fallback must still authenticate: ok=%v %+v", ok, state)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("root")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ver != account.AuthVersion {
|
||||
t.Fatalf("fallback login must report the account auth version: %d != %d", ver, account.AuthVersion)
|
||||
}
|
||||
if _, _, ok := st.VerifyLoginWithVersion("root", superAdminTestPassword); ok {
|
||||
t.Fatal("fallback must not be persisted: plain verifier accepted the environment password")
|
||||
}
|
||||
if VerifyPassword(storedPasswordHash(t, st, "root"), superAdminTestPassword) {
|
||||
t.Fatal("database hash must not be overwritten with the environment password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminFallbackAsCurrentPassword(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
bootstrapRoot(t, st)
|
||||
if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ChangeUserPasswordActive("root", superAdminTestPassword, superAdminHash(t, "db-password-2"), ""); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Fatalf("without fallback the environment password is not current: %v", err)
|
||||
}
|
||||
if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-2"), "", rootFallback); err != nil {
|
||||
t.Fatalf("fallback must be accepted as current password: %v", err)
|
||||
}
|
||||
if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-1", rootFallback); ok {
|
||||
t.Fatal("previous database password must stop working")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminFallbackCompletesForcedPasswordChange(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
bootstrapRoot(t, st)
|
||||
if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE users SET must_change_password=? WHERE owner=?`), true, "root"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, ver, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback)
|
||||
if !ok || !state.MustChangePassword {
|
||||
t.Fatalf("fallback login on forced account: ok=%v %+v", ok, state)
|
||||
}
|
||||
tok, err := st.CreateSessionVerified("root", SessionScopeSetPasswordOnly, 10*time.Minute, ver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CompleteForcedPasswordChecked(tok, superAdminTestPassword, superAdminHash(t, "db-password-2")); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Fatalf("without fallback the environment password is not current: %v", err)
|
||||
}
|
||||
if _, err := st.CompleteForcedPasswordCheckedWithFallback(tok, superAdminTestPassword, superAdminHash(t, "db-password-2"), rootFallback); err != nil {
|
||||
t.Fatalf("fallback must complete forced change: %v", err)
|
||||
}
|
||||
if _, _, ok := st.SessionInfo(tok); ok {
|
||||
t.Fatal("forced change must revoke the restricted session")
|
||||
}
|
||||
if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-1", rootFallback); ok {
|
||||
t.Fatal("previous database password must stop working")
|
||||
}
|
||||
for _, pw := range []string{"db-password-2", superAdminTestPassword} {
|
||||
if _, _, ok := st.VerifyLoginWithFallback("root", pw, rootFallback); !ok {
|
||||
t.Fatalf("credential %q must authenticate after forced change", pw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminFallbackRejectedForUnmarkedAccounts(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
bootstrapRoot(t, st)
|
||||
if err := st.CreateAccount("alice", "", superAdminHash(t, "alice-pass-1"), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearMustChangePassword("alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("legacy-admin", "", superAdminHash(t, "legacy-pass-1"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearMustChangePassword("legacy-admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, owner := range []string{"alice", "legacy-admin", "ghost"} {
|
||||
if _, _, ok := st.VerifyLoginWithFallback(owner, superAdminTestPassword, anyOwnerFallback); ok {
|
||||
t.Fatalf("fallback authenticated unmarked account %q", owner)
|
||||
}
|
||||
}
|
||||
if err := st.ChangeUserPasswordActiveWithFallback("alice", superAdminTestPassword, superAdminHash(t, "alice-pass-2"), "", anyOwnerFallback); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Fatalf("fallback accepted as current password for unmarked account: %v", err)
|
||||
}
|
||||
if _, _, ok := st.VerifyLoginWithFallback("alice", "alice-pass-1", rootFallback); !ok {
|
||||
t.Fatal("ordinary database login must be unaffected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminFallbackRejectedWhenDisabled(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
bootstrapRoot(t, st)
|
||||
if _, err := st.db.Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "root"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback); ok {
|
||||
t.Fatal("disabled super admin must not authenticate with the fallback")
|
||||
}
|
||||
if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled super admin password change: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user