Replace recovery proposals with administrator one-time passwords #221

Merged
weichen merged 2 commits from feature/auth-one-time-password-r2 into main 2026-10-20 01:56:46 +00:00
27 changed files with 1002 additions and 1642 deletions
+3 -11
View File
@@ -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)
}
})
}
+12 -29
View File
@@ -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 == "" {
+1 -1
View File
@@ -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)
}
@@ -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"}`},
+17 -24
View File
@@ -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())
}
}
+44 -29
View File
@@ -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,
})
}
+4 -4
View File
@@ -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
+11 -11
View File
@@ -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 身份)。
+82
View File
@@ -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
}
-69
View File
@@ -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())
}
}
-217
View File
@@ -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)
}
}
+274
View File
@@ -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)
}
}
-128
View File
@@ -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)
}
-167
View File
@@ -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")
}
}
@@ -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)
}
}
+1 -18
View File
@@ -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
-22
View File
@@ -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)
}
}
@@ -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())
@@ -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
}
-3
View File
@@ -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
+56
View File
@@ -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)
+29 -90
View File
@@ -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
}
+264
View File
@@ -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
}
+139
View File
@@ -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")
}
}
-456
View File
@@ -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
}
-232
View File
@@ -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)
}
}
+6 -2
View File
@@ -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)