Merge pull request 'Replace recovery proposals with administrator one-time passwords' (#221) from feature/auth-one-time-password-r2 into main
This commit was merged in pull request #221.
This commit is contained in:
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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"}`},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 身份)。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user