Implement owner-confirmed account recovery and account controls
Why: Administrator-issued password and login-key paths could take over target accounts, while old credentials and plugin handles ignored current account state. Evidence: SELF receipts /tmp/iam-u3-backend-all-replay.json, /tmp/iam-u3-backend-final-pg-core.json, /tmp/iam-u3-backend-build.json, and focused HTTP/concurrency receipts in /tmp/iam-u3-*.json. Impact: Adds trusted pending-account initialization and reissue, preexisting owner-session proof, proposal/rotation/grant transactions, server-side admin search, current Host guards, and legacy HTTP/CLI closure. Production cutover and downstream pins remain pending. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
This commit is contained in:
+49
-61
@@ -238,12 +238,11 @@ func runAPIKey(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报
|
||||
// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。
|
||||
// deliverCredential writes a new 0600 file without following/overwriting an
|
||||
// existing path. A secret never goes to stdout or a world-readable old file.
|
||||
func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error {
|
||||
if outPath == "" {
|
||||
fmt.Println(plaintext)
|
||||
return nil
|
||||
return fmt.Errorf("protected --out delivery path required")
|
||||
}
|
||||
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
|
||||
if expiresAt != nil {
|
||||
@@ -253,18 +252,25 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(outPath, buf, 0o600); err != nil {
|
||||
file, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := file.Write(buf); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。
|
||||
// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。
|
||||
// runUser manages local owner initialization and metadata-only account actions.
|
||||
func runUser(args []string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list|issue-login-key|revoke-login-key|bootstrap-admin> [flags]")
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|initialize-account|list|revoke-login-key|bootstrap-admin> [flags]")
|
||||
return 2
|
||||
}
|
||||
sub := args[0]
|
||||
@@ -272,20 +278,27 @@ func runUser(args []string) int {
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
owner := fs.String("owner", "", "用户名(owner)")
|
||||
userID := fs.String("user-id", "", "待初始化账号稳定 ID")
|
||||
expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号当前 stateVersion")
|
||||
expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号当前 generation")
|
||||
name := fs.String("name", "", "显示名")
|
||||
password := fs.String("password", "", "密码(create 空=激活 Key;加 --generate-password 则随机)")
|
||||
password := fs.String("password", "", "旧建号密码参数(现已拒绝)")
|
||||
admin := fs.Bool("admin", false, "管理员")
|
||||
role := fs.String("role", "user", "角色 user|admin")
|
||||
openExisting := fs.Bool("open-existing", false, "只打开既有存储")
|
||||
generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码")
|
||||
purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover")
|
||||
ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)")
|
||||
revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话")
|
||||
generatePassword := fs.Bool("generate-password", false, "旧随机初始密码参数(现已拒绝)")
|
||||
fs.String("purpose", "recover", "旧 issue-login-key 参数(现已拒绝)")
|
||||
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,代替 stdout 明文;非交互推荐)")
|
||||
out := fs.String("out", "", "凭据保护交付文件(0600 JSON,必填)")
|
||||
fs.Parse(args[1:])
|
||||
switch sub {
|
||||
case "create":
|
||||
if *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "user create: --out 必填(保护交付文件)")
|
||||
return 2
|
||||
}
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user create: --owner 必填")
|
||||
return 2
|
||||
@@ -308,32 +321,25 @@ func runUser(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" {
|
||||
if *password == "" && !*generatePassword {
|
||||
fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。")
|
||||
} else if *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
|
||||
}
|
||||
return 0
|
||||
case "initialize-account":
|
||||
if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 || *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation、--out 必填")
|
||||
return 2
|
||||
}
|
||||
plain, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "reset-password":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "password", *owner, pw, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" && *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
|
||||
}
|
||||
return 0
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password")
|
||||
return 2
|
||||
case "list":
|
||||
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
|
||||
if err != nil {
|
||||
@@ -344,27 +350,8 @@ func runUser(args []string) int {
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
case "issue-login-key":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
if *purpose != store.LoginKeyPurposeActivate && *purpose != store.LoginKeyPurposeRecover {
|
||||
fmt.Fprintf(os.Stderr, "user issue-login-key: 无效 purpose %q\n", *purpose)
|
||||
return 2
|
||||
}
|
||||
plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。")
|
||||
}
|
||||
return 0
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery")
|
||||
return 2
|
||||
case "revoke-login-key":
|
||||
if *keyID == "" {
|
||||
fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填")
|
||||
@@ -377,6 +364,10 @@ func runUser(args []string) int {
|
||||
fmt.Println("revoked")
|
||||
return 0
|
||||
case "bootstrap-admin":
|
||||
if *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin: --out 必填(保护交付文件)")
|
||||
return 2
|
||||
}
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填")
|
||||
return 2
|
||||
@@ -390,9 +381,6 @@ func runUser(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
|
||||
return 1
|
||||
}
|
||||
if *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。")
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "user: 未知子命令", sub)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) {
|
||||
data := t.TempDir()
|
||||
for _, args := range [][]string{
|
||||
{"reset-password", "--data", data, "--owner", "victim", "--password", "attacker-password"},
|
||||
{"issue-login-key", "--data", data, "--owner", "victim", "--purpose", "recover", "--revoke-all-credentials"},
|
||||
} {
|
||||
if code := runUser(args); code != 2 {
|
||||
t.Fatalf("legacy command did not reject before storage: %v code=%d", args[0], code)
|
||||
}
|
||||
}
|
||||
out := filepath.Join(data, "delivery.json")
|
||||
if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 1 {
|
||||
t.Fatalf("password-bearing local create accepted: %d", code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) {
|
||||
t.Fatalf("rejected commands opened/created a database: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(out); !os.IsNotExist(err) {
|
||||
t.Fatalf("rejected commands wrote a delivery file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialDeliveryCreatesExclusive0600File(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "credential.json")
|
||||
if err := os.WriteFile(out, []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := deliverCredential(out, "activation", "alice", "secret", nil); err == nil {
|
||||
t.Fatal("existing permissive file was overwritten")
|
||||
}
|
||||
bytes, err := os.ReadFile(out)
|
||||
if err != nil || string(bytes) != "old" {
|
||||
t.Fatalf("existing file changed: %q %v", bytes, err)
|
||||
}
|
||||
out = filepath.Join(dir, "new.json")
|
||||
if err := deliverCredential(out, "activation", "alice", "secret", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(out)
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("delivery mode: %v %v", info, err)
|
||||
}
|
||||
bytes, err = os.ReadFile(out)
|
||||
if err != nil || !strings.Contains(string(bytes), `"secret": "secret"`) {
|
||||
t.Fatalf("delivery payload: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func cmdBackup(args []string) error {
|
||||
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
|
||||
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
|
||||
var copyOrder = []string{
|
||||
"projects", "users", "profiles", "settings", "plugins",
|
||||
"projects", "users", "account_directory_revision", "profiles", "settings", "plugins",
|
||||
"conversations", "runs", "messages", "events", "artifacts",
|
||||
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
|
||||
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
|
||||
@@ -297,6 +297,7 @@ var copyOrder = []string{
|
||||
"agent_system_identity",
|
||||
"account_events",
|
||||
"redeem_attempts",
|
||||
"recovery_proposals", "recovery_proposal_proofs",
|
||||
}
|
||||
|
||||
const controlDDL = `
|
||||
@@ -707,6 +708,14 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// The destination migrator creates a fresh singleton directory revision at
|
||||
// schema startup. Replace it with the source's opaque revision so existing
|
||||
// cursors/visible-change history remain tied to the migrated account data.
|
||||
if table == "account_directory_revision" {
|
||||
if _, err := tx.Exec(`DELETE FROM account_directory_revision`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -784,7 +793,8 @@ func advanceSequences(db *sql.DB, table string) error {
|
||||
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
|
||||
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
|
||||
func checkOwners(db *sql.DB, src *sql.DB) error {
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"}
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards",
|
||||
"project_event_shares", "command_audit", "sessions", "api_keys", "login_keys", "account_events", "recovery_proposals"}
|
||||
for _, t := range ownerTables {
|
||||
if !sqliteHasTable(src, t) {
|
||||
continue
|
||||
|
||||
@@ -51,6 +51,7 @@ type directoryPolicy struct {
|
||||
allowed map[string]bool
|
||||
match []string
|
||||
candidates map[string]bool
|
||||
owners map[string]bool
|
||||
all bool
|
||||
status string
|
||||
version string
|
||||
@@ -68,6 +69,9 @@ func knownDirectoryField(field string) bool {
|
||||
|
||||
func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery) (directoryPolicy, error) {
|
||||
var p directoryPolicy
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return p, err
|
||||
}
|
||||
if h.actor == "" || h.caps.st == nil || h.caps.directory == nil || q.SchemaVersion != extension.AccountDirectorySchemaVersion {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
@@ -153,12 +157,19 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
|
||||
}
|
||||
sort.Strings(p.match)
|
||||
p.candidates = make(map[string]bool, len(scope.CandidateIDs))
|
||||
p.owners = make(map[string]bool, len(scope.CandidateOwners))
|
||||
for _, id := range scope.CandidateIDs {
|
||||
if id == "" {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
p.candidates[id] = true
|
||||
}
|
||||
for _, owner := range scope.CandidateOwners {
|
||||
if owner == "" {
|
||||
return p, store.ErrAccountForbidden
|
||||
}
|
||||
p.owners[owner] = true
|
||||
}
|
||||
p.all = scope.AllCandidates
|
||||
if q.Purpose == extension.AccountDirectoryPurposeSystemUsers {
|
||||
p.all = true
|
||||
@@ -172,13 +183,18 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
owners := make([]string, 0, len(p.owners))
|
||||
for owner := range p.owners {
|
||||
owners = append(owners, owner)
|
||||
}
|
||||
sort.Strings(owners)
|
||||
fingerprint := struct {
|
||||
Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Public, Staff, Scope string
|
||||
Match, IDs []string
|
||||
Match, IDs, Owners []string
|
||||
All bool
|
||||
}{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID,
|
||||
Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status, Public: revision.Public,
|
||||
Scope: scope.ScopeVersion, Match: p.match, IDs: ids, All: p.all}
|
||||
Scope: scope.ScopeVersion, Match: p.match, IDs: ids, Owners: owners, All: p.all}
|
||||
if p.allowed["staffNo"] {
|
||||
fingerprint.Staff = revision.StaffNo
|
||||
}
|
||||
@@ -254,7 +270,7 @@ func directoryMatches(row store.DirectoryAccountRow, fields []string, mode, quer
|
||||
}
|
||||
|
||||
func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool {
|
||||
if !p.all && !p.candidates[row.UserID] {
|
||||
if !p.all && !p.candidates[row.UserID] && !p.owners[row.Owner] {
|
||||
return false
|
||||
}
|
||||
return p.status == "all" || (p.status == "enabled" && row.Enabled) || (p.status == "disabled" && !row.Enabled)
|
||||
@@ -335,7 +351,7 @@ func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.Acco
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryLookup{}, err
|
||||
}
|
||||
if !p.all && !p.candidates[userID] {
|
||||
if !p.all && len(p.owners) == 0 && !p.candidates[userID] {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
}
|
||||
account, err := h.caps.st.GetAccount(userID)
|
||||
@@ -350,4 +366,27 @@ func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.Acco
|
||||
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) LookupAccountDirectoryOwner(ctx context.Context, q extension.AccountDirectoryQuery, owner string) (extension.AccountDirectoryLookup, error) {
|
||||
if owner == "" || q.Cursor != "" {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile
|
||||
}
|
||||
p, err := h.directoryPolicy(ctx, q)
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryLookup{}, err
|
||||
}
|
||||
if !p.all && len(p.candidates) == 0 && !p.owners[owner] {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
}
|
||||
account, err := h.caps.st.GetAccountByOwner(owner)
|
||||
if err != nil {
|
||||
return extension.AccountDirectoryLookup{}, err
|
||||
}
|
||||
row := store.DirectoryAccountRow{UserID: account.ID, Owner: account.Owner, Name: account.Profile.Name,
|
||||
StaffNo: account.Profile.StaffNo, Enabled: account.Enabled}
|
||||
if !directoryRowAllowed(row, p) {
|
||||
return extension.AccountDirectoryLookup{}, store.ErrNotFound
|
||||
}
|
||||
return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil
|
||||
}
|
||||
|
||||
var _ extension.AccountDirectoryProvider = inprocHost{}
|
||||
|
||||
@@ -106,6 +106,12 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) {
|
||||
}
|
||||
query.SearchFields = nil
|
||||
query.Query = "同名"
|
||||
if hit, err := h.LookupAccountDirectoryOwner(context.Background(), query, "alice"); err != nil || hit.Entry.UserID != alice.ID {
|
||||
t.Fatalf("explicit owner lookup failed to resolve stable ID: %+v %v", hit, err)
|
||||
}
|
||||
if _, err := h.LookupAccountDirectoryOwner(context.Background(), query, "outsider"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("owner lookup leaked outsider: %v", err)
|
||||
}
|
||||
if _, err := h.LookupAccountDirectory(context.Background(), query, "not-in-scope"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("exact lookup leaked outsider: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// accountEffectiveAuthorization is shared by runmgr and in-process Host
|
||||
// capabilities. The implicit local singleton is the only auth-off missing-row
|
||||
// exception; an existing disabled local account remains denied.
|
||||
func accountEffectiveAuthorization(st *store.Store, authOn bool) func(context.Context, string) (bool, error) {
|
||||
return func(_ context.Context, owner string) (bool, error) {
|
||||
account, err := st.GetAccountByOwner(owner)
|
||||
if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return account.Enabled, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestInprocHostRechecksEffectiveAccountOnEveryCapability(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/host.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("alice", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := accountEffectiveAuthorization(st, true)
|
||||
h := inprocHost{caps: hostcaps{st: st, accountGuard: func(owner string) error {
|
||||
valid, err := check(context.Background(), owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}}, owner: "alice", actor: "alice", pluginID: "rdm"}
|
||||
if err := h.KVPut("before", "allowed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.KVPut("after", "denied"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host wrote KV: %v", err)
|
||||
}
|
||||
if _, _, err := h.KVGet("before"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host read KV: %v", err)
|
||||
}
|
||||
if _, err := h.TaskCreate("test", "denied", ""); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host created task: %v", err)
|
||||
}
|
||||
if _, err := h.ProjectNameAvailable("denied", ""); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host used project capability: %v", err)
|
||||
}
|
||||
if err := h.Audit("action", "target", ""); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host wrote audit: %v", err)
|
||||
}
|
||||
if _, err := h.Complete(context.Background(), "denied"); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host reached model completion: %v", err)
|
||||
}
|
||||
if _, err := h.KnowledgeSearch(context.Background(), "", "denied", 1); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("disabled host read knowledge: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImplicitLocalAccountExceptionIsAuthOffOnly(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/local.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
allowed, err := accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault)
|
||||
if err != nil || !allowed {
|
||||
t.Fatalf("implicit local denied: %v %v", allowed, err)
|
||||
}
|
||||
allowed, err = accountEffectiveAuthorization(st, true)(context.Background(), store.OwnerDefault)
|
||||
if !errors.Is(err, store.ErrNotFound) || allowed {
|
||||
t.Fatalf("auth-on missing account allowed: %v %v", allowed, err)
|
||||
}
|
||||
if err := st.EnsureUser(store.OwnerDefault, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, store.OwnerDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed, err = accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault)
|
||||
if err != nil || allowed {
|
||||
t.Fatalf("disabled existing local account allowed: %v %v", allowed, err)
|
||||
}
|
||||
}
|
||||
+64
-57
@@ -7,7 +7,6 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -452,16 +451,8 @@ func Run(opt Options) error {
|
||||
mockRunner := &runtime.MockRunner{}
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{})
|
||||
authOn := resolved.AuthEnabled()
|
||||
mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) {
|
||||
account, err := st.GetAccountByOwner(owner)
|
||||
if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault {
|
||||
return true, nil // legacy implicit local singleton has no users row
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return account.Enabled, nil
|
||||
})
|
||||
effectiveAccount := accountEffectiveAuthorization(st, authOn)
|
||||
mgr.SetAccountEffectiveAuthorization(effectiveAccount)
|
||||
instanceID := opt.InstanceID
|
||||
if instanceID == "" {
|
||||
host, err := os.Hostname()
|
||||
@@ -613,7 +604,17 @@ func Run(opt Options) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc,
|
||||
accountGuard: func(owner string) error {
|
||||
valid, err := effectiveAccount(context.Background(), owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
|
||||
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
|
||||
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
|
||||
@@ -649,6 +650,7 @@ func Run(opt Options) error {
|
||||
}
|
||||
}
|
||||
hostSrv.Dispatch = func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error {
|
||||
if err := caps.accountGuard(owner); err != nil { return err }
|
||||
resp := bus.Dispatch(ctx, cmdbus.Request{ID: commandID, Params: params, Entrance: "api", Actor: pluginID, Owner: owner})
|
||||
if resp.Receipt.State == "rejected" || resp.Receipt.State == "unknown" {
|
||||
return fmt.Errorf("%s", resp.Receipt.Reason)
|
||||
@@ -753,31 +755,38 @@ func Run(opt Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserAccount CLI 建号。
|
||||
// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。
|
||||
// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) {
|
||||
var out string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if password != "" || generatePassword {
|
||||
pw := password
|
||||
if pw == "" {
|
||||
pw = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
out = pw
|
||||
return nil
|
||||
func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error {
|
||||
if storageDSN != "" {
|
||||
var isOwner bool
|
||||
err := st.DB().QueryRow(`SELECT current_user = pg_get_userbyid(c.relowner) FROM pg_class c WHERE c.oid='users'::regclass`).Scan(&isOwner)
|
||||
if err != nil || !isOwner {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
if err := st.CreateAccount(owner, name, "", isAdmin); err != nil {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dataDir, "agent-system.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok || (os.Geteuid() != 0 && stat.Uid != uint32(os.Geteuid())) {
|
||||
return store.ErrAccountForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserAccount is the local owner-only atomic pending-account path.
|
||||
// Password-bearing creation is rejected before opening or changing storage.
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) {
|
||||
if password != "" || generatePassword {
|
||||
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only")
|
||||
}
|
||||
var out string
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0)
|
||||
_, plain, err := st.CreateLocalPendingAccount(owner, name, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -789,19 +798,26 @@ func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmi
|
||||
|
||||
// IssueUserLoginKey CLI 签发激活/恢复 Key。
|
||||
func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) {
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if revokeAllCreds {
|
||||
if err := st.RevokeAllAPIKeys(owner); err != nil {
|
||||
return err
|
||||
}
|
||||
st.DeleteUserSessions(owner, "")
|
||||
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery")
|
||||
}
|
||||
|
||||
// InitializeAccount resumes a specific pending account. The database/file owner
|
||||
// and the local OS UID are obtained from the live environment, not CLI flags.
|
||||
func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion, expectedGeneration int64) (string, string, error) {
|
||||
var plain, owner string
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl)
|
||||
plain = p
|
||||
account, err := st.GetAccount(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plain, err = st.InitializePendingAccount(userID, expectedStateVersion, expectedGeneration)
|
||||
owner = account.Owner
|
||||
return err
|
||||
})
|
||||
return plain, err
|
||||
return plain, owner, err
|
||||
}
|
||||
|
||||
// RevokeUserLoginKey CLI 撤销未使用登录 Key。
|
||||
@@ -816,6 +832,9 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error
|
||||
func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) {
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap")
|
||||
plain = p
|
||||
return err
|
||||
@@ -826,19 +845,7 @@ func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (strin
|
||||
// ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、
|
||||
// 作废未用登录 Key、吊销全部会话并审计。
|
||||
func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) {
|
||||
if password == "" {
|
||||
password = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
return st.AdminResetUserPassword(owner, hash, "cli", "cli")
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password")
|
||||
}
|
||||
|
||||
// ListUserAccounts CLI 用户列表。
|
||||
|
||||
@@ -21,13 +21,14 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
projects *projectLifecycleSlot
|
||||
directory *accountDirectorySlot
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||||
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
|
||||
projects *projectLifecycleSlot
|
||||
directory *accountDirectorySlot
|
||||
accountGuard func(string) error
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||||
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
|
||||
}
|
||||
|
||||
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
|
||||
@@ -110,6 +111,9 @@ type sharedRetriever interface {
|
||||
}
|
||||
|
||||
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
if c.accountGuard != nil {
|
||||
if err := c.accountGuard(owner); err != nil { return nil, err }
|
||||
}
|
||||
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
|
||||
if c.projects != nil && projectID != "" {
|
||||
id, err := c.projects.resolve(ctx, c, owner, projectID)
|
||||
@@ -144,6 +148,9 @@ func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core
|
||||
|
||||
// complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。
|
||||
func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) {
|
||||
if c.accountGuard != nil {
|
||||
if err := c.accountGuard(owner); err != nil { return "", err }
|
||||
}
|
||||
route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, ""))
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return "", fmt.Errorf("llm route not configured")
|
||||
@@ -177,6 +184,13 @@ type inprocHost struct {
|
||||
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
|
||||
}
|
||||
|
||||
func (h inprocHost) requireEffective() error {
|
||||
if h.caps.accountGuard != nil {
|
||||
return h.caps.accountGuard(h.owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h inprocHost) PluginID() string { return h.pluginID }
|
||||
func (h inprocHost) Owner() string { return h.owner }
|
||||
|
||||
@@ -195,6 +209,9 @@ func (h inprocHost) IsAdmin() bool {
|
||||
}
|
||||
|
||||
func (h inprocHost) legacySystemDirectoryAuthorized() bool {
|
||||
if h.requireEffective() != nil {
|
||||
return false
|
||||
}
|
||||
if h.actor == "" {
|
||||
return false
|
||||
}
|
||||
@@ -276,14 +293,23 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account
|
||||
}
|
||||
|
||||
func (h inprocHost) KVPut(key, value string) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
|
||||
}
|
||||
|
||||
func (h inprocHost) KVGet(key string) (string, bool, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return h.caps.st.GetPluginKV(h.pluginID, h.owner, key)
|
||||
}
|
||||
|
||||
func (h inprocHost) Audit(action, target, evidence string) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.caps.st.AppendAuditEvent(store.AuditEvent{
|
||||
Owner: h.owner, Actor: "plugin:" + h.pluginID,
|
||||
Action: action, Target: target, Evidence: evidence,
|
||||
@@ -291,10 +317,16 @@ func (h inprocHost) Audit(action, target, evidence string) error {
|
||||
}
|
||||
|
||||
func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return h.caps.complete(ctx, h.owner, prompt)
|
||||
}
|
||||
|
||||
func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.caps.search(ctx, h.owner, projectID, text, topK)
|
||||
}
|
||||
|
||||
@@ -316,6 +348,9 @@ func (h inprocHost) knowledgeSource(source string) error {
|
||||
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
|
||||
// (embedding 失败降级纯全文,不阻断写入)。
|
||||
func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -339,6 +374,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
|
||||
// A registered lifecycle scopes replacement to the exact owner/project/plugin;
|
||||
// ordinary unregistered Hosts retain the legacy cross-source ref semantics.
|
||||
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -375,6 +413,9 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD
|
||||
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
|
||||
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
|
||||
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
if sourcePrefix != "plugin:"+h.pluginID {
|
||||
return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID)
|
||||
}
|
||||
@@ -384,6 +425,9 @@ func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) er
|
||||
|
||||
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
|
||||
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -392,27 +436,42 @@ func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, erro
|
||||
}
|
||||
|
||||
func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText)
|
||||
}
|
||||
|
||||
// ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。
|
||||
func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Artifact{}, err
|
||||
}
|
||||
return h.caps.st.GetArtifact(h.owner, artifactID)
|
||||
}
|
||||
|
||||
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.caps.st.ArtifactContent(h.owner, artifactID)
|
||||
}
|
||||
|
||||
// ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储,
|
||||
// owner 作用域由调用方经 ArtifactContent 取字节时保证。
|
||||
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.ParsedDocument{}, err
|
||||
}
|
||||
return h.caps.parse(ctx, filename, mime, content)
|
||||
}
|
||||
|
||||
// ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ----
|
||||
|
||||
func (h inprocHost) surface() (*surfacesvc.Service, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.caps.surfaces == nil {
|
||||
return nil, surfacesvc.ErrUnavailable
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bootstrap, err := BootstrapAdmin("", dir, "root", false)
|
||||
if err != nil || bootstrap == "" {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
first, err := CreateUserAccount("", dir, "alice", "", "", false, true, false)
|
||||
if err != nil || first == "" {
|
||||
t.Fatalf("local pending create: %v", err)
|
||||
}
|
||||
st, err := store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
second, owner, err := InitializeAccount("", dir, account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
if err != nil || owner != "alice" || second == first {
|
||||
t.Fatalf("owner reissue: owner=%s err=%v", owner, err)
|
||||
}
|
||||
st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
current, err := st.GetAccount(account.ID)
|
||||
if err != nil || current.Owner != account.Owner || current.ID != account.ID || current.InitializationGeneration != account.InitializationGeneration+1 {
|
||||
t.Fatalf("reissue changed account identity: %+v %v", current, err)
|
||||
}
|
||||
if _, _, err := st.RedeemLoginKey(first); err == nil {
|
||||
t.Fatal("old activation grant survived reissue")
|
||||
}
|
||||
if _, _, err := st.RedeemLoginKey(second); err != nil {
|
||||
t.Fatalf("new activation grant failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,9 @@ func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, ow
|
||||
var _ extension.ProjectHost = inprocHost{}
|
||||
|
||||
func (h inprocHost) OwnedProjects() ([]core.Project, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ps, err := h.caps.st.ListProjects(h.owner)
|
||||
for i := range ps {
|
||||
ps[i].Owner = h.owner
|
||||
@@ -91,9 +94,15 @@ func (h inprocHost) OwnedProjects() ([]core.Project, error) {
|
||||
return ps, err
|
||||
}
|
||||
func (h inprocHost) OwnedProject(id string) (core.Project, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return h.caps.st.GetProject(h.owner, id)
|
||||
}
|
||||
func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
p, err := h.caps.st.ProjectMetadata(id)
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
@@ -114,11 +123,20 @@ func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
|
||||
return p, nil
|
||||
}
|
||||
func (h inprocHost) EnsureProject(id, name string) (core.Project, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return h.caps.st.EnsureProject(h.owner, id, name)
|
||||
}
|
||||
func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return h.caps.st.RenameOwnedProject(h.owner, id, name)
|
||||
}
|
||||
func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) {
|
||||
if err := h.requireEffective(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return h.caps.st.ProjectNameAvailable(name, exceptID)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,73 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
alice, err := st.CreatePendingAccount("admin", "alice-search", store.AccountProfile{Name: "同名", Phone: "private-needle"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := st.CreatePendingAccount("admin", "bob-search", store.AccountProfile{Name: "同名", Phone: "other"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
search := func(query url.Values) (int, struct {
|
||||
Accounts []struct {
|
||||
UserID string `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
} `json:"accounts"`
|
||||
NextCursor string `json:"nextCursor"`
|
||||
}) {
|
||||
response := do(h, "GET", "/api/admin/users/search?"+query.Encode(), "", admin)
|
||||
var page struct {
|
||||
Accounts []struct {
|
||||
UserID string `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
} `json:"accounts"`
|
||||
NextCursor string `json:"nextCursor"`
|
||||
}
|
||||
_ = json.Unmarshal(response.Body.Bytes(), &page)
|
||||
return response.Code, page
|
||||
}
|
||||
params := url.Values{"query": {"同名"}, "mode": {"fuzzy"}, "fields": {"name"}, "limit": {"1"}}
|
||||
code, first := search(params)
|
||||
if code != http.StatusOK || len(first.Accounts) != 1 || first.Accounts[0].UserID != alice.ID || first.NextCursor == "" {
|
||||
t.Fatalf("first search page: %d %+v", code, first)
|
||||
}
|
||||
params.Set("cursor", first.NextCursor)
|
||||
code, second := search(params)
|
||||
if code != http.StatusOK || len(second.Accounts) != 1 || second.Accounts[0].UserID != bob.ID || second.NextCursor != "" {
|
||||
t.Fatalf("second search page: %d %+v", code, second)
|
||||
}
|
||||
params.Del("cursor")
|
||||
params.Set("query", "private-needle")
|
||||
params.Set("fields", "phone")
|
||||
params.Set("mode", "exact")
|
||||
code, contact := search(params)
|
||||
if code != http.StatusOK || len(contact.Accounts) != 1 || contact.Accounts[0].UserID != alice.ID {
|
||||
t.Fatalf("authorized contact search: %d %+v", code, contact)
|
||||
}
|
||||
params.Set("query", "同名")
|
||||
params.Set("fields", "name")
|
||||
params.Set("mode", "fuzzy")
|
||||
params.Set("cursor", first.NextCursor)
|
||||
if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "changed-private"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, _ = search(params)
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("private profile edit did not expire system cursor: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func newAccountSearchSecret() [32]byte {
|
||||
var key [32]byte
|
||||
if _, err := rand.Read(key[:]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
type adminSearchCursor struct {
|
||||
Version string `json:"v"`
|
||||
Owner string `json:"o"`
|
||||
UserID string `json:"u"`
|
||||
}
|
||||
|
||||
func (s *Server) signAdminCursor(cursor adminSearchCursor) string {
|
||||
data, _ := json.Marshal(cursor)
|
||||
mac := hmac.New(sha256.New, s.accountSearchSecret[:])
|
||||
_, _ = mac.Write(data)
|
||||
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Server) parseAdminCursor(encoded, version string) (adminSearchCursor, bool) {
|
||||
var cursor adminSearchCursor
|
||||
payload, signature, found := strings.Cut(encoded, ".")
|
||||
if !found {
|
||||
return cursor, false
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil || len(data) > 1024 {
|
||||
return cursor, false
|
||||
}
|
||||
provided, err := base64.RawURLEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return cursor, false
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.accountSearchSecret[:])
|
||||
_, _ = mac.Write(data)
|
||||
if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &cursor) != nil || cursor.Version != version || cursor.Owner == "" || cursor.UserID == "" {
|
||||
return adminSearchCursor{}, false
|
||||
}
|
||||
return cursor, true
|
||||
}
|
||||
|
||||
func searchAccountField(account store.Account, field string) string {
|
||||
switch field {
|
||||
case "userId":
|
||||
return account.ID
|
||||
case "owner":
|
||||
return account.Owner
|
||||
case "name":
|
||||
return account.Profile.Name
|
||||
case "phone":
|
||||
return account.Profile.Phone
|
||||
case "email":
|
||||
return account.Profile.Email
|
||||
case "staffNo":
|
||||
return account.Profile.StaffNo
|
||||
case "department":
|
||||
return account.Profile.Department
|
||||
case "notes":
|
||||
return account.Profile.Notes
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
params := r.URL.Query()
|
||||
query := strings.TrimSpace(params.Get("query"))
|
||||
if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 128 {
|
||||
http.Error(w, "invalid account search query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, ch := range query {
|
||||
if unicode.IsControl(ch) {
|
||||
http.Error(w, "invalid account search query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
mode := params.Get("mode")
|
||||
if mode == "" {
|
||||
mode = "fuzzy"
|
||||
}
|
||||
if mode != "exact" && mode != "fuzzy" {
|
||||
http.Error(w, "unknown account search mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
status := params.Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
if status != "all" && status != "enabled" && status != "disabled" {
|
||||
http.Error(w, "unknown account status filter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit := 20
|
||||
if raw := params.Get("limit"); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 1 || parsed > 100 {
|
||||
http.Error(w, "invalid account search limit", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
fields := []string{"owner", "name", "userId"}
|
||||
if raw := params.Get("fields"); raw != "" {
|
||||
fields = strings.Split(raw, ",")
|
||||
}
|
||||
allowed := map[string]bool{"owner": true, "name": true, "userId": true, "phone": true, "email": true,
|
||||
"staffNo": true, "department": true, "notes": true}
|
||||
seen := map[string]bool{}
|
||||
for _, field := range fields {
|
||||
if !allowed[field] || seen[field] {
|
||||
http.Error(w, "unknown or duplicate account search field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seen[field] = true
|
||||
}
|
||||
sort.Strings(fields)
|
||||
accounts, revision, err := s.st.ListAdminAccountsForActor(actor)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
visibleRevision := sha256.Sum256([]byte(revision.Private))
|
||||
directoryVersion := hex.EncodeToString(visibleRevision[:])
|
||||
fingerprint, _ := json.Marshal(struct {
|
||||
Actor, Query, Mode, Status, Revision string
|
||||
Fields []string
|
||||
}{actor, query, mode, status, directoryVersion, fields})
|
||||
digest := sha256.Sum256(fingerprint)
|
||||
version := hex.EncodeToString(digest[:])
|
||||
var after adminSearchCursor
|
||||
if token := params.Get("cursor"); token != "" {
|
||||
var valid bool
|
||||
after, valid = s.parseAdminCursor(token, version)
|
||||
if !valid {
|
||||
http.Error(w, "account search cursor expired or invalid", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
page := make([]store.Account, 0, limit+1)
|
||||
for _, account := range accounts {
|
||||
if status == "enabled" && !account.Enabled || status == "disabled" && account.Enabled {
|
||||
continue
|
||||
}
|
||||
if after.Owner != "" && (account.Owner < after.Owner || account.Owner == after.Owner && account.ID <= after.UserID) {
|
||||
continue
|
||||
}
|
||||
if query != "" {
|
||||
matched := false
|
||||
for _, field := range fields {
|
||||
value := searchAccountField(account, field)
|
||||
if mode == "exact" && strings.EqualFold(value, query) || mode == "fuzzy" && strings.Contains(strings.ToLower(value), strings.ToLower(query)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
page = append(page, account)
|
||||
if len(page) > limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
more := len(page) > limit
|
||||
if more {
|
||||
page = page[:limit]
|
||||
}
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, account := range page {
|
||||
items = append(items, accountProfileJSON(account))
|
||||
}
|
||||
next := ""
|
||||
if more {
|
||||
last := page[len(page)-1]
|
||||
next = s.signAdminCursor(adminSearchCursor{Version: version, Owner: last.Owner, UserID: last.ID})
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion})
|
||||
}
|
||||
+32
-66
@@ -104,6 +104,7 @@ func authStateJSON(st store.UserAuthState) map[string]any {
|
||||
// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。
|
||||
// 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。
|
||||
func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if s.Auth == nil || !s.Auth.Enabled {
|
||||
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
|
||||
return
|
||||
@@ -137,6 +138,7 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
out := authStateJSON(st)
|
||||
out["sessionScope"] = store.SessionScopeSetPasswordOnly
|
||||
out["passwordChangeMode"] = store.PasswordChangeModeGrantSetup
|
||||
out["reloginRequired"] = false
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -192,6 +194,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
setSessionCookie(w, r, token, maxAge)
|
||||
out := authStateJSON(st)
|
||||
out["sessionScope"] = scope
|
||||
out["passwordChangeMode"] = store.PasswordChangeModeCurrentRequired
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -219,6 +222,11 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
mode, err := s.st.SessionPasswordChangeMode(tok)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Current string `json:"current"`
|
||||
Next string `json:"next"`
|
||||
@@ -236,15 +244,27 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
st0, _ := s.st.GetUserAuthState(owner)
|
||||
forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword
|
||||
if forceSet {
|
||||
if _, err := s.st.CompleteSetPasswordChecked(tok, hash); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
if scope == store.SessionScopeSetPasswordOnly {
|
||||
var completeErr error
|
||||
switch mode {
|
||||
case store.PasswordChangeModeGrantSetup:
|
||||
_, completeErr = s.st.CompleteSetPasswordChecked(tok, hash)
|
||||
case store.PasswordChangeModeCurrentRequired:
|
||||
_, completeErr = s.st.CompleteForcedPasswordChecked(tok, req.Current, hash)
|
||||
default:
|
||||
http.Error(w, "unsupported password change mode", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if completeErr != nil {
|
||||
if errors.Is(completeErr, store.ErrBadCredentials) {
|
||||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if errors.Is(completeErr, store.ErrNotFound) {
|
||||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeStoreErr(w, err)
|
||||
writeStoreErr(w, completeErr)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, "", -1)
|
||||
@@ -330,6 +350,7 @@ func accountProfileJSON(account store.Account) map[string]any {
|
||||
"profileVersion": account.ProfileVersion, "enabled": account.Enabled,
|
||||
"stateVersion": account.StateVersion, "isAdmin": account.IsAdmin,
|
||||
"passwordSet": account.HasPassword, "initializationState": account.InitializationState,
|
||||
"initializationGeneration": account.InitializationGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,35 +461,9 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) {
|
||||
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
|
||||
// 登录 Key、吊销全部会话、审计。
|
||||
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
if _, ok := s.requireAdmin(w, r); ok {
|
||||
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
|
||||
}
|
||||
owner := r.PathValue("owner")
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validPassword(req.Password); err != nil {
|
||||
http.Error(w, "new password must be 8+ characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hash, err := store.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
st, _ := s.st.GetUserAuthState(owner)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"owner": st.Owner, "mustChangePassword": st.MustChangePassword,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 管理员:一次性登录 Key(恢复流程主入口)----
|
||||
@@ -476,38 +471,9 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request)
|
||||
// issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。
|
||||
// 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。
|
||||
func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
if _, ok := s.requireAdmin(w, r); ok {
|
||||
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
|
||||
}
|
||||
owner := strings.TrimSpace(r.PathValue("owner"))
|
||||
var req struct {
|
||||
Purpose string `json:"purpose"`
|
||||
TTLMinutes int `json:"ttlMinutes"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil && !errors.Is(err, io.EOF) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
purpose := req.Purpose
|
||||
if purpose == "" {
|
||||
purpose = store.LoginKeyPurposeRecover
|
||||
}
|
||||
if purpose != store.LoginKeyPurposeRecover && purpose != store.LoginKeyPurposeActivate {
|
||||
http.Error(w, "purpose must be recover or activate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ttl := time.Duration(req.TTLMinutes) * time.Minute
|
||||
plain, meta, err := s.st.IssueLoginKey(owner, purpose, actor, ttl)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": meta.ID, "owner": meta.Owner, "purpose": meta.Purpose,
|
||||
"status": meta.Status, "expiresAt": meta.ExpiresAt, "createdBy": meta.CreatedBy,
|
||||
"key": plain, // 明文仅此一次;关闭弹层后不可再取
|
||||
})
|
||||
}
|
||||
|
||||
// listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。
|
||||
@@ -525,7 +491,7 @@ func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor); err != nil {
|
||||
if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor, r.PathValue("owner")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
}
|
||||
p := r.URL.Path
|
||||
// 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" {
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" || p == "/api/auth/recovery-grants/redeem" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -328,8 +328,8 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 首登改密:不要求 current;成功后销毁会话,须重新密码登录。
|
||||
if rec := session("POST", "/api/auth/password", `{"next":"admin-final-9"}`); rec.Code != http.StatusOK ||
|
||||
// 历史初始密码必须提供 current;成功后销毁会话,须重新密码登录。
|
||||
if rec := session("POST", "/api/auth/password", `{"current":"boot-pass-00","next":"admin-final-9"}`); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -387,11 +387,11 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("must_change non-admin create must 403: %d", rec.Code)
|
||||
}
|
||||
// must_change 改密忽略 current;短密码拒绝;成功后 reloginRequired。
|
||||
// must_change 改密要求 current;短密码拒绝;成功后 reloginRequired。
|
||||
if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("short password must 400: %d", rec.Code)
|
||||
}
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"ignored","next":"alice-final-9"}`); rec.Code != http.StatusOK ||
|
||||
if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK ||
|
||||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -419,19 +419,19 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
t.Fatalf("non-admin create must 403: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 管理员重置 alice 密码 → 其旧会话吊销 + 首登再改密。
|
||||
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("admin reset: %d %s", rec.Code, rec.Body.String())
|
||||
// 旧管理员重置入口关闭,不能动 alice 的密码与当前会话。
|
||||
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusGone {
|
||||
t.Fatalf("admin reset must be closed: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("alice old session must be revoked after reset: %d", rec.Code)
|
||||
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("closed reset must preserve alice session: %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`))
|
||||
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 !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("reset must force change on next login: %s", rec.Body.String())
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("closed reset must preserve normal login: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。
|
||||
@@ -454,7 +454,7 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`))
|
||||
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)
|
||||
|
||||
@@ -13,10 +13,14 @@ import (
|
||||
func TestRedeemLoginKeyRestrictedSession(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
if err := st.CreateAccount("carol", "", "", false); err != nil {
|
||||
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("carol", store.LoginKeyPurposeActivate, "test", 0)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -35,7 +34,7 @@ func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string {
|
||||
if tok == "" {
|
||||
t.Fatal("missing session cookie")
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"next":"admin-final-9"}`))
|
||||
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()
|
||||
@@ -89,145 +88,48 @@ func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRec
|
||||
func TestAdminLoginKeyRecoveryFlow(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
adminTok := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("mallory", "", "", false); err != nil {
|
||||
admin := bootAdmin(t, h, st)
|
||||
hash, err := store.HashPassword("victim-old-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("pleb", "", "", false); err != nil {
|
||||
if err := st.CreateAccount("victim", "", hash, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 匿名/无会话不能签发。
|
||||
if rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, "not-a-session"); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous issue: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 管理员签发:返回明文一次 + 元数据。
|
||||
rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, adminTok)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var issued struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
|
||||
legacy, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(issued.Key, "alk_") || issued.ID == "" {
|
||||
t.Fatalf("issue payload: %+v", issued)
|
||||
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())
|
||||
}
|
||||
|
||||
// 列表:状态可见、无明文。
|
||||
rec = do(h, "GET", "/api/admin/users/mallory/login-keys", "", adminTok)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "alk_") {
|
||||
t.Fatalf("list must not leak plaintext: %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 !strings.Contains(rec.Body.String(), `"status":"unused"`) {
|
||||
t.Fatalf("list should show unused status: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 用户兑换:只提交 Key,身份由服务端绑定决定。
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"owner":"mallory"`) ||
|
||||
!strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) {
|
||||
t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
userTok := cookieOf(rec)
|
||||
|
||||
// 重复兑换失败。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("replay redeem: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 受限会话:业务与管理员入口均拒绝。
|
||||
if rec := do(h, "GET", "/api/projects", "", userTok); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("business api: %d", rec.Code)
|
||||
}
|
||||
if rec := do(h, "POST", "/api/admin/users/pleb/login-keys", `{}`, userTok); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("restricted session must not issue keys: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 设密(无 current)→ reloginRequired → 新密码登录。
|
||||
rec = do(h, "POST", "/api/auth/password", `{"next":"mallory-final-9"}`, userTok)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
|
||||
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"mallory","password":"mallory-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(), `"mustChangePassword":true`) {
|
||||
t.Fatalf("login with new password: %d %s", rec.Code, rec.Body.String())
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// 补发与撤销:旧 Key 作废、旧受限会话失效。
|
||||
func TestAdminLoginKeyReissueAndRevoke(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
adminTok := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("nia", "", "", false); err != nil {
|
||||
admin := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("victim", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issue := func() (string, string) {
|
||||
rec := do(h, "POST", "/api/admin/users/nia/login-keys", `{}`, adminTok)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return out.ID, out.Key
|
||||
legacy, meta, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldID, oldKey := issue()
|
||||
_ = oldID
|
||||
|
||||
// 兑换旧 Key 得受限会话;补发后该会话失效、旧 Key 不可再用。
|
||||
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
oldSess := cookieOf(rec)
|
||||
if oldSess == "" {
|
||||
t.Fatal("redeem old key failed")
|
||||
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())
|
||||
}
|
||||
issue()
|
||||
if rec := do(h, "GET", "/api/me", "", oldSess); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("restricted session must die after reissue: %d", rec.Code)
|
||||
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())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("consumed old key must not redeem again")
|
||||
}
|
||||
|
||||
// 撤销端点:未用 Key 撤销后不可兑换。
|
||||
newID, newKey := issue()
|
||||
if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/"+newID, "", adminTok); rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+newKey+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("revoked key must not redeem")
|
||||
}
|
||||
// 不存在的编号 404。
|
||||
if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/lk_nope", "", adminTok); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown key revoke: %d", rec.Code)
|
||||
if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK {
|
||||
t.Fatal("revoked legacy key redeemed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +155,7 @@ func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) {
|
||||
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", `{"next":"oscar-final-9"}`, tok)
|
||||
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())
|
||||
}
|
||||
@@ -271,10 +173,14 @@ func TestActiveChangePasswordRequiresCurrent(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
_ = srv
|
||||
if err := st.CreateAccount("petra", "", "", false); err != nil {
|
||||
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("petra", store.LoginKeyPurposeActivate, "test", 0)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain})
|
||||
}
|
||||
|
||||
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")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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 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 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 TestLegacyAdministratorRecoveryEntrancesAreClosed(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/password", `{"password":"attacker-pass"}`},
|
||||
{"/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")
|
||||
}
|
||||
}
|
||||
+42
-21
@@ -42,26 +42,27 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
projectLifecycle *extension.ProjectLifecycle
|
||||
projectHost func(string) extension.Host
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
accountSearchSecret [32]byte
|
||||
projectLifecycle *extension.ProjectLifecycle
|
||||
projectHost func(string) extension.Host
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
|
||||
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
|
||||
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
|
||||
@@ -160,7 +161,8 @@ func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.
|
||||
if err != nil {
|
||||
panic(err) // embed 布局错误属构建期问题
|
||||
}
|
||||
return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub}
|
||||
return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub,
|
||||
accountSearchSecret: newAccountSearchSecret()}
|
||||
}
|
||||
|
||||
// NLIntentGate 意图闸门端口(intentgate.Gate 实现;避免包依赖方向反转)。
|
||||
@@ -282,15 +284,25 @@ 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)
|
||||
mux.HandleFunc("GET /api/admin/users/search", s.searchAdminUsers)
|
||||
mux.HandleFunc("POST /api/admin/users", s.createAdminUser)
|
||||
mux.HandleFunc("GET /api/me/profile", s.meProfile)
|
||||
mux.HandleFunc("PATCH /api/me/profile", s.patchMeProfile)
|
||||
mux.HandleFunc("GET /api/admin/users/{userId}", s.adminAccount)
|
||||
mux.HandleFunc("PATCH /api/admin/users/{userId}/profile", s.patchAdminAccountProfile)
|
||||
mux.HandleFunc("PUT /api/admin/users/{userId}/state", s.setAdminAccountState)
|
||||
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)
|
||||
@@ -845,6 +857,11 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
if sc := SessionScope(r); sc != "" {
|
||||
out["sessionScope"] = sc
|
||||
}
|
||||
if AuthVia(r) == AuthViaSession {
|
||||
if mode, err := s.st.SessionPasswordChangeMode(sessionToken(r)); err == nil {
|
||||
out["passwordChangeMode"] = mode
|
||||
}
|
||||
}
|
||||
if st, err := s.st.GetUserAuthState(owner); err == nil {
|
||||
out["isAdmin"] = st.IsAdmin
|
||||
out["mustChangePassword"] = st.MustChangePassword
|
||||
@@ -2166,6 +2183,10 @@ 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
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// DirectoryAccountRow is an internal candidate projection. It intentionally
|
||||
// excludes contact details, notes, credentials and administrator privileges.
|
||||
type DirectoryAccountRow struct {
|
||||
@@ -28,3 +34,58 @@ func (s *Store) ListDirectoryAccountRows() ([]DirectoryAccountRow, error) {
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// ListAdminAccountsForActor takes a consistent snapshot for the protected
|
||||
// system.users.view adapter. The actor's current admin/effective state is
|
||||
// checked under lock before any profile fields are read.
|
||||
func (s *Store) ListAdminAccountsForActor(actorOwner string) ([]Account, AccountDirectoryRevision, error) {
|
||||
var tx *sql.Tx
|
||||
var err error
|
||||
if s.Dialect() == "postgres" {
|
||||
tx, err = s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
|
||||
} else {
|
||||
tx, err = s.db.Begin()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, AccountDirectoryRevision{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
locked, err := s.lockAccountUsers(tx, actorOwner)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, AccountDirectoryRevision{}, ErrAccountForbidden
|
||||
}
|
||||
if err != nil {
|
||||
return nil, AccountDirectoryRevision{}, err
|
||||
}
|
||||
actor := locked[actorOwner]
|
||||
if !actor.Enabled || !actor.IsAdmin {
|
||||
return nil, AccountDirectoryRevision{}, ErrAccountForbidden
|
||||
}
|
||||
rows, err := tx.Query(s.q(`SELECT ` + accountColumns + ` FROM users ORDER BY owner,id`))
|
||||
if err != nil {
|
||||
return nil, AccountDirectoryRevision{}, err
|
||||
}
|
||||
accounts := []Account{}
|
||||
for rows.Next() {
|
||||
account, err := scanAccount(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, AccountDirectoryRevision{}, err
|
||||
}
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, AccountDirectoryRevision{}, err
|
||||
}
|
||||
rows.Close()
|
||||
var revision AccountDirectoryRevision
|
||||
if err := tx.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`).
|
||||
Scan(&revision.Public, &revision.StaffNo, &revision.Private); err != nil {
|
||||
return nil, AccountDirectoryRevision{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, AccountDirectoryRevision{}, err
|
||||
}
|
||||
return accounts, revision, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminAccountSearchSnapshotRequiresCurrentSystemAuthority(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
t.Logf("admin directory dialect=%s", st.Dialect())
|
||||
if err := st.CreateAccount("admin", "", "hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("ordinary", "", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Email: "alice@example.test", Notes: "private"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, revision, err := st.ListAdminAccountsForActor("admin")
|
||||
if err != nil || len(accounts) != 3 || revision.Private == "" {
|
||||
t.Fatalf("authorized snapshot: accounts=%d rev=%+v err=%v", len(accounts), revision, err)
|
||||
}
|
||||
if accounts[1].ID != pending.ID || accounts[1].Profile.Email != "alice@example.test" {
|
||||
t.Fatalf("profile snapshot or stable sort: %+v", accounts)
|
||||
}
|
||||
if _, _, err := st.ListAdminAccountsForActor("ordinary"); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("ordinary account read protected profiles: %v", err)
|
||||
}
|
||||
if _, err := st.DB().Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.ListAdminAccountsForActor("admin"); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled administrator read protected profiles: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,9 @@ type accountQuerier interface {
|
||||
QueryRow(string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
func scanAccount(row *sql.Row) (Account, error) {
|
||||
type accountScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanAccount(row accountScanner) (Account, error) {
|
||||
var a Account
|
||||
err := row.Scan(&a.ID, &a.Owner, &a.Profile.Name, &a.Profile.Phone, &a.Profile.Email,
|
||||
&a.Profile.StaffNo, &a.Profile.Department, &a.Profile.Notes, &a.ProfileVersion,
|
||||
|
||||
@@ -66,6 +66,9 @@ 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
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -71,6 +72,59 @@ func TestAccountStateDisablesCredentialsWithoutRestoringThem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountStateConcurrentLastTwoManagersAcrossPools(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
other := reopenAccountModelStore(t, st)
|
||||
t.Logf("last managers dialect=%s two independent pools", st.Dialect())
|
||||
for _, owner := range []string{"admin-a", "admin-b"} {
|
||||
if err := st.CreateAccount(owner, "", "existing-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ClearMustChangePassword(owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a, _ := st.GetAccountByOwner("admin-a")
|
||||
b, _ := st.GetAccountByOwner("admin-b")
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for i, item := range []struct {
|
||||
owner string
|
||||
account Account
|
||||
}{{"admin-a", a}, {"admin-b", b}} {
|
||||
conn := []*Store{st, other}[i]
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := conn.SetAccountEnabled(item.owner, item.account.ID, item.account.StateVersion, false)
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
wins, conflicts := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
wins++
|
||||
} else if errors.Is(err, ErrConflict) {
|
||||
conflicts++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if wins != 1 || conflicts != 1 {
|
||||
t.Fatalf("last-admin race wins=%d conflicts=%d", wins, conflicts)
|
||||
}
|
||||
a, _ = st.GetAccount(a.ID)
|
||||
b, _ = st.GetAccount(b.ID)
|
||||
if a.Enabled == b.Enabled {
|
||||
t.Fatalf("must retain exactly one operable manager: a=%v b=%v", a.Enabled, b.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountStateProtectsLastOperableSystemManager(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
hash, err := HashPassword("admin-password-1")
|
||||
|
||||
+15
-12
@@ -165,9 +165,14 @@ const sessionTTL = 7 * 24 * time.Hour
|
||||
|
||||
// AdminUser 管理员用户列表的展示视图(不含哈希)。
|
||||
type AdminUser struct {
|
||||
ID string `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
StateVersion int64 `json:"stateVersion"`
|
||||
ProfileVersion int64 `json:"profileVersion"`
|
||||
InitializationState string `json:"initializationState"`
|
||||
InitializationGeneration int64 `json:"initializationGeneration"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
HasPassword bool `json:"hasPassword"`
|
||||
@@ -249,15 +254,7 @@ func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) er
|
||||
|
||||
// SetUserPassword 设置/重置密码:must_change=1(首登强制改密)。
|
||||
func (s *Store) SetUserPassword(owner, passwordHash string) error {
|
||||
res, err := s.db.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`),
|
||||
passwordHash, nowISO(), true, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
return s.AdminResetUserPassword(owner, passwordHash, "legacy-store", "legacy-store")
|
||||
}
|
||||
|
||||
// ClearMustChangePassword 改密完成后清标志(改密者会话保留,其余吊销由调用方处理)。
|
||||
@@ -385,7 +382,11 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex
|
||||
var ver int
|
||||
var enabled bool
|
||||
var origin CredentialOrigin
|
||||
err = tx.QueryRow(s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`), owner).Scan(&ver, &enabled, &origin)
|
||||
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
|
||||
}
|
||||
@@ -448,7 +449,8 @@ func (s *Store) DeleteUserSessions(owner, exceptToken string) {
|
||||
|
||||
// ListUsersAdmin 管理员用户列表(按创建序)。
|
||||
func (s *Store) ListUsersAdmin() ([]AdminUser, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT owner,COALESCE(name,''),enabled,password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`))
|
||||
rows, err := s.db.Query(s.q(`SELECT id,owner,COALESCE(name,''),enabled,state_version,profile_version,initialization_state,initialization_generation,
|
||||
password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -457,7 +459,8 @@ func (s *Store) ListUsersAdmin() ([]AdminUser, error) {
|
||||
for rows.Next() {
|
||||
var u AdminUser
|
||||
var hash, psa, ca string
|
||||
if err := rows.Scan(&u.Owner, &u.Name, &u.Enabled, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Owner, &u.Name, &u.Enabled, &u.StateVersion, &u.ProfileVersion,
|
||||
&u.InitializationState, &u.InitializationGeneration, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.HasPassword = hash != ""
|
||||
|
||||
+338
-71
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -33,12 +35,66 @@ const (
|
||||
SessionScopeFull = "full"
|
||||
SessionScopeSetPasswordOnly = "set_password_only"
|
||||
|
||||
defaultLoginKeyTTL = 30 * time.Minute
|
||||
minLoginKeyTTL = 5 * time.Minute
|
||||
maxLoginKeyTTL = 120 * time.Minute
|
||||
restrictedSessionTTL = 10 * time.Minute
|
||||
defaultLoginKeyTTL = 30 * time.Minute
|
||||
minLoginKeyTTL = 5 * time.Minute
|
||||
maxLoginKeyTTL = 120 * time.Minute
|
||||
restrictedSessionTTL = 10 * time.Minute
|
||||
PasswordChangeModeGrantSetup = "grant_setup"
|
||||
PasswordChangeModeCurrentRequired = "current_required"
|
||||
)
|
||||
|
||||
// SessionPasswordChangeMode is server-derived. A legacy restricted session has
|
||||
// no mode and therefore cannot enter either password-writing branch.
|
||||
func (s *Store) SessionPasswordChangeMode(token string) (string, error) {
|
||||
if token == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
var method, origin, grantID, scope, exp string
|
||||
var sessVersion, userVersion int
|
||||
var enabled bool
|
||||
err := s.db.QueryRow(s.q(`SELECT s.auth_method,s.credential_origin,s.grant_id,s.scope,s.expires_at,s.auth_version,u.auth_version,u.enabled
|
||||
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
|
||||
Scan(&method, &origin, &grantID, &scope, &exp, &sessVersion, &userVersion, &enabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
expiry, err := time.Parse(time.RFC3339Nano, exp)
|
||||
if err != nil || time.Now().After(expiry) || !enabled || sessVersion != userVersion {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if method == AuthMethodPassword || (method == AuthMethodLegacyUnknown && scope == SessionScopeFull) {
|
||||
return PasswordChangeModeCurrentRequired, nil
|
||||
}
|
||||
if method == AuthMethodGrant && scope == SessionScopeSetPasswordOnly && grantID != "" &&
|
||||
(CredentialOrigin(origin) == CredentialOriginOwnerInitialization || CredentialOrigin(origin) == CredentialOriginOwnerRecovery) {
|
||||
var grantStatus, grantExpiry, grantOrigin, proposalID 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// LoginKeyMeta 对外可见元数据(无明文)。
|
||||
type LoginKeyMeta struct {
|
||||
ID string `json:"id"`
|
||||
@@ -50,6 +106,13 @@ type LoginKeyMeta struct {
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
}
|
||||
|
||||
type grantBinding struct {
|
||||
origin CredentialOrigin
|
||||
generation int64
|
||||
version int
|
||||
proposalID string
|
||||
}
|
||||
|
||||
// AccountEvent 账户生命周期审计行(不含任何凭据材料)。
|
||||
type AccountEvent struct {
|
||||
ID string `json:"id"`
|
||||
@@ -131,8 +194,12 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio
|
||||
|
||||
// insertLoginKeyTx 在既有事务内签发:撤销旧 Key、作废受限会话、插入新 Key、审计。
|
||||
// (BootstrapFirstAdmin 与建号同事务复用——KTD12:管理员行与激活 Key 共同提交。)
|
||||
func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time) (LoginKeyMeta, error) {
|
||||
func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time, bindings ...grantBinding) (LoginKeyMeta, error) {
|
||||
meta := LoginKeyMeta{}
|
||||
binding := grantBinding{origin: CredentialOriginLegacyUnknown}
|
||||
if len(bindings) == 1 {
|
||||
binding = bindings[0]
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`),
|
||||
LoginKeyStatusRevoked, owner, purpose, LoginKeyStatusUnused); err != nil {
|
||||
return meta, err
|
||||
@@ -141,8 +208,10 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by) VALUES(?,?,?,?,?,?,?,?)`),
|
||||
id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy); err != nil {
|
||||
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,proposal_id)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy,
|
||||
binding.origin, binding.generation, binding.version, binding.proposalID); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil {
|
||||
@@ -153,8 +222,10 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i
|
||||
|
||||
// BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 →
|
||||
// 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。
|
||||
// createdBy 保留旧调用签名,但审计主体总从实际本地 OS UID 取得。
|
||||
// SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。
|
||||
func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string, err error) {
|
||||
createdBy = fmt.Sprintf("local-owner:uid:%d", os.Geteuid())
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
@@ -182,9 +253,9 @@ func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string
|
||||
if n > 0 {
|
||||
return "", ErrConflict // 有数据环境拒绝接管(R11)
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin)
|
||||
VALUES(?,?,?,?,?,?,?,?)`),
|
||||
newID("usr"), owner, name, nowISO(), "", "", false, true); err != nil {
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin,initialization_state,initialization_generation)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`),
|
||||
newID("usr"), owner, name, nowISO(), "", "", false, true, InitializationPending, 1); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return "", ErrConflict
|
||||
}
|
||||
@@ -197,7 +268,8 @@ func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string
|
||||
if n != 1 {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp); err != nil {
|
||||
if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp,
|
||||
grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "bootstrap_admin", owner); err != nil {
|
||||
@@ -239,6 +311,12 @@ func entranceOf(createdBy string) string {
|
||||
if createdBy == "" {
|
||||
return "api"
|
||||
}
|
||||
if strings.HasPrefix(createdBy, "local-owner:") {
|
||||
return "local-owner-cli"
|
||||
}
|
||||
if strings.HasPrefix(createdBy, "owner-proof:") {
|
||||
return "owner-http"
|
||||
}
|
||||
if createdBy == "cli" || createdBy == "cli-bootstrap" || (len(createdBy) > 4 && createdBy[:4] == "cli-") {
|
||||
return "cli"
|
||||
}
|
||||
@@ -260,7 +338,7 @@ func (s *Store) RevokeLoginKey(id string) error {
|
||||
|
||||
// RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员
|
||||
// HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。
|
||||
func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error) {
|
||||
func (s *Store) RevokeLoginKeyCascade(id, actor string, expectedOwner ...string) (owner string, err error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -273,6 +351,9 @@ func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(expectedOwner) > 0 && expectedOwner[0] != owner {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`),
|
||||
LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil {
|
||||
return "", err
|
||||
@@ -336,24 +417,75 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
|
||||
if plain == "" || len(plain) < 5 || plain[:4] != "alk_" {
|
||||
return "", "", ErrNotFound
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var id, o, status, exp string
|
||||
err = tx.QueryRow(s.q(`SELECT id,owner,status,expires_at FROM login_keys WHERE key_hash=?`), hashKey(plain)).
|
||||
Scan(&id, &o, &status, &exp)
|
||||
// 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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
locked, err := s.lockAccountUsers(tx, routeOwner)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
account := locked[routeOwner]
|
||||
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 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=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
grantQuery += ` FOR UPDATE`
|
||||
}
|
||||
err = tx.QueryRow(grantQuery, hashKey(plain)).Scan(&id, &o, &status, &exp, &purpose, &grantOrigin, &generation, &issuedVersion, &proposalID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if o != routeOwner || proposalID != routeProposal || 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) {
|
||||
return "", "", ErrAccountForbidden
|
||||
}
|
||||
default:
|
||||
// Migrated administrator-issued keys have legacy_unknown provenance.
|
||||
return "", "", ErrAccountForbidden
|
||||
}
|
||||
expT, err := time.Parse(time.RFC3339Nano, exp)
|
||||
if err != nil || time.Now().After(expT) {
|
||||
_, _ = tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusExpired, id, LoginKeyStatusUnused)
|
||||
_ = tx.Commit()
|
||||
return "", "", ErrNotFound
|
||||
}
|
||||
if status != LoginKeyStatusUnused {
|
||||
@@ -367,23 +499,14 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return "", "", ErrConflict
|
||||
}
|
||||
var authVersion int
|
||||
var enabled bool
|
||||
err = tx.QueryRow(s.q(`SELECT auth_version,enabled FROM users WHERE owner=?`), o).Scan(&authVersion, &enabled)
|
||||
if err != nil {
|
||||
return "", "", err // 账户已不存在:兑换失败整体回滚
|
||||
}
|
||||
if !enabled {
|
||||
return "", "", ErrAccountForbidden
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
token = "sess_" + hex.EncodeToString(raw)
|
||||
sessExp := time.Now().Add(restrictedSessionTTL)
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
|
||||
hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, authVersion); err != nil {
|
||||
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), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, account.AuthVersion, AuthMethodGrant, grantOrigin, id); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, o, "login-key", o, "login_key_redeemed", id); err != nil {
|
||||
@@ -395,51 +518,102 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
|
||||
return token, o, nil
|
||||
}
|
||||
|
||||
// CompleteSetPasswordChecked 设密事务(恢复/强制路径):在最终事务内复验会话
|
||||
// (存在、未过期、scope 受限或账户仍处强制态、auth_version 未变),然后统一
|
||||
// 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、
|
||||
// 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。
|
||||
// CompleteSetPasswordChecked accepts only a restricted session derived from a
|
||||
// trusted owner initialization or owner-confirmed recovery grant. Historical
|
||||
// key sessions and password-authenticated forced sessions cannot use it.
|
||||
func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var scope, sessExp string
|
||||
var sessVer, userVer int
|
||||
var mustChange, enabled bool
|
||||
var hash sql.NullString
|
||||
err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash,u.enabled
|
||||
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
|
||||
Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash, &enabled)
|
||||
var routeGrant string
|
||||
err = s.db.QueryRow(s.q(`SELECT owner,grant_id FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner, &routeGrant)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !enabled {
|
||||
if routeGrant == "" {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) {
|
||||
return "", ErrNotFound
|
||||
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
|
||||
}
|
||||
// 受限会话、待改密、未设密三者之一才允许免旧密码设密;其余按主动改密路径走。
|
||||
if scope != SessionScopeSetPasswordOnly && !mustChange && (hash.Valid && hash.String != "") {
|
||||
return "", ErrConflict
|
||||
}
|
||||
// 会话创建后账户凭据已变(并发改密/重置):本次授权过期,拒绝继续。
|
||||
if sessVer != userVer {
|
||||
return "", ErrConflict
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
|
||||
passwordHash, nowISO(), false, owner)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
defer tx.Rollback()
|
||||
accounts, err := s.lockAccountUsers(tx, owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account := accounts[owner]
|
||||
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=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
grantQuery += ` FOR UPDATE`
|
||||
}
|
||||
var grantOwner, purpose, status, grantExpiry, origin, proposalID string
|
||||
var generation int64
|
||||
var issuedVersion int
|
||||
if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion, &proposalID); 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=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
sessionQuery += ` FOR UPDATE`
|
||||
}
|
||||
var sessionOwner, scope, sessionExpiry, method, sessionOrigin, grantID string
|
||||
var sessionVersion int
|
||||
if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &sessionExpiry, &sessionVersion, &method, &sessionOrigin, &grantID); err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
grantExpires, err := time.Parse(time.RFC3339Nano, grantExpiry)
|
||||
if err != nil || time.Now().After(grantExpires) {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
default:
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
res, err := tx.Exec(s.q(`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=?`),
|
||||
passwordHash, nowISO(), false, origin, InitializationInitialized, owner, account.AuthVersion, true)
|
||||
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
|
||||
@@ -447,11 +621,83 @@ 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
|
||||
}
|
||||
entrance := "must-change"
|
||||
if scope == SessionScopeSetPasswordOnly {
|
||||
entrance = "recovery"
|
||||
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 := s.insertAccountEvent(tx, owner, entrance, owner, "password_set", entrance); err != nil {
|
||||
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
|
||||
}
|
||||
return owner, tx.Commit()
|
||||
}
|
||||
|
||||
// CompleteForcedPasswordChecked preserves the provenance of a historical
|
||||
// password-authenticated account. It requires the current password even when
|
||||
// the session is restricted by must_change_password.
|
||||
func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash string) (string, error) {
|
||||
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
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
accounts, err := s.lockAccountUsers(tx, owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account := accounts[owner]
|
||||
if !account.Enabled || !account.MustChangePassword || !account.HasPassword {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
var hash string
|
||||
if err := tx.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := s.q(`SELECT scope,auth_method,expires_at,auth_version FROM sessions WHERE token_hash=? AND owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var scope, method, expiresAt string
|
||||
var version int
|
||||
if err := tx.QueryRow(query, hashKey(token), owner).Scan(&scope, &method, &expiresAt, &version); err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
expiry, err := time.Parse(time.RFC3339Nano, expiresAt)
|
||||
if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
return "", ErrBadCredentials
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1
|
||||
WHERE owner=? AND auth_version=? AND enabled=? AND must_change_password=?`),
|
||||
passwordHash, nowISO(), false, owner, account.AuthVersion, true, true)
|
||||
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(`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
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, owner, "password-session", owner, "password_change_forced", ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return owner, tx.Commit()
|
||||
@@ -468,7 +714,11 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken
|
||||
var hash string
|
||||
var ver int
|
||||
var mustChange, enabled bool
|
||||
err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`), owner).
|
||||
accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
accountQuery += ` FOR UPDATE`
|
||||
}
|
||||
err = tx.QueryRow(accountQuery, owner).
|
||||
Scan(&hash, &ver, &mustChange, &enabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
@@ -510,6 +760,12 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken
|
||||
} else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -524,18 +780,29 @@ func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance stri
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
|
||||
passwordHash, nowISO(), true, owner)
|
||||
locked, err := s.lockAccountUsers(tx, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !locked[owner].Enabled {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1,
|
||||
credential_origin=?,initialization_state=? WHERE owner=? AND auth_version=? AND enabled=?`),
|
||||
passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, owner, locked[owner].AuthVersion, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
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(`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
|
||||
}
|
||||
|
||||
@@ -15,18 +15,32 @@ func TestOpenExistingSQLiteMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func pendingActivationFixture(t *testing.T, st *Store, owner string) (Account, string) {
|
||||
t.Helper()
|
||||
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.CreatePendingAccount("admin", owner, AccountProfile{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return account, plain
|
||||
}
|
||||
|
||||
func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, meta, err := st.IssueLoginKey("alice", LoginKeyPurposeActivate, "test", 0)
|
||||
if err != nil || plain == "" || meta.ID == "" {
|
||||
t.Fatalf("issue: %v meta=%+v", err, meta)
|
||||
_, plain := pendingActivationFixture(t, st, "alice")
|
||||
keys, err := st.ListLoginKeys("alice")
|
||||
if err != nil || plain == "" || len(keys) != 1 || keys[0].ID == "" {
|
||||
t.Fatalf("issue: %v keys=%+v", err, keys)
|
||||
}
|
||||
tok, owner, err := st.RedeemLoginKey(plain)
|
||||
if err != nil || owner != "alice" || tok == "" {
|
||||
@@ -72,13 +86,7 @@ func TestLoginKeyConcurrentRedeem(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("bob", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("bob", LoginKeyPurposeRecover, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, plain := pendingActivationFixture(t, st, "bob")
|
||||
var okCount atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
@@ -103,18 +111,16 @@ func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("carol", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, old1 := pendingActivationFixture(t, st, "carol")
|
||||
oldTok, _, err := st.RedeemLoginKey(old1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old2, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0)
|
||||
account, err = st.GetAccount(account.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old2, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -123,7 +129,11 @@ func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) {
|
||||
t.Fatal("restricted session from superseded key should be revoked")
|
||||
}
|
||||
// 旧 Key 未用场景同样撤销(old1 已消费,此处验证 old2 补发路径)。
|
||||
new1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0)
|
||||
account, err = st.GetAccount(account.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
new1, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -150,19 +160,17 @@ func TestCompleteSetPasswordRevalidatesSession(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("dave", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, plain := pendingActivationFixture(t, st, "dave")
|
||||
tok, _, err := st.RedeemLoginKey(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 会话被签发补发作废后,设密必须失败。
|
||||
if _, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0); err != nil {
|
||||
account, err = st.GetAccount(account.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := HashPassword("dave-final-99")
|
||||
@@ -177,7 +185,11 @@ func TestCompleteSetPasswordRevalidatesSession(t *testing.T) {
|
||||
t.Fatal("password must be unchanged after rejected set")
|
||||
}
|
||||
// 新 Key 兑换出的会话可以设密,且版本前进、旧密码不适用(未设密前无旧密码)。
|
||||
plain2, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0)
|
||||
account, err = st.GetAccount(account.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain2, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -338,18 +350,20 @@ func TestRevokeLoginKeyCascade(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.CreateAccount("hana", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, meta, err := st.IssueLoginKey("hana", LoginKeyPurposeRecover, "test", 0)
|
||||
if err != nil {
|
||||
_, plain := pendingActivationFixture(t, st, "hana")
|
||||
issued, err := st.ListLoginKeys("hana")
|
||||
if err != nil || len(issued) != 1 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := issued[0]
|
||||
tok, _, err := st.RedeemLoginKey(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin"); err != nil {
|
||||
if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin", "other-owner"); err == nil {
|
||||
t.Fatal("path owner mismatch revoked another user's grant")
|
||||
}
|
||||
if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin", "hana"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := st.SessionInfo(tok); ok {
|
||||
|
||||
@@ -855,9 +855,42 @@ func init() {
|
||||
},
|
||||
down: map[string][]string{"sqlite": accountFoundationDown, "postgres": accountFoundationDown},
|
||||
},
|
||||
migration{version: 32, name: "owner_proof_recovery",
|
||||
up: map[string][]string{"sqlite": recoveryFoundationUp, "postgres": recoveryFoundationUp},
|
||||
down: map[string][]string{"sqlite": recoveryFoundationDown, "postgres": recoveryFoundationDown},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var recoveryFoundationUp = []string{
|
||||
`ALTER TABLE login_keys ADD COLUMN grant_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`,
|
||||
`ALTER TABLE login_keys ADD COLUMN generation BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE login_keys ADD COLUMN issued_auth_version INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE login_keys ADD COLUMN proposal_id TEXT NOT NULL DEFAULT ''`,
|
||||
`CREATE TABLE recovery_proposals (
|
||||
id TEXT PRIMARY KEY,user_id TEXT NOT NULL,owner TEXT NOT NULL,status TEXT NOT NULL,
|
||||
version BIGINT NOT NULL DEFAULT 1,generation BIGINT NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL,created_at TEXT NOT NULL,expires_at TEXT NOT NULL,
|
||||
issued_auth_version INTEGER NOT NULL,idempotency_key TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE INDEX idx_recovery_proposals_owner_status ON recovery_proposals(owner,status)`,
|
||||
`CREATE UNIQUE INDEX uq_recovery_proposals_actor_idempotency ON recovery_proposals(created_by,idempotency_key) WHERE idempotency_key<>''`,
|
||||
`CREATE TABLE recovery_proposal_proofs (
|
||||
proposal_id TEXT NOT NULL,session_hash TEXT NOT NULL,PRIMARY KEY(proposal_id,session_hash)
|
||||
)`,
|
||||
}
|
||||
|
||||
var recoveryFoundationDown = []string{
|
||||
`DROP TABLE recovery_proposal_proofs`,
|
||||
`DROP INDEX uq_recovery_proposals_actor_idempotency`,
|
||||
`DROP INDEX idx_recovery_proposals_owner_status`,
|
||||
`DROP TABLE recovery_proposals`,
|
||||
`ALTER TABLE login_keys DROP COLUMN proposal_id`,
|
||||
`ALTER TABLE login_keys DROP COLUMN issued_auth_version`,
|
||||
`ALTER TABLE login_keys DROP COLUMN generation`,
|
||||
`ALTER TABLE login_keys DROP COLUMN grant_origin`,
|
||||
}
|
||||
|
||||
// v31 preserves legacy identities and credentials. Only the explicit pending
|
||||
// account primitive sets pending initialization; migration cannot infer proof.
|
||||
func accountFoundationUp(enabledType, revisionExpression string) []string {
|
||||
|
||||
@@ -36,6 +36,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
29: false, // redeem_attempts:新表,旧读者不受影响
|
||||
30: false, // project_event_publication_proofs:新表,旧读者不受影响
|
||||
31: true, // 账号有效态/凭据来源是安全协议边界;即使旧 SQL 可读,也不得恢复旧认证写者
|
||||
32: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InitializePendingAccount is reserved for the local machine/DB owner CLI. It
|
||||
// accepts a stable user ID and expected versions, never an actor string from a
|
||||
// request. The audit actor is the actual local OS UID. The caller must use the
|
||||
// protected delivery channel; a failed delivery is retried with the same ID.
|
||||
func (s *Store) InitializePendingAccount(userID string, expectedStateVersion, expectedGeneration int64) (string, error) {
|
||||
if userID == "" || expectedStateVersion < 1 || expectedGeneration < 0 {
|
||||
return "", ErrConflict
|
||||
}
|
||||
pre, err := s.GetAccount(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain := "alk_" + hex.EncodeToString(raw[:])
|
||||
operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid())
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
locked, err := s.lockAccountUsers(tx, pre.Owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account := locked[pre.Owner]
|
||||
if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if !account.Enabled || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
var unsafeCreds int
|
||||
err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM api_keys WHERE owner=? AND revoked=0`), account.Owner).Scan(&unsafeCreds)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if unsafeCreds != 0 {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM sessions WHERE owner=? AND scope=?`), account.Owner, SessionScopeFull).Scan(&unsafeCreds)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if unsafeCreds != 0 {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM login_keys WHERE owner=? AND grant_origin<>?`), account.Owner, CredentialOriginOwnerInitialization).Scan(&unsafeCreds)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if unsafeCreds != 0 {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
nextGeneration := account.InitializationGeneration + 1
|
||||
nextAuthVersion := account.AuthVersion + 1
|
||||
result, err := tx.Exec(s.q(`UPDATE users SET initialization_generation=?,state_version=state_version+1,auth_version=auth_version+1
|
||||
WHERE id=? AND state_version=? AND initialization_generation=? AND initialization_state=? AND password_hash='' AND enabled=?`),
|
||||
nextGeneration, userID, expectedStateVersion, expectedGeneration, InitializationPending, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, err := result.RowsAffected(); err != nil || n != 1 {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if _, err := s.insertLoginKeyTx(tx, account.Owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL),
|
||||
grantBinding{origin: CredentialOriginOwnerInitialization, generation: nextGeneration, version: nextAuthVersion}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, operator, "local-owner-cli", account.Owner, "initialization_issued", fmt.Sprintf("user_id=%s generation=%d", userID, nextGeneration)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// CreateLocalPendingAccount is the local owner-only new-account path. Account
|
||||
// and first activation grant commit together, including a local administrator
|
||||
// flag when explicitly requested by the machine/DB owner CLI.
|
||||
func (s *Store) CreateLocalPendingAccount(owner, name string, isAdmin bool) (Account, string, error) {
|
||||
operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid())
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
plain := "alk_" + hex.EncodeToString(raw[:])
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
var users int
|
||||
if err := tx.QueryRow(`SELECT COUNT(1) FROM users`).Scan(&users); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if users == 0 {
|
||||
return Account{}, "", ErrConflict // first administrator requires BootstrapFirstAdmin
|
||||
}
|
||||
account, err := s.insertPendingAccountTx(tx, owner, AccountProfile{Name: name})
|
||||
if err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), isAdmin, 1, account.ID); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL),
|
||||
grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, operator, "local-owner-cli", owner, "local_account_created", account.ID); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
account.IsAdmin, account.InitializationGeneration = isAdmin, 1
|
||||
return account, plain, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOwnerInitializationReissuesSamePendingIdentityAndClosesAfterCompletion(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
t.Logf("owner initialization dialect=%s", st.Dialect())
|
||||
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Name: "Alice"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration)
|
||||
if err != nil || first == "" {
|
||||
t.Fatalf("initialize pending: %v", err)
|
||||
}
|
||||
firstSession, owner, err := st.RedeemLoginKey(first)
|
||||
if err != nil || owner != "alice" {
|
||||
t.Fatalf("redeem first activation: %v %q", err, owner)
|
||||
}
|
||||
stillPending, err := st.GetAccount(pending.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := st.InitializePendingAccount(pending.ID, stillPending.StateVersion, stillPending.InitializationGeneration)
|
||||
if err != nil || second == first {
|
||||
t.Fatalf("reissue: %v", err)
|
||||
}
|
||||
if _, _, ok := st.SessionInfo(firstSession); ok {
|
||||
t.Fatal("reissue left old restricted session active")
|
||||
}
|
||||
secondSession, owner, err := st.RedeemLoginKey(second)
|
||||
if err != nil || owner != "alice" {
|
||||
t.Fatalf("redeem second activation: %v %q", err, owner)
|
||||
}
|
||||
hash, err := HashPassword("alice-initialized-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CompleteSetPasswordChecked(secondSession, hash); err != nil {
|
||||
t.Fatalf("complete initial password: %v", err)
|
||||
}
|
||||
completed, err := st.GetAccount(pending.ID)
|
||||
if err != nil || completed.ID != pending.ID || completed.Owner != pending.Owner || completed.InitializationState != InitializationInitialized ||
|
||||
completed.CredentialOrigin != CredentialOriginOwnerInitialization || !completed.HasPassword {
|
||||
t.Fatalf("completion changed identity or lost trusted lineage: %+v %v", completed, err)
|
||||
}
|
||||
if _, err := st.InitializePendingAccount(pending.ID, completed.StateVersion, completed.InitializationGeneration); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("completed account could be initialized again: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
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 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
|
||||
}
|
||||
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
|
||||
}
|
||||
plain, id, err := newRecoveryGrant()
|
||||
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() ||
|
||||
p.Version != expectedVersion || p.Generation != expectedGeneration || time.Now().After(p.ExpiresAt) {
|
||||
return "", RecoveryProposal{}, ErrConflict
|
||||
}
|
||||
if (!rotate && p.Status != RecoveryProposalPending) || (rotate && p.Status != RecoveryProposalConfirmed) {
|
||||
return "", RecoveryProposal{}, ErrConflict
|
||||
}
|
||||
if err := s.eligibleRecoveryProof(tx, account, p, token); 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 := 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 := tx.Commit(); err != nil {
|
||||
return RecoveryProposal{}, err
|
||||
}
|
||||
p.Status, p.Version = RecoveryProposalRevoked, p.Version+1
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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{})
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for _, conn := range []*Store{st, other} {
|
||||
wg.Add(1)
|
||||
go func(conn *Store) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _, err := conn.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version)
|
||||
results <- err
|
||||
}(conn)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
wins, conflicts := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
wins++
|
||||
} else if errors.Is(err, ErrConflict) {
|
||||
conflicts++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if wins != 1 || conflicts != 1 {
|
||||
t.Fatalf("concurrent confirmation wins=%d conflicts=%d", wins, 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)
|
||||
}
|
||||
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 _, _, 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)
|
||||
}
|
||||
}
|
||||
@@ -549,6 +549,9 @@ func TestAdminAccountsAndSessions(t *testing.T) {
|
||||
if err := st.SetUserPassword("admin", hash2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := st.SessionOwner(tok); ok {
|
||||
t.Fatal("password reset must revoke prior session")
|
||||
}
|
||||
if err := st.ClearMustChangePassword("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -556,6 +559,10 @@ func TestAdminAccountsAndSessions(t *testing.T) {
|
||||
if st1.MustChangePassword {
|
||||
t.Fatal("must_change should clear after self-change")
|
||||
}
|
||||
tok, err = st.CreateSession("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.DeleteUserSessions("admin", tok)
|
||||
if _, ok := st.SessionOwner(tok); !ok {
|
||||
t.Fatal("kept session (except-path) must survive")
|
||||
|
||||
@@ -55,6 +55,9 @@ type AccountDirectoryLookup struct {
|
||||
type AccountDirectoryProvider interface {
|
||||
QueryAccountDirectory(context.Context, AccountDirectoryQuery) (AccountDirectoryPage, error)
|
||||
LookupAccountDirectory(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error)
|
||||
// LookupAccountDirectoryOwner is an explicit legacy-owner compatibility
|
||||
// lookup; it resolves to the same stable user ID and applies the same scope.
|
||||
LookupAccountDirectoryOwner(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error)
|
||||
}
|
||||
|
||||
// AccountDirectoryScopeRequest is constructed by the Backend from the trusted
|
||||
@@ -70,12 +73,14 @@ type AccountDirectoryScopeRequest struct {
|
||||
|
||||
// AccountDirectoryScope is returned by the trusted in-process domain resolver.
|
||||
// AllCandidates is explicit: a nil candidate list never means global access.
|
||||
// Candidates are stable user IDs and are applied before search and pagination.
|
||||
// CandidateIDs or CandidateOwners are applied before search and pagination;
|
||||
// domain membership stores may use owners while the provider returns stable IDs.
|
||||
type AccountDirectoryScope struct {
|
||||
AllowedFields []string
|
||||
MatchFields []string
|
||||
AllCandidates bool
|
||||
CandidateIDs []string
|
||||
CandidateOwners []string
|
||||
ScopeVersion string
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ AccountDirectoryModeFuzzy
|
||||
AccountDirectoryPage
|
||||
AccountDirectoryProvider
|
||||
AccountDirectoryProvider.LookupAccountDirectory
|
||||
AccountDirectoryProvider.LookupAccountDirectoryOwner
|
||||
AccountDirectoryProvider.QueryAccountDirectory
|
||||
AccountDirectoryPurposeDocumentDuties
|
||||
AccountDirectoryPurposeProjectMembers
|
||||
|
||||
Reference in New Issue
Block a user