Merge pull request 'Add one-time login keys, restricted sessions, and OpenExisting' (#171) from feature/svc-deploy-u4-login-key into main
ci / vet-and-test (push) Failing after 43s

Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/171
This commit was merged in pull request #171.
This commit is contained in:
2026-10-12 05:00:47 +00:00
13 changed files with 821 additions and 85 deletions
+69 -9
View File
@@ -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 <create|reset-password|list> [--owner x --name y --admin --password p]")
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list|issue-login-key|revoke-login-key|bootstrap-admin> [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
+96 -21
View File
@@ -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
+6
View File
@@ -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 {
+56 -9
View File
@@ -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
+82 -18
View File
@@ -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 <api key>(程序化;?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 ") {
+43 -9
View File
@@ -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 {
+65
View File
@@ -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())
}
}
+5
View File
@@ -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)
}
+39 -12
View File
@@ -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 注销当前会话。
+195
View File
@@ -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
}
+85
View File
@@ -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)
}
}
+42
View File
@@ -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`,
}}},
)
}
+38 -7
View File
@@ -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
}