diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 7df1a50..4b4a03b 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -9,6 +9,7 @@ import ( "flag" "fmt" "os" + "time" "agent-system-backend/internal/app" "agent-system-backend/internal/config" @@ -222,11 +223,11 @@ func runAPIKey(args []string) int { } } -// runUser:用户账号管理子命令(管理员发放制,R2)——create / reset-password / list。 -// 密码未显式给定时生成随机值,明文仅此一次输出;创建/重置后首次登录强制改密。 +// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。 +// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。 func runUser(args []string) int { if len(args) == 0 { - fmt.Fprintln(os.Stderr, "用法:agent-system user [--owner x --name y --admin --password p]") + fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") return 2 } sub := args[0] @@ -235,8 +236,15 @@ func runUser(args []string) int { dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)") owner := fs.String("owner", "", "用户名(owner)") name := fs.String("name", "", "显示名") - password := fs.String("password", "", "密码(空 = 随机生成并打印一次)") - admin := fs.Bool("admin", false, "管理员(可管理用户与密钥)") + password := fs.String("password", "", "密码(create 空=激活 Key;加 --generate-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 与会话") + keyID := fs.String("id", "", "revoke-login-key 凭据编号") fs.Parse(args[1:]) switch sub { case "create": @@ -244,13 +252,24 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user create: --owner 必填") return 2 } - pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, *admin) + isAdmin := *admin + switch *role { + case "user": + case "admin": + isAdmin = true + default: + fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role) + return 2 + } + pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword) if err != nil { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } fmt.Println(pw) - if *password == "" { + if *password == "" && !*generatePassword { + fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。") + } else if *password == "" { fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。") } return 0 @@ -259,7 +278,7 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填") return 2 } - pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password) + pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting) if err != nil { fmt.Fprintln(os.Stderr, "user reset-password:", err) return 1 @@ -270,7 +289,7 @@ func runUser(args []string) int { } return 0 case "list": - users, err := app.ListUserAccounts(*dsn, *data) + users, err := app.ListUserAccounts(*dsn, *data, *openExisting) if err != nil { fmt.Fprintln(os.Stderr, "user list:", err) return 1 @@ -278,6 +297,47 @@ func runUser(args []string) int { out, _ := json.MarshalIndent(users, "", " ") 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 + } + fmt.Println(plain) + fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。") + return 0 + case "revoke-login-key": + if *keyID == "" { + fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填") + return 2 + } + if err := app.RevokeUserLoginKey(*dsn, *data, *keyID, *openExisting); err != nil { + fmt.Fprintln(os.Stderr, "user revoke-login-key:", err) + return 1 + } + fmt.Println("revoked") + return 0 + case "bootstrap-admin": + if *owner == "" { + fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填") + return 2 + } + plain, err := app.BootstrapAdmin(*dsn, *data, *owner, *openExisting) + if err != nil { + fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) + return 1 + } + fmt.Println(plain) + fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。") + return 0 default: fmt.Fprintln(os.Stderr, "user: 未知子命令", sub) return 2 diff --git a/internal/app/app.go b/internal/app/app.go index 46ce5a1..3e49b15 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -704,25 +704,87 @@ func Run(opt Options) error { return nil } -// CreateUserAccount CLI 建号(管理员发放制):未给密码则生成随机 12 位并返回明文(仅此一次输出)。 -func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin bool) (string, error) { - if password == "" { - password = server.RandomPassword() - } - hash, err := store.HashPassword(password) - if err != nil { - return "", err - } - if err := withStore(storageDSN, dataDir, func(st *store.Store) error { - return st.CreateAccount(owner, name, hash, isAdmin) - }); err != nil { - return "", err - } - return password, 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 + } + if err := st.CreateAccount(owner, name, "", isAdmin); err != nil { + return err + } + plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0) + if err != nil { + return err + } + out = plain + return nil + }) + return out, err +} + +// 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, "") + } + p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl) + plain = p + return err + }) + return plain, err +} + +// RevokeUserLoginKey CLI 撤销未使用登录 Key。 +func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error { + return withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { + return st.RevokeLoginKey(id) + }) +} + +// BootstrapAdmin 仅当库无任何用户时创建首位管理员并签发 activate Key。 +func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) { + var plain string + err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { + users, err := st.ListUsersAdmin() + if err != nil { + return err + } + if len(users) > 0 { + return fmt.Errorf("bootstrap-admin: users already exist (%d); refuse takeover", len(users)) + } + if err := st.CreateAccount(owner, owner, "", true); err != nil { + return err + } + p, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli-bootstrap", 0) + plain = p + return err + }) + return plain, err } // ResetUserPassword CLI 重置密码:must_change=1(首登强制改密),吊销全部会话。 -func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, error) { +func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) { if password == "" { password = server.RandomPassword() } @@ -730,7 +792,7 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, err if err != nil { return "", err } - if err := withStore(storageDSN, dataDir, func(st *store.Store) error { + if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { if err := st.SetUserPassword(owner, hash); err != nil { return err } @@ -743,9 +805,9 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, err } // ListUserAccounts CLI 用户列表。 -func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) { +func ListUserAccounts(storageDSN, dataDir string, openExisting bool) ([]store.AdminUser, error) { var out []store.AdminUser - err := withStore(storageDSN, dataDir, func(st *store.Store) error { + err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { var e error out, e = st.ListUsersAdmin() return e @@ -754,12 +816,25 @@ func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) { } func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error { + return withStoreMode(storageDSN, dataDir, false, fn) +} + +func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error { var st *store.Store var err error if storageDSN != "" { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) + if openExisting { + st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) + } else { + st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) + } } else { - st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db")) + dbPath := filepath.Join(dataDir, "agent-system.db") + if openExisting { + st, err = store.OpenExistingSQLite(dbPath) + } else { + st, err = store.OpenSQLite(dbPath) + } } if err != nil { return err diff --git a/internal/blobstore/blobstore.go b/internal/blobstore/blobstore.go index 29bdd65..ab981f1 100644 --- a/internal/blobstore/blobstore.go +++ b/internal/blobstore/blobstore.go @@ -32,6 +32,12 @@ func NewLocal(dir string) (*Local, error) { return &Local{dir: dir}, nil } +// OpenLocalExisting 绑定既有目录,不 MkdirAll。目录缺失时仍返回可用句柄 +// (账户运维只碰 DB;Put 时若目录不存在会失败)。 +func OpenLocalExisting(dir string) (*Local, error) { + return &Local{dir: dir}, nil +} + // validID 只接受纯文件名形态的对象 id:非空、不含路径分隔符、非 . / ..。 // Put 的 id 可能是上层透传的用户输入(如工件路径参数),先在此挡住路径穿越。 func validID(id string) bool { diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 6bd42b1..a753161 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -70,10 +70,41 @@ func setSessionCookie(w http.ResponseWriter, token string, maxAge int) { func authStateJSON(st store.UserAuthState) map[string]any { return map[string]any{ "owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin, - "mustChangePassword": st.MustChangePassword, "auth": true, + "mustChangePassword": st.MustChangePassword, "passwordSet": st.HasPassword, + "auth": true, } } +// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。 +func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { + if s.Auth == nil || !s.Auth.Enabled { + http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed) + return + } + var req struct { + Key string `json:"key"` + } + if err := decodeJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key)) + if err != nil { + if errors.Is(err, store.ErrConflict) { + http.Error(w, "login key already used", http.StatusConflict) + return + } + http.Error(w, "invalid or expired login key", http.StatusUnauthorized) + return + } + setSessionCookie(w, token, int(10*60)) + st, _ := s.st.GetUserAuthState(owner) + out := authStateJSON(st) + out["sessionScope"] = store.SessionScopeSetPasswordOnly + out["reloginRequired"] = false + writeJSON(w, http.StatusOK, out) +} + // login:用户名+密码 → 会话。失败统一 401(不区分账号是否存在); // 连续失败触发进程内退避(429)。 func (s *Server) login(w http.ResponseWriter, r *http.Request) { @@ -123,15 +154,16 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } -// changePassword:会话登录者改密。校验当前密码 → 新哈希落库 → 清 must_change → -// 吊销其余会话(保留当前)。重置后的首登改密走同一路径。 +// changePassword:会话登录者改密。 +// - scope=set_password_only 或 must_change/未设密:不要求 current;完成后销毁全部会话,要求重新密码登录。 +// - 普通主动改密:要求 current;保留当前会话、吊销其余。 func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { tok := sessionToken(r) if tok == "" { http.Error(w, "session login required (API keys do not own passwords)", http.StatusUnauthorized) return } - owner, ok := s.st.SessionOwner(tok) + owner, scope, ok := s.st.SessionInfo(tok) if !ok { http.Error(w, "session expired", http.StatusUnauthorized) return @@ -144,24 +176,39 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - if _, ok := s.st.VerifyLogin(owner, req.Current); !ok { - http.Error(w, "current password incorrect", http.StatusUnauthorized) - return - } if err := validPassword(req.Next); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + st0, _ := s.st.GetUserAuthState(owner) + forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword + if !forceSet { + if _, ok := s.st.VerifyLogin(owner, req.Current); !ok { + http.Error(w, "current password incorrect", http.StatusUnauthorized) + return + } + } hash, err := store.HashPassword(req.Next) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if forceSet { + if err := s.st.CompleteSetPassword(owner, hash); err != nil { + writeStoreErr(w, err) + return + } + setSessionCookie(w, "", -1) + st, _ := s.st.GetUserAuthState(owner) + out := authStateJSON(st) + out["reloginRequired"] = true + writeJSON(w, http.StatusOK, out) + return + } if err := s.st.SetUserPassword(owner, hash); err != nil { writeStoreErr(w, err) return } - // SetUserPassword 置了 must_change=1(重置语义);本人主动改密后即清除。 if err := s.st.ClearMustChangePassword(owner); err != nil { writeStoreErr(w, err) return diff --git a/internal/server/auth.go b/internal/server/auth.go index f9bc5ad..c32127e 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -14,13 +14,16 @@ type ownerCtxKey struct{} // authViaCtxKey 携带凭证通道(AuthViaKey / AuthViaSession;未启用认证与豁免路径为空)。 type authViaCtxKey struct{} +// sessionScopeCtxKey 携带会话 scope(full / set_password_only)。 +type sessionScopeCtxKey struct{} + // 凭证通道值:/api/me 的 authVia 字段;前端据此区分「改密门」只对会话登录者放行。 const ( AuthViaKey = "key" AuthViaSession = "session" ) -// Authenticator 是 API key 认证(阶段 1:user 即租户)。 +// Authenticator 是 API key 认证(阶段 1:owner 即租户)。 // Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。 // SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。 type Authenticator struct { @@ -41,41 +44,94 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(withOwner(r, store.OwnerDefault))) return } - // 豁免:静态前端(登录页须可加载)与登录入口本身。 p := r.URL.Path - if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" { + // 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。 + if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" { next.ServeHTTP(w, r) return } - // 凭证二选一:Authorization: Bearer (程序化;?key= 兼容 SSE 老客户端) - // 或会话 cookie(浏览器登录;2026-09-30 R2 管理员发放制)。 - // 失效 key 不一票否决:浏览器 localStorage 可能残留已吊销 key(前端对所有请求 - // 附加 Bearer 头),会话 cookie 仍有效时回落会话;程序化客户端不带 cookie, - // 行为不变(两者都无效才 401)。 key := bearerKey(r) if key == "" { key = r.URL.Query().Get("key") } + // 一次性登录 Key 不得经 Bearer / ?key= 使用。 + if strings.HasPrefix(key, "alk_") { + http.Error(w, "unauthorized: one-time login keys must use /api/auth/login-key/redeem", http.StatusUnauthorized) + return + } + var owner string + var via string + var scope string + ok := false if key != "" { - if owner, ok := a.st.AuthOwner(key); ok { - next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey))) + if o, keyOK := a.st.AuthOwner(key); keyOK { + owner, via, scope, ok = o, AuthViaKey, store.SessionScopeFull, true + } else if o, sc, sessOK := cookieSession(r, a.st); sessOK { + owner, via, scope, ok = o, AuthViaSession, sc, true + } else { + http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized) return } - if owner, ok := cookieOwner(r, a.st); ok { - next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession))) - return - } - http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized) + } else if o, sc, sessOK := cookieSession(r, a.st); sessOK { + owner, via, scope, ok = o, AuthViaSession, sc, true + } + if !ok { + http.Error(w, "unauthorized: login required", http.StatusUnauthorized) return } - if owner, ok := cookieOwner(r, a.st); ok { - next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession))) + ctx := withAuthVia(withOwner(r, owner), via) + ctx = context.WithValue(ctx, sessionScopeCtxKey{}, scope) + if err := a.enforcePasswordGate(w, r, owner, via, scope, p); err != nil { return } - http.Error(w, "unauthorized: login required", http.StatusUnauthorized) + next.ServeHTTP(w, r.WithContext(ctx)) }) } +func (a *Authenticator) enforcePasswordGate(w http.ResponseWriter, r *http.Request, owner, via, scope, path string) error { + allow := path == "/api/me" || path == "/api/auth/password" || path == "/api/auth/logout" || + path == "/api/health" + if scope == store.SessionScopeSetPasswordOnly { + if !allow { + http.Error(w, "forbidden: set-password session only", http.StatusForbidden) + return errGate + } + return nil + } + st, err := a.st.GetUserAuthState(owner) + if err != nil { + return nil + } + // 长期 API Key:仅当 must_change(已有密码待改)时拒绝业务,防止绕过改密门; + // 无密码的纯集成账户仍可走 Key(与历史 apikey 语义兼容)。 + if via == AuthViaKey { + if st.MustChangePassword && !allow { + http.Error(w, "forbidden: password change required", http.StatusForbidden) + return errGate + } + return nil + } + // 会话:待改密或尚未设密 → 只允许身份/设密/退出。 + if (st.MustChangePassword || !st.HasPassword) && !allow { + http.Error(w, "forbidden: password setup required", http.StatusForbidden) + return errGate + } + return nil +} + +var errGate = errorsGate{} + +type errorsGate struct{} + +func (errorsGate) Error() string { return "gate" } + +func cookieSession(r *http.Request, st *store.Store) (owner, scope string, ok bool) { + if tok := sessionToken(r); tok != "" { + return st.SessionInfo(tok) + } + return "", "", false +} + // cookieOwner 会话 cookie 有效时返回其 owner。 func cookieOwner(r *http.Request, st *store.Store) (string, bool) { if tok := sessionToken(r); tok != "" { @@ -108,6 +164,14 @@ func AuthVia(r *http.Request) string { return "" } +// SessionScope 返回当前会话 scope(非会话为空)。 +func SessionScope(r *http.Request) string { + if v, ok := r.Context().Value(sessionScopeCtxKey{}).(string); ok { + return v + } + return "" +} + func bearerKey(r *http.Request) string { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "Bearer ") { diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index 4ad0424..7c476cd 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -321,10 +321,29 @@ func TestAdminAccountFlow(t *testing.T) { return rec } - // 会话可访问 API;/api/me 携带身份与管理员位。 + // /api/me 允许 must_change;业务 API 拒绝(U4 设密门)。 if rec := session("GET", "/api/me", ""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"isAdmin":true`) { t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String()) } + 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 || + !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { + t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String()) + } + if rec := session("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("admin session must die after set-password: %d", rec.Code) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("admin relogin after set-password: %d %s", rec.Code, rec.Body.String()) + } + tok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] // 管理员建号(普通用户,初始密码)。 if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusOK { @@ -335,7 +354,7 @@ func TestAdminAccountFlow(t *testing.T) { t.Fatalf("duplicate owner must 409: %d", rec.Code) } - // alice 首登:mustChangePassword=true;改密后清除。 + // alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。 req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() @@ -358,16 +377,19 @@ func TestAdminAccountFlow(t *testing.T) { if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { t.Fatalf("alice first login must force change: %s", rec.Body.String()) } - // alice 非管理员:建号被拒 403。 if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden { - t.Fatalf("non-admin create must 403: %d", rec.Code) + t.Fatalf("must_change non-admin create must 403: %d", rec.Code) } - // alice 改密(错误当前密码 → 401;成功 → must_change 清除)。 - if rec := alice("POST", "/api/auth/password", `{"current":"bad","next":"alice-final-9"}`); rec.Code != http.StatusUnauthorized { - t.Fatalf("wrong current password: %d", rec.Code) + // 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":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { - t.Fatalf("alice change password: %d %s", rec.Code, rec.Body.String()) + if rec := alice("POST", "/api/auth/password", `{"current":"ignored","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()) + } + if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("alice session must die after set-password: %d", rec.Code) } // 旧密码不能再登录;新密码可以且无需改密。 req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`)) @@ -377,6 +399,18 @@ func TestAdminAccountFlow(t *testing.T) { if rec.Code != http.StatusUnauthorized { t.Fatalf("old password must fail after change: %d", rec.Code) } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("alice relogin: %d %s", rec.Code, rec.Body.String()) + } + aliceTok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] + // 已设密普通用户:建号仍 403(非管理员)。 + if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden { + 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 { diff --git a/internal/server/login_key_test.go b/internal/server/login_key_test.go new file mode 100644 index 0000000..84f2503 --- /dev/null +++ b/internal/server/login_key_test.go @@ -0,0 +1,65 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "agent-system-backend/internal/store" +) + +func TestRedeemLoginKeyRestrictedSession(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + if err := st.CreateAccount("carol", "", "", false); err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("carol", store.LoginKeyPurposeActivate, "test", 0) + if err != nil { + t.Fatal(err) + } + body, _ := json.Marshal(map[string]string{"key": plain}) + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String()) + } + cookie := rec.Result().Cookies() + var sess string + for _, c := range cookie { + if c.Name == SessionCookie { + sess = c.Value + } + } + if sess == "" { + t.Fatal("missing session cookie") + } + // 业务拒绝 + req = httptest.NewRequest("GET", "/api/projects", nil) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("projects want 403, got %d", rec.Code) + } + // me 允许 + req = httptest.NewRequest("GET", "/api/me", nil) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("me: %d", rec.Code) + } + // 设密后要求重新登录 + pwBody, _ := json.Marshal(map[string]string{"next": "carol-final-99"}) + req = httptest.NewRequest("POST", "/api/auth/password", bytes.NewReader(pwBody)) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(`"reloginRequired":true`)) { + t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index fc9478a..1e53ebc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -251,6 +251,7 @@ 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/logout", s.logout) mux.HandleFunc("POST /api/auth/password", s.changePassword) mux.HandleFunc("GET /api/admin/users", s.listAdminUsers) @@ -634,9 +635,13 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { name = u.Name } out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled, "authVia": AuthVia(r)} + if sc := SessionScope(r); sc != "" { + out["sessionScope"] = sc + } if st, err := s.st.GetUserAuthState(owner); err == nil { out["isAdmin"] = st.IsAdmin out["mustChangePassword"] = st.MustChangePassword + out["passwordSet"] = st.HasPassword } writeJSON(w, http.StatusOK, out) } diff --git a/internal/store/auth.go b/internal/store/auth.go index 66cb233..0161cb7 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -218,7 +218,8 @@ func VerifyPassword(hash, password string) bool { return subtle.ConstantTimeCompare(got, want) == 1 } -// CreateAccount 管理员建号:owner 唯一;初始密码 must_change=1。 +// CreateAccount 管理员建号:owner 唯一。 +// passwordHash 非空时 must_change=1;空哈希表示未设密(走激活 Key),must_change=0。 func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error { if _, err := s.GetUser(owner); err == nil { return ErrConflict @@ -226,10 +227,15 @@ func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) er if err := s.EnsureUser(owner, name); err != nil { return err } + mustChange := passwordHash != "" + setAt := "" + if passwordHash != "" { + setAt = nowISO() + } // 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍, // PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。 _, err := s.db.Exec(s.q(`UPDATE users SET name=?, password_hash=?, password_set_at=?, must_change_password=?, is_admin=? WHERE owner=?`), - name, passwordHash, nowISO(), true, isAdmin, owner) + name, passwordHash, setAt, mustChange, isAdmin, owner) return err } @@ -294,35 +300,56 @@ func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) { // ---- 会话 ---- -// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间。 +// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间;scope=full。 func (s *Store) CreateSession(owner string) (string, error) { + return s.CreateSessionScoped(owner, SessionScopeFull, sessionTTL) +} + +// CreateSessionScoped 按 scope 与 TTL 建会话。 +func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (string, error) { + if scope == "" { + scope = SessionScopeFull + } + if ttl <= 0 { + ttl = sessionTTL + } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } token := "sess_" + hex.EncodeToString(raw) - exp := time.Now().Add(sessionTTL) - _, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)`), - hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO()) + exp := time.Now().Add(ttl) + _, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope) VALUES(?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope) return token, err } // SessionOwner 校验会话:命中且未过期返回 owner(尽力刷新 last_seen)。 func (s *Store) SessionOwner(token string) (string, bool) { + owner, _, ok := s.SessionInfo(token) + return owner, ok +} + +// SessionInfo 返回 owner 与 scope。 +func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) { if token == "" { - return "", false + return "", "", false } - var owner, exp string - err := s.db.QueryRow(s.q(`SELECT owner,expires_at FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner, &exp) + var exp string + err := s.db.QueryRow(s.q(`SELECT owner,expires_at,COALESCE(scope,'full') FROM sessions WHERE token_hash=?`), hashKey(token)). + Scan(&owner, &exp, &scope) if err != nil { - return "", false + return "", "", false } if expT, err := time.Parse(time.RFC3339Nano, exp); err != nil || time.Now().After(expT) { s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token)) - return "", false + return "", "", false } s.db.Exec(s.q(`UPDATE sessions SET last_seen_at=? WHERE token_hash=?`), nowISO(), hashKey(token)) - return owner, true + if scope == "" { + scope = SessionScopeFull + } + return owner, scope, true } // DeleteSession 注销当前会话。 diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go new file mode 100644 index 0000000..3a3f4ad --- /dev/null +++ b/internal/store/login_keys.go @@ -0,0 +1,195 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" +) + +// 一次性登录凭据(与长期 api_keys 分离)。明文前缀 alk_;库只存 SHA-256。 + +const ( + LoginKeyPurposeActivate = "activate" + LoginKeyPurposeRecover = "recover" + + LoginKeyStatusUnused = "unused" + LoginKeyStatusConsumed = "consumed" + LoginKeyStatusRevoked = "revoked" + LoginKeyStatusExpired = "expired" + + SessionScopeFull = "full" + SessionScopeSetPasswordOnly = "set_password_only" + + defaultLoginKeyTTL = 30 * time.Minute + minLoginKeyTTL = 5 * time.Minute + maxLoginKeyTTL = 120 * time.Minute + restrictedSessionTTL = 10 * time.Minute +) + +// LoginKeyMeta 对外可见元数据(无明文)。 +type LoginKeyMeta struct { + ID string `json:"id"` + Owner string `json:"owner"` + Purpose string `json:"purpose"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expiresAt"` + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy,omitempty"` +} + +// IssueLoginKey 签发一次性登录 Key;撤销同 owner+purpose 的 unused 旧 Key。 +// 返回明文(仅此一次)与元数据。 +func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duration) (plain string, meta LoginKeyMeta, err error) { + if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover { + return "", meta, fmt.Errorf("login key: invalid purpose %q", purpose) + } + if _, err := s.GetUser(owner); err != nil { + return "", meta, err + } + if ttl <= 0 { + ttl = defaultLoginKeyTTL + } + if ttl < minLoginKeyTTL { + ttl = minLoginKeyTTL + } + if ttl > maxLoginKeyTTL { + ttl = maxLoginKeyTTL + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", meta, err + } + idRaw := make([]byte, 8) + if _, err := rand.Read(idRaw); err != nil { + return "", meta, err + } + plain = "alk_" + hex.EncodeToString(raw) + id := "lk_" + hex.EncodeToString(idRaw) + now := time.Now().UTC() + exp := now.Add(ttl) + tx, err := s.db.Begin() + if err != nil { + return "", meta, err + } + defer tx.Rollback() + 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 + } + 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 { + return "", meta, err + } + if err := tx.Commit(); err != nil { + return "", meta, err + } + meta = LoginKeyMeta{ID: id, Owner: owner, Purpose: purpose, Status: LoginKeyStatusUnused, ExpiresAt: exp, CreatedAt: now, CreatedBy: createdBy} + return plain, meta, nil +} + +// RevokeLoginKey 按凭据编号撤销未使用 Key。 +func (s *Store) RevokeLoginKey(id string) error { + res, err := s.db.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), + LoginKeyStatusRevoked, id, LoginKeyStatusUnused) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// RedeemLoginKey 原子消费:unused+未过期 → consumed,并创建 set_password_only 会话。 +// 明文不得走 Bearer;调用方专用 HTTP 入口。失败整体回滚。 +func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err error) { + 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) + if errors.Is(err, sql.ErrNoRows) { + return "", "", ErrNotFound + } + if err != nil { + return "", "", err + } + 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 { + return "", "", ErrConflict + } + res, err := tx.Exec(s.q(`UPDATE login_keys SET status=?, consumed_at=? WHERE id=? AND status=?`), + LoginKeyStatusConsumed, nowISO(), id, LoginKeyStatusUnused) + if err != nil { + return "", "", err + } + if n, _ := res.RowsAffected(); n != 1 { + return "", "", ErrConflict + } + 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) VALUES(?,?,?,?,?,?)`), + hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly); err != nil { + return "", "", err + } + if err := tx.Commit(); err != nil { + return "", "", err + } + return token, o, nil +} + +// CompleteSetPassword 设密事务:写 hash、清 must_change、作废未用登录 Key、删除全部会话。 +func (s *Store) CompleteSetPassword(owner, passwordHash string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`), + passwordHash, nowISO(), false, owner) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + return tx.Commit() +} + +// CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。 +func (s *Store) CountUnusedLoginKeys(owner string) (int, error) { + var n int + err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM login_keys WHERE owner=? AND status=?`), owner, LoginKeyStatusUnused).Scan(&n) + return n, err +} + +// RevokeAllAPIKeys 失陷恢复:吊销 owner 全部长期 API Key。 +func (s *Store) RevokeAllAPIKeys(owner string) error { + _, err := s.db.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), owner) + return err +} diff --git a/internal/store/login_keys_test.go b/internal/store/login_keys_test.go new file mode 100644 index 0000000..dc048b8 --- /dev/null +++ b/internal/store/login_keys_test.go @@ -0,0 +1,85 @@ +package store + +import ( + "path/filepath" + "sync" + "sync/atomic" + "testing" +) + +func TestOpenExistingSQLiteMissing(t *testing.T) { + _, err := OpenExistingSQLite(filepath.Join(t.TempDir(), "nope.db")) + if err == nil { + t.Fatal("expected missing file error") + } +} + +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) + } + tok, owner, err := st.RedeemLoginKey(plain) + if err != nil || owner != "alice" || tok == "" { + t.Fatalf("redeem: %v owner=%s", err, owner) + } + if _, _, err := st.RedeemLoginKey(plain); err == nil { + t.Fatal("replay should fail") + } + o, scope, ok := st.SessionInfo(tok) + if !ok || o != "alice" || scope != SessionScopeSetPasswordOnly { + t.Fatalf("session info %v %q %q", ok, o, scope) + } + hash, err := HashPassword("new-password-9") + if err != nil { + t.Fatal(err) + } + if err := st.CompleteSetPassword("alice", hash); err != nil { + t.Fatal(err) + } + if _, _, ok := st.SessionInfo(tok); ok { + t.Fatal("session should be gone after set password") + } + stAuth, ok := st.VerifyLogin("alice", "new-password-9") + if !ok || stAuth.MustChangePassword || !stAuth.HasPassword { + t.Fatalf("login after set: ok=%v %+v", ok, stAuth) + } +} + +func TestLoginKeyConcurrentRedeem(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "c.db")) + if err != nil { + 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) + } + var okCount atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, _, err := st.RedeemLoginKey(plain); err == nil { + okCount.Add(1) + } + }() + } + wg.Wait() + if n := okCount.Load(); n != 1 { + t.Fatalf("expected 1 success, got %d", n) + } +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 51959bd..3656ab5 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -712,6 +712,48 @@ func init() { `ALTER TABLE knowledge_vectors DROP COLUMN content_hash`, `ALTER TABLE knowledge_vectors DROP COLUMN model`, }}}, + migration{version: 26, name: "login_keys_session_scope", + // 一次性登录 Key(激活/恢复)与会话 scope(full | set_password_only)。 + up: map[string][]string{"sqlite": { + `CREATE TABLE IF NOT EXISTS login_keys ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + purpose TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + status TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + instance_id TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX IF NOT EXISTS idx_login_keys_owner_purpose ON login_keys(owner, purpose, status)`, + `ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'`, + }, "postgres": { + `CREATE TABLE IF NOT EXISTS login_keys ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + purpose TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + status TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + instance_id TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX IF NOT EXISTS idx_login_keys_owner_purpose ON login_keys(owner, purpose, status)`, + `ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'`, + }}, + down: map[string][]string{"sqlite": { + `ALTER TABLE sessions DROP COLUMN scope`, + `DROP INDEX IF EXISTS idx_login_keys_owner_purpose`, + `DROP TABLE IF EXISTS login_keys`, + }, "postgres": { + `ALTER TABLE sessions DROP COLUMN scope`, + `DROP INDEX IF EXISTS idx_login_keys_owner_purpose`, + `DROP TABLE IF EXISTS login_keys`, + }}}, ) } diff --git a/internal/store/store.go b/internal/store/store.go index d5e1cd3..ba64e01 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -87,6 +87,22 @@ func OpenSQLite(path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } + return openSQLiteFile(path, true) +} + +// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录。 +// 仍执行兼容迁移(requireCompatible);用于账户运维,避免恢复操作顺带建库。 +func OpenExistingSQLite(path string) (*Store, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("open existing: sqlite file missing: %s", path) + } + return nil, err + } + return openSQLiteFile(path, false) +} + +func openSQLiteFile(path string, createArtifacts bool) (*Store, error) { // _txlock=immediate:写事务在 BEGIN 即取库写锁(而非首个写语句升级), // 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。 // busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s @@ -98,7 +114,7 @@ func OpenSQLite(path string) (*Store, error) { } // SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。 db.SetMaxOpenConns(1) - return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}) + return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts) } // OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。 @@ -116,6 +132,15 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St // OpenPostgresWithOptions 按 KTD12 连接预算打开 PG 档;QueryTimeoutSec>0 时 // 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。 func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) { + return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true) +} + +// OpenExistingPostgres 打开既有 PG 库并执行兼容迁移;不创建 artifacts 目录。 +func OpenExistingPostgres(dsn, artifactsDir string) (*Store, error) { + return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false) +} + +func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts bool) (*Store, error) { pool = pool.effective() if pool.QueryTimeoutSec > 0 { dsn = withPGRuntimeParam(dsn, "statement_timeout", strconv.Itoa(pool.QueryTimeoutSec*1000)) @@ -130,7 +155,7 @@ func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs b if pool.ConnMaxLifetimeSec > 0 { db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second) } - return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool) + return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts) } // withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。 @@ -156,10 +181,14 @@ func AppendPGRuntimeParam(dsn, key, value string) string { return dsn + sep + kv } -func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions) (*Store, error) { +func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) { if blobs == nil { var err error - blobs, err = blobstore.NewLocal(artifactsDir) + if createArtifacts { + blobs, err = blobstore.NewLocal(artifactsDir) + } else { + blobs, err = blobstore.OpenLocalExisting(artifactsDir) + } if err != nil { db.Close() return nil, err @@ -170,9 +199,11 @@ func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Stor db.Close() return nil, err } - if err := os.MkdirAll(artifactsDir, 0o755); err != nil { - db.Close() - return nil, err + if createArtifacts { + if err := os.MkdirAll(artifactsDir, 0o755); err != nil { + db.Close() + return nil, err + } } return s, nil }